feat(clustering): campaign-clusterer worker + bus topics + CLI
The campaign clusterer worker mirrors the identity-side worker shell (bus connect, heartbeat, control listener, slow-tick fallback) but wakes on identity.> instead of attacker.> — campaign-level work is gated on identity-layer changes, not raw observations. The connected-components implementation reads identities via list_identities_for_clustering, projects them with from_identity_row, runs union-find over combined_campaign_weight, writes campaigns rows, sets attacker_identities.campaign_id, and runs the same revocable- merge pass as the identity layer (a merged-out campaign whose identities no longer co-cluster with the winner gets revoked). Bus: adds campaign.> family (formed / identity.assigned / merged / unmerged) plus the cross-family identity.campaign.assigned so existing identity-stream subscribers see the badge update without having to subscribe to campaign.>. Wiki Service-Bus.md updated in wiki-checkout in the same wave per the project's bus-signals discipline. CLI: decnet campaign-clusterer registered as master-only via MASTER_ONLY_COMMANDS; --poll-interval / --daemon mirror the identity clusterer command surface.
This commit is contained in:
@@ -18,6 +18,11 @@ Token structure (NATS-style, dot-separated):
|
||||
identity.observation.linked
|
||||
identity.merged
|
||||
identity.unmerged
|
||||
identity.campaign.assigned
|
||||
campaign.formed
|
||||
campaign.identity.assigned
|
||||
campaign.merged
|
||||
campaign.unmerged
|
||||
credential.captured
|
||||
credential.reuse.detected
|
||||
system.log
|
||||
@@ -38,6 +43,7 @@ TOPOLOGY = "topology"
|
||||
DECKY = "decky"
|
||||
ATTACKER = "attacker"
|
||||
IDENTITY = "identity"
|
||||
CAMPAIGN = "campaign"
|
||||
SYSTEM = "system"
|
||||
CREDENTIAL = "credential"
|
||||
|
||||
@@ -117,6 +123,33 @@ IDENTITY_FORMED = "formed"
|
||||
IDENTITY_OBSERVATION_LINKED = "observation.linked"
|
||||
IDENTITY_MERGED = "merged"
|
||||
IDENTITY_UNMERGED = "unmerged"
|
||||
# Campaign-clusterer cross-family event — fires under ``identity.>`` so
|
||||
# identity-stream subscribers (e.g. the IdentityDetail SSE client) get
|
||||
# notified the moment an identity's ``campaign_id`` changes without
|
||||
# having to subscribe to the campaign topic family. The same event
|
||||
# fires under ``campaign.identity.assigned`` for campaign-side
|
||||
# subscribers.
|
||||
IDENTITY_CAMPAIGN_ASSIGNED = "campaign.assigned"
|
||||
|
||||
# Campaign-clusterer event types (second/third tokens under
|
||||
# ``campaign``). Mirror of the identity family at the layer above:
|
||||
# campaigns group identities into operations, and the clusterer
|
||||
# publishes the same form / link / merge / unmerge lifecycle.
|
||||
#
|
||||
# campaign.formed — clusterer creates a new campaign from
|
||||
# one or more identities
|
||||
# campaign.identity.assigned — identity attached to an existing
|
||||
# campaign (or reassigned from another)
|
||||
# campaign.merged — two campaigns collapsed; loser gets
|
||||
# ``merged_into_uuid`` set, subscribers
|
||||
# re-key cached references to the winner
|
||||
# campaign.unmerged — revocable-merge undo: contradicting
|
||||
# evidence cleared ``merged_into_uuid``
|
||||
# and re-split identities
|
||||
CAMPAIGN_FORMED = "formed"
|
||||
CAMPAIGN_IDENTITY_ASSIGNED = "identity.assigned"
|
||||
CAMPAIGN_MERGED = "merged"
|
||||
CAMPAIGN_UNMERGED = "unmerged"
|
||||
|
||||
# Credential event types (second/third tokens under ``credential``).
|
||||
# ``credential.captured`` fires once per upserted Credential row — the
|
||||
@@ -221,6 +254,19 @@ def attacker(event_type: str) -> str:
|
||||
return f"{ATTACKER}.{event_type}"
|
||||
|
||||
|
||||
def campaign(event_type: str) -> str:
|
||||
"""Build ``campaign.<event_type>``.
|
||||
|
||||
*event_type* is typically one of :data:`CAMPAIGN_FORMED`,
|
||||
:data:`CAMPAIGN_IDENTITY_ASSIGNED`, :data:`CAMPAIGN_MERGED`, or
|
||||
:data:`CAMPAIGN_UNMERGED`. Dotted leaves (``identity.assigned``)
|
||||
are permitted — same rationale as :func:`system`.
|
||||
"""
|
||||
if not event_type:
|
||||
raise ValueError("campaign topic requires a non-empty event_type")
|
||||
return f"{CAMPAIGN}.{event_type}"
|
||||
|
||||
|
||||
def identity(event_type: str) -> str:
|
||||
"""Build ``identity.<event_type>``.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user