# Attribution Engine — Design **Status:** pre-implementation. This doc is the spec; code follows. **Tracks:** DEBT-051 (cross-session BEHAVE primitive aggregation — named in `BEHAVE-INTEGRATION.md`). **Depends on:** `IDENTITY_RESOLUTION.md` (substrate shipped — table, FK, lifecycle topics), `BEHAVE-INTEGRATION.md` (observation producer), `DEBT-032` (fingerprint rotation, shipped). **Engine home:** this repo, `decnet/correlation/attribution/` (sublibrary inside the existing correlation worker — no new daemon). ## Premise DECNET has three layers stacked above raw events. After `BEHAVE-INTEGRATION.md` ships, we have: | Layer | What it stores | What it knows | |---|---|---| | **Observation** | `observations` table, one row per (sid, primitive) | "I saw value V for primitive P, sourced from session S, at time T, with confidence C." | | **Attacker** | `attackers` table, one row per source IP | "These observations all came from IP X." | | **Identity** | `attacker_identities` table (empty today — `IDENTITY_RESOLUTION.md`) | "These N attacker rows are the same hands." | BEHAVE *emits*. Attackers are *observed*. The attribution engine is the layer that **concludes** — it links observations into identities and surfaces a per-identity primitive map with explicit merge semantics. This doc specifies it. ## The bright line — lifted from BEHAVE, binding here The BEHAVE envelope module docstring (`core/decnet_behave_core/spec/envelope.py:20-26`) draws an explicit bright line: > Explicitly NOT for: identity attribution to named natural persons; > access or admission decisions; biometric login; ML-driven user > identification. Those framings push into legal/ethics territory the > project will not walk into by accident. That binding statement carries forward. The attribution engine: - **Links observations to opaque identity UUIDs**, never to named persons. - **Emits probabilistic linkage**, never certainty. - **Does not gate access** to anything — it's an analytics surface. - **Does not output classifier verdicts** about "good" vs "bad" operators; it surfaces *behavioural coherence* (these observations cluster) and *behavioural drift* (this identity's primitives are changing), and stops there. Crossing this line is grounds for ripping the engine out and starting over. ## What the engine IS, what it IS NOT | IS | IS NOT | |---|---| | A clusterer + state machine over BEHAVE observations | A keystroke-dynamics extractor (that's the engine in `BEHAVE-EXTRACTOR.md`) | | The thing that writes `attacker_identities` rows | The thing that decides whether to block/alert/page on an attacker | | The producer of `attribution.profile.*` events | The producer of `attacker.observation.*` events | | Honest about uncertainty (every claim carries a confidence) | A binary classifier with an arbitrary threshold | | Replayable / deterministic given the same observation sequence | A black-box ML model | ## Architectural placement ``` /home/anti/Tools/DECNET/ ├── decnet/correlation/ EXISTING worker — gains a sublibrary + a new trigger │ ├── worker.py gains attacker.observation.* subscription │ ├── fingerprint_rotation.py UNCHANGED — already shipped (DEBT-032) │ └── attribution/ NEW — pure attribution library │ ├── __init__.py exposes link_observation(), aggregate_identity() │ ├── linkage.py "which identity does this observation belong to?" │ ├── aggregate.py per-(identity, primitive) merge state machine │ ├── _signals/ per-signal scorers (jarm, hassh, kd, c2, ip) │ └── _thresholds.py named constants, calibration-cited └── decnet/web/db/models/ ├── attacker_identities.py EXISTING (IDENTITY_RESOLUTION.md substrate) └── attribution_state.py NEW — per-(identity, primitive) state rows ``` **No new worker.** The existing `decnet-correlation.service` supervises this codepath. The correlation worker already owns cross-attacker reasoning (DEBT-032 fingerprint rotation lives there). Attribution is a natural peer. **Audit finding (correlation vs profiler).** Profiler emits observations per-session (BEHAVE-SHELL extraction). Correlation consumes observations across sessions and decides identity. Two roles, two workers, clean cut. **Don't mix them.** ## Two responsibilities, kept separate The engine has **two axes of work**, often confused: ### Axis 1 — Linkage > "This new observation arrived. Which identity does it belong to?" Inputs: one observation (just arrived) + the existing identity table. Output: one of {`assign-to-existing(uuid)`, `create-new()`, `defer(reason)`}. Lives in `attribution/linkage.py`. Reads `attacker.observation.*` events; writes `attacker_identities` rows and `attackers.identity_id` FK; emits `identity.formed` / `identity.observation.linked` (existing topics from `IDENTITY_RESOLUTION.md`). ### Axis 2 — Aggregation > "Given an identity's full observation history, what's the > per-primitive summary I should surface to AttackerDetail / > IdentityDetail?" Inputs: all observations linked to one identity. Output: a per-primitive state map: `{primitive: (current_value, state, confidence, dispersion)}` where `state ∈ {stable, drifting, conflicted, multi_actor, unknown}`. Lives in `attribution/aggregate.py`. Pure function — given the same observation set, returns the same state map (replayability is non-negotiable). **These two axes are separable.** v0 ships **aggregation only** (over single-`attacker_uuid` proto-identities), solves DEBT-051. v1 adds linkage (real clustering across attacker_uuids). v2 adds federation. This ordering is deliberate — aggregation has narrower failure modes and doesn't require the linkage signals to be calibrated yet. ## v0 / v1 / v2 ladder ### v0 — Aggregation over per-attacker proto-identities The substrate of `IDENTITY_RESOLUTION.md` ships empty: every `attackers` row has `identity_id = NULL`. No clusterer means no identity rows. v0 sidesteps this honestly: **treat each `attacker_uuid` as its own proto-identity** and aggregate observations over it. What v0 delivers: - Per-(attacker_uuid, primitive) merge state machine. - New `attribution_state` table holding the derived state. - New `attribution.profile.*` bus topics emitting state transitions. - AttackerDetail's "current state" panel gains state badges (`stable / drifting / conflicted`) replacing today's naïve latest-wins surface from `BEHAVE-INTEGRATION.md` Q3. What v0 does NOT do: - No clustering across IPs. - No identity rows ever populated. - `IdentityDetail.tsx` (already built per `IDENTITY_RESOLUTION.md`) stays unreached — there are no identities yet. **v0 closes DEBT-051.** That's the explicit scope. ### v1 — Linkage (real clustering) What changes: - Clusterer subscribes to high-confidence rotation-resistant signals (HASSH, payload simhashes, keystroke-dynamics simhash, C2 callbacks) and groups `attacker_uuid`s under `attacker_identities.uuid`. - v0's aggregation engine retargets from `attacker_uuid` to `identity_uuid` once a cluster forms. - `identity.formed` / `identity.observation.linked` / `identity.merged` (existing topics) start firing. - IdentityDetail.tsx starts seeing rows. What v1 does NOT do: - No federation. Cluster decisions are master-local. - No retroactive observation re-linking once an identity is committed (that's a v1.5 problem, "stable" identities should be hard to un-link silently). ### v2 — Federation gossip What changes: - Identities + their primitive-state maps gossip over the existing swarm mTLS infra to peer masters. - `schema_version` field on `attacker_identities` (`IDENTITY_RESOLUTION.md` Risk #3) becomes load-bearing. - Trust model is **social**, not cryptographic (memory rule: federation trust is invite-based/human). Out of scope for this doc beyond noting it exists. Federation gets its own design pass. --- ## v0 design — Aggregation state machine The whole reason DEBT-051 was filed. This is the load-bearing piece. ### State definitions For each `(attacker_uuid, primitive)` pair, the engine maintains a state from this set: | State | Meaning | When to assert | |---|---|---| | `unknown` | Insufficient observations to classify | Default; < 3 observations OR all-`unknown` values | | `stable` | Recent observations agree | Last N observations all share the same value | | `drifting` | Recent observations disagree with older | Recent N != older N, but recent N is internally consistent | | `conflicted` | Recent observations disagree with each other | Recent N is split (no majority) | | `multi_actor` | Strong signal that two operators share access | Conflicted + alternation pattern (operator A → B → A → B), not random flip | ### Per-primitive merge logic The engine carries a per-`ValueKind` merge function. Categorical primitives dominate the calibration grid; numeric and hash primitives need different math: #### Categorical (`motor.input_modality`, `cognitive.feedback_loop_engagement`, etc.) Last-N window comparison. With `N = 5` (configurable in `_thresholds.py`): ``` recent_5 = observations[-5:] older_5 = observations[-10:-5] # if available if all(o.value == recent_5[0].value for o in recent_5): if older_5 and all(o.value == older_5[0].value for o in older_5): if recent_5[0].value != older_5[0].value: state = drifting else: state = stable else: state = stable # consistent with no older comparison elif majority_value(recent_5): state = stable # tolerant — one outlier in five is fine else: state = conflicted ``` `multi_actor` triggers on conflicted + temporal alternation (operator A and B observations interleave on a session-level granularity, not just within one session). Lower-confidence detection; v0 emits at confidence ≤ 0.6 by design. #### Numeric (`toolchain.c2.beacon_interval_ms`, etc.) EWMA + dispersion. State = `stable` if dispersion < 20% of mean, `drifting` if mean shifts > 30% over recent window, `conflicted` if dispersion > 100%. #### Hash (`toolchain.tls.jarm_server`, `toolchain.ssh.hassh_client`) Already handled by DEBT-032 fingerprint rotation. Attribution engine *reads* `attacker.fingerprint_rotated` events, doesn't recompute. State = `stable` if no rotation, `drifting` if 1-2 rotations, `conflicted` if > 2 rotations in a tight window. ### Storage — the `attribution_state` table Materialised view of the state machine. Re-derivable from `observations` + DEBT-032's rotation log; this table is a cache for cheap reads, not a source of truth. ```python # decnet/web/db/models/attribution_state.py class AttributionStateRow(SQLModel, table=True): __tablename__ = "attribution_state" # ── key ──────────────────────────────────────────────── attacker_uuid: UUID = Field(foreign_key="attackers.uuid", primary_key=True) primitive: str = Field(primary_key=True) # ── derived state ────────────────────────────────────── current_value: dict[str, Any] | str | int | float | bool | list = \ Field(sa_column=Column(JSON, nullable=False)) state: str # 'stable' | 'drifting' | 'conflicted' | 'multi_actor' | 'unknown' confidence: float # engine's confidence in the state assertion (not in any verdict) observation_count: int # how many observations underlie this state last_change_ts: float # when state last flipped last_observation_ts: float # most recent observation that fed this row # ── audit ────────────────────────────────────────────── schema_version: int = 1 # for federation, mirrors AttackerIdentity convention updated_at: float __table_args__ = ( Index("ix_attribution_state_state", "state"), Index("ix_attribution_state_last_change", "last_change_ts"), ) ``` `(attacker_uuid, primitive)` is the composite PK — at most one state row per pair. v1 will rename `attacker_uuid` to a polymorphic `subject_uuid` keyed on either attackers or identities (deferred — don't pre-build the polymorphism before clustering ships). ### Bus topics New, distinct from `IDENTITY_RESOLUTION.md`'s `identity.*` lifecycle topics: | Topic | Payload | When | |---|---|---| | `attribution.profile.state_changed` | `{attacker_uuid, primitive, old_state, new_state, current_value, confidence, ts}` | State transitions (e.g. `stable` → `drifting`) | | `attribution.profile.multi_actor_suspected` | `{attacker_uuid, primitives: [], evidence_summary, confidence, ts}` | When ≥ 2 primitives independently signal `multi_actor`; correlation is the trigger, not any single primitive | `identity.*` topics from `IDENTITY_RESOLUTION.md` stay reserved for v1 (clusterer-emitted lifecycle events). v0 doesn't touch them. **Wiki:** `Service-Bus.md` documents these in the same commit that adds the constants (`feedback_wiki_bus_signals`). ### API surface ``` GET /api/v1/attackers/{uuid}/attribution → { "primitives": [ { "primitive": "motor.input_modality", "current_value": "pasted", "state": "stable", "confidence": 0.91, "observation_count": 7, "last_change_ts": 1714521660.456 }, ... ] } ``` AttackerDetail.tsx merges this with the latest-per-primitive query from `BEHAVE-INTEGRATION.md`. The state badge is the new bit. The SSE route from `BEHAVE-INTEGRATION.md` (`GET /api/v1/attackers/{uuid}/events`) gains forwarded `attribution.profile.state_changed` events so the badge updates live. --- ## Linkage signals (v1 — not v0) For when v0 is stable and we promote attacker_uuid → identity_uuid. Documented here so v0 doesn't paint into a corner. ### Signal weights Each signal contributes to a linkage score. Two `attacker_uuid`s with combined score above the threshold get clustered. | Signal | Strength | Why | Cost | |---|---|---|---| | Same `kd_digraph_simhash` (Hamming distance < 8) | **STRONG** | Keystroke rhythm is hard to fake without effort | Computed at session-end by BEHAVE engine | | Same C2 callback endpoint | **STRONG** | Operator infra is sticky | Already extracted | | Same `hassh_client` | MEDIUM | Tools change less than IPs | Already in `attacker_behavior` | | Same `jarm_server` (if attacker exposes services) | MEDIUM | Probed-attacker substrate (DEBT-032) | Already shipped | | Same `tcp_fingerprint` cluster | WEAK | OS info, easily collided | Already in `attacker_behavior` | | Same source IP | **REJECT** | Triggers naïvely on NAT collisions; never use IP alone | n/a | ### Threshold Single combined score, calibrated against: - **False merges**: two distinct attackers collapsed into one (silent miscount). HARD failure — engine refuses to merge below ~0.85. - **Missed merges**: two observations from the same operator unrelated. Soft failure — operator can review unmerged candidates in IdentityDetail's "candidate links" panel and merge manually. The threshold lives in `_thresholds.py` like the BEHAVE-SHELL engine's; calibration cycle ships with the linkage code. ### Soft-merge audit trail `attacker_identities.merged_into_uuid` already exists from `IDENTITY_RESOLUTION.md`. v1 uses it. When the clusterer reverses an earlier merge (rare but real), the loser row's `merged_into_uuid` is NULLed and a `attribution.profile.split_proposed` event surfaces in the operator's review queue. --- ## Phase plan Per the "commit per task" + "tests per task" memory rules. Each phase is one commit. ### Phase 1 — Schema + topics + empty handler - New `attribution_state` SQLModel + migration (none needed pre-v1, per the memory rule — just edit the model). - `decnet/bus/topics.py` registers `attribution.profile.*` prefix. - `decnet/correlation/worker.py` gains an `attacker.observation.*` subscription handler that does **nothing yet** — just logs. Proves the wiring. - Wiki `Service-Bus.md` update co-commits. - Tests: SQLModel CRUD on `attribution_state`, bus subscription handler is exercised by FakeBus. Commit: `feat(correlation/attribution): substrate + idle handler`. ### Phase 2 — Categorical merge function - `attribution/aggregate.py:_aggregate_categorical(observations) → (value, state, confidence)`. - Implements the last-N comparison logic above. - Pure function. Synthetic-input tests covering each state transition (unknown → stable → drifting → stable, conflicted, multi_actor). - No DB, no bus, no I/O. Commit: `feat(correlation/attribution): categorical merge state machine`. ### Phase 3 — Hash + numeric merge functions - `_aggregate_hash` reads `attacker_fingerprint_rotation` events (DEBT-032 already produces them). - `_aggregate_numeric` does EWMA + dispersion. - Per-`ValueKind` dispatcher in `aggregate.py` picks the right function. - Tests for each value-kind path. Commit: `feat(correlation/attribution): hash + numeric merge functions`. ### Phase 4 — Wire into the worker - Subscription handler reads each `attacker.observation.*` event, loads the prior `AttributionStateRow` (if any), runs the merger, upserts the new state, emits `attribution.profile.state_changed` on transition. - Trigger isolation: handler exceptions logged, do not affect fingerprint-rotation or any other correlator path. - Tests: end-to-end with FakeBus + in-memory DB, observation-in → state-row-out + transition-event-out. Commit: `feat(correlation/attribution): wire bus handler, persist state`. ### Phase 5 — `multi_actor_suspected` cross-primitive correlator - Periodic tick (every 60s default — configurable) walks `attribution_state` rows where `state = 'multi_actor'`, groups by `attacker_uuid`, fires `attribution.profile.multi_actor_suspected` if ≥ 2 primitives flag the same attacker_uuid concurrently. - Tests: synthetic state rows, assert event fires only on co-flag. Commit: `feat(correlation/attribution): cross-primitive multi-actor detection`. ### Phase 6 — API surface - `GET /api/v1/attackers/{uuid}/attribution` route + Pydantic model. - AttackerDetail.tsx renders state badges per primitive in the Behavioural Primitives panel. - SSE route forwarding `attribution.profile.state_changed` events filtered by attacker_uuid. - Frontend Vitest coverage. Commit: `feat(web): expose attribution state on AttackerDetail`. ### Phase 7 — v0 lockdown - Synthetic calibration scenarios (extending the BEHAVE-SHELL calibration grid concept): - "Stable HUMAN over 7 sessions" → all primitives `stable` - "HUMAN switches to LLM mid-week" → primitives flip `stable` → `drifting` - "Two operators alternating on shared creds" → ≥ 2 primitives flag `multi_actor` - "Single short session" → all primitives `unknown` - All four scenarios green in CI. Commit: `test(correlation/attribution): v0 calibration lockdown`. --- ## Out of scope Filed for future paydown when they bite. Do not let them creep into v0. - **Linkage / clustering across attacker_uuids.** That's v1. - **Federation gossip of identities.** That's v2. - **Identity-level intel** (`attacker_identity_intel` from `IDENTITY_RESOLUTION.md`). Different lifecycle, ships with v1. - **Manual operator merge UI.** Operators can't fix clusterer mistakes from the dashboard — the read-only API stays read-only in v0. Editable identity rows are a v1 concern. - **Retroactive re-aggregation** when thresholds change. v0 recomputes lazily on next observation per attacker; no batch re-walk. - **Confidence calibration against ground truth.** No ground-truth data exists yet. v0 confidence values are heuristic; calibration ships when red-team exercises produce labelled trace data. - **Persona-classification** (e.g. "this identity behaves like a bot"). The bright line forbids this. State machine emits *coherence* and *drift*, not classifier labels. ## Resolved decisions - **Where the engine lives.** RESOLVED: `decnet/correlation/attribution/`, sublibrary inside the existing correlation worker. No new daemon. Symmetric with BEHAVE-SHELL's placement under `decnet/profiler/behave_shell/`. - **Linkage vs aggregation separation.** RESOLVED: two axes, two modules (`linkage.py` / `aggregate.py`). v0 ships aggregation only. - **Topic namespace.** RESOLVED: `attribution.profile.*` for derived state, distinct from `IDENTITY_RESOLUTION.md`'s `identity.*` lifecycle topics. The two namespaces compose; they don't overlap. - **State machine vocabulary.** RESOLVED: `unknown / stable / drifting / conflicted / multi_actor`. Five states, no more (resist the urge to grow the enum). - **Subject of attribution in v0.** RESOLVED: `attacker_uuid`, not `identity_uuid`. v1 widens. - **Deviation (Phase 1 implementation):** the engine actually keys state rows on `identity_uuid` from day one, materialising a 1:1 stub `attacker_identities` row per Attacker on first observation. Rationale: re-keying state rows when the v1 clusterer eventually merges attackers is exactly the migration debt v0 should not bake in. With identity-keyed state from the start, the v1 clusterer becomes a natural rollup operation (merge B's stub identity into A's identity, recompute the union once on the merge event) instead of a column-rename. No polymorphic `subject_uuid` column. ANTI sign-off in conversation; saved as memory `feedback_attribution_keys_identity`. ## Real open questions These are not stoppers for v0 but need answers before the engine ships beyond v0. 1. **`multi_actor` false-positive cost.** A flapping primitive can look like multi-actor when it's really an operator on a flaky network or split between phone/laptop. v0's confidence ≤ 0.6 cap helps but doesn't eliminate it. Open: what's the operator-facing UX for a `multi_actor` claim that's wrong? 2. **Window size `N`.** v0 hardcodes `N=5` for last-N comparison. This is calibrated against typical session counts (most attackers are observed < 10 times before they go quiet). Operators with long-running attackers (resident threats) may want a wider window; needs config knob in v1. 3. **Primitive-weight asymmetry.** Today every primitive contributes equally to the implicit "is this attacker behavioural-stable?" summary. But `motor.input_modality` is far more discriminative than `temporal.weekend_cadence`. Open: do we expose primitive weights in the API, or just sort by confidence? 4. **Observation-to-row contention.** A burst of observations for the same `(attacker_uuid, primitive)` pair (e.g. a long session with 50 sub-observations) hits the same row 50 times. v0 reads the row, runs the merger, writes back — under load this is a serialised hot path. Open: should the merger batch-process within one tick, or is per-observation latency cheap enough? 5. **What happens to `attribution_state` rows when an `attacker_uuid` is deleted?** No `attackers` deletion path exists today, but if/when one ships (GDPR purge, federation resync), `ON DELETE CASCADE` is the obvious choice. File when it matters. --- ## Implementation order checklist A single page you can paste into a TODO and tick off: - [ ] Phase 1 — Schema + topics + idle handler - [ ] Phase 2 — Categorical merge function (pure, no I/O) - [ ] Phase 3 — Hash + numeric merge functions - [ ] Phase 4 — Wire bus handler, persist state - [ ] Phase 5 — `multi_actor_suspected` cross-primitive correlator - [ ] Phase 6 — API + AttackerDetail badges + SSE forwarding - [ ] Phase 7 — v0 calibration scenarios lockdown Seven commits, seven test sets. v0 closes DEBT-051 and gives operators an honest "is this attacker behaviourally stable, drifting, or showing multiple operators?" surface — without crossing the attribution-of-natural-persons bright line. After v0, v1 (linkage / clustering) is gated on: - v0 stable in production for ≥ 1 month - ≥ 1 high-discrimination linkage signal calibrated (keystroke-dynamics simhash from BEHAVE-SHELL is the obvious candidate; v1 of the BEHAVE engine adds it post-step-10) --- **Owner:** ANTI. **Implementation gate:** this doc reviewed → Phase 1 starts after `BEHAVE-INTEGRATION.md` v0 is live (observation table populated + worker emitting `attacker.observation.*` events).