feat(correlation/attribution): substrate + idle handler (Phase 1)

v0 Phase 1 of ATTRIBUTION-ENGINE.md:

* AttributionStateRow SQLModel keyed on (identity_uuid, primitive)
  per ANTI direction — re-keying state rows when the v1 clusterer
  merges attackers is the migration debt v0 should not bake in.
  ATTRIBUTION-ENGINE.md updated with the deviation note.
* AttributionMixin: ensure_stub_identity_for_attacker, idempotent
  upsert_attribution_state, get_attribution_state[_for_identity],
  list_multi_actor_identities (the Phase 5 correlator's read).
* attribution.profile.{state_changed,multi_actor_suspected} bus
  topics + builder; wiki Service-Bus.md updated separately.
* attribution_worker.py: subscribes to attacker.observation.>,
  ensures stub identity per event, logs and continues. No merger,
  no state writes, no derived events — Phase 4 wires those.
* attribution/{aggregate.py,_thresholds.py} skeletons: Phase 2
  fills _aggregate_categorical, Phase 3 adds numeric+hash+dispatcher.
This commit is contained in:
2026-05-08 23:16:13 -04:00
parent e94ab608d9
commit c2891d6cca
15 changed files with 1203 additions and 0 deletions

View File

@@ -0,0 +1,169 @@
"""Phase 1 attribution worker — wiring smoke tests.
The Phase 1 worker subscribes to ``attacker.observation.>`` and, for
each event, ensures the source attacker has a stub identity row.
That's it — no merger, no state writes, no derived events. These
tests pin the wiring + the stub-materialisation contract.
Phase 4 will extend with end-to-end state-row + transition-event
assertions.
"""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import pytest
from decnet.bus.fake import FakeBus
from decnet.correlation import attribution_worker as _aw
from decnet.web.db.factory import get_repository
@pytest.fixture
async def repo(tmp_path: Path):
r = get_repository(db_path=str(tmp_path / "attribution_wiring.db"))
await r.initialize()
return r
@pytest.fixture
async def attacker_uuid(repo) -> str:
now = datetime.now(timezone.utc)
return await repo.upsert_attacker({
"ip": "10.0.0.42",
"first_seen": now,
"last_seen": now,
})
def _make_event(payload: dict[str, Any]) -> Any:
"""Light Event-shaped object — the handler reads ``.payload``
via ``getattr`` and falls back to dicts. We pass a dict directly
because that's what tests give the BEHAVE handler too."""
return payload
@pytest.mark.anyio
async def test_handle_event_creates_stub_for_known_attacker(
repo, attacker_uuid: str,
) -> None:
"""First observation for an attacker → stub identity created and
stamped onto the Attacker row."""
bus = FakeBus()
await bus.connect()
payload = {
"attacker_uuid": attacker_uuid,
"primitive": "motor.input_modality",
"value": "pasted",
"ts": 1714000000.0,
"confidence": 0.9,
}
await _aw.handle_observation_event(bus, repo, _make_event(payload))
attacker = await repo.get_attacker_by_uuid(attacker_uuid)
assert attacker is not None
assert attacker["identity_id"] is not None
# Second event re-uses the same stub.
await _aw.handle_observation_event(bus, repo, _make_event(payload))
attacker_again = await repo.get_attacker_by_uuid(attacker_uuid)
assert attacker_again["identity_id"] == attacker["identity_id"]
await bus.close()
@pytest.mark.anyio
async def test_handle_event_defers_for_missing_attacker(repo) -> None:
"""No Attacker row yet → handler returns without raising and
without inserting an orphan identity (the worker treats this as
'profiler hasn't materialised the attacker, defer')."""
bus = FakeBus()
await bus.connect()
payload = {
"attacker_uuid": "00000000000000000000000000000000",
"primitive": "motor.input_modality",
"value": "pasted",
"ts": 1714000000.0,
"confidence": 0.9,
}
# Should NOT raise.
await _aw.handle_observation_event(bus, repo, _make_event(payload))
# No identities materialised.
identities = await repo.list_all_identities()
assert identities == []
await bus.close()
@pytest.mark.anyio
async def test_handle_event_skips_malformed_payload(
repo, attacker_uuid: str,
) -> None:
"""Missing attacker_uuid or primitive → log + continue, never
raise. Bus delivery is at-least-once; bad payloads must not
poison the consumer."""
bus = FakeBus()
await bus.connect()
for bad in (
{"primitive": "motor.input_modality"}, # missing attacker_uuid
{"attacker_uuid": attacker_uuid}, # missing primitive
{}, # both missing
):
await _aw.handle_observation_event(bus, repo, _make_event(bad))
# No identity materialised because every payload was rejected
# before the stub helper ran.
attacker = await repo.get_attacker_by_uuid(attacker_uuid)
assert attacker is not None
assert attacker["identity_id"] is None
await bus.close()
@pytest.mark.anyio
async def test_handle_event_idempotent_per_observation(
repo, attacker_uuid: str,
) -> None:
"""Hammer the same payload N times — one stub identity, no
duplicate rows, no exception."""
bus = FakeBus()
await bus.connect()
payload = {
"attacker_uuid": attacker_uuid,
"primitive": "motor.input_modality",
"value": "pasted",
"ts": 1714000000.0,
"confidence": 0.9,
}
for _ in range(5):
await _aw.handle_observation_event(bus, repo, _make_event(payload))
identities = await repo.list_all_identities()
assert len(identities) == 1
await bus.close()
@pytest.mark.anyio
async def test_event_object_payload_attribute(
repo, attacker_uuid: str,
) -> None:
"""Real bus events carry payload on ``.payload``; the handler
must follow the attribute, not assume the event itself is the
dict."""
class _Evt:
def __init__(self, payload: dict[str, Any]) -> None:
self.payload = payload
bus = FakeBus()
await bus.connect()
payload = {
"attacker_uuid": attacker_uuid,
"primitive": "cognitive.feedback_loop_engagement",
"value": "closed_loop",
"ts": 1714000000.0,
"confidence": 0.85,
}
await _aw.handle_observation_event(bus, repo, _Evt(payload))
attacker = await repo.get_attacker_by_uuid(attacker_uuid)
assert attacker is not None
assert attacker["identity_id"] is not None
await bus.close()