diff --git a/decnet/bus/topics.py b/decnet/bus/topics.py index 49457341..d70f4227 100644 --- a/decnet/bus/topics.py +++ b/decnet/bus/topics.py @@ -14,6 +14,9 @@ Token structure (NATS-style, dot-separated): attacker.scored attacker.session.started attacker.session.ended + identity.formed + identity.observation.linked + identity.merged credential.captured credential.reuse.detected system.log @@ -33,6 +36,7 @@ from __future__ import annotations TOPOLOGY = "topology" DECKY = "decky" ATTACKER = "attacker" +IDENTITY = "identity" SYSTEM = "system" CREDENTIAL = "credential" @@ -83,6 +87,27 @@ ATTACKER_SESSION_ENDED = "session.ended" # provider summary so SIEM-bound webhooks don't need to re-query the DB. ATTACKER_INTEL_ENRICHED = "intel.enriched" +# Identity-resolution event types (second/third tokens under ``identity``). +# Published by the (future) clusterer worker — see +# development/IDENTITY_RESOLUTION.md. Constants ship in this commit; +# no publishers exist yet, but consumers (webhook worker, dashboard +# SSE relay) can subscribe to ``identity.>`` from day one and receive +# events the instant the clusterer comes online. +# +# identity.formed — clusterer creates a new identity from +# one or more observations +# identity.observation.linked — observation attached to an existing +# identity (or reattached from another) +# identity.merged — two identities collapsed; loser gets +# ``merged_into_uuid`` set, subscribers +# re-key cached references to the winner +# +# ``identity.campaign.assigned`` is deferred; it ships when the campaign +# clusterer ships. YAGNI before then. +IDENTITY_FORMED = "formed" +IDENTITY_OBSERVATION_LINKED = "observation.linked" +IDENTITY_MERGED = "merged" + # Credential event types (second/third tokens under ``credential``). # ``credential.captured`` fires once per upserted Credential row — the # correlator listens for it and runs the cred-reuse query in response, @@ -186,6 +211,19 @@ def attacker(event_type: str) -> str: return f"{ATTACKER}.{event_type}" +def identity(event_type: str) -> str: + """Build ``identity.``. + + *event_type* is typically one of :data:`IDENTITY_FORMED`, + :data:`IDENTITY_OBSERVATION_LINKED`, :data:`IDENTITY_MERGED`. Dotted + leaves (``observation.linked``) are permitted — same rationale as + :func:`system`. + """ + if not event_type: + raise ValueError("identity topic requires a non-empty event_type") + return f"{IDENTITY}.{event_type}" + + def system_health(worker: str) -> str: """Build ``system..health``. diff --git a/tests/bus/test_topics.py b/tests/bus/test_topics.py index f6b16526..36c21cc5 100644 --- a/tests/bus/test_topics.py +++ b/tests/bus/test_topics.py @@ -72,3 +72,17 @@ def test_system_control_builder() -> None: def test_system_control_rejects_bad_segments(bad: str) -> None: with pytest.raises(ValueError): topics.system_control(bad) + + +# ─── Identity resolution topics (commit 4 of IDENTITY_RESOLUTION.md) ───────── + + +def test_identity_builder() -> None: + assert topics.identity(topics.IDENTITY_FORMED) == "identity.formed" + assert topics.identity(topics.IDENTITY_OBSERVATION_LINKED) == "identity.observation.linked" + assert topics.identity(topics.IDENTITY_MERGED) == "identity.merged" + + +def test_identity_builder_rejects_empty() -> None: + with pytest.raises(ValueError): + topics.identity("")