Files
DECNET/development/ATTRIBUTION-ENGINE.md
anti c2891d6cca 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.
2026-05-08 23:16:13 -04:00

584 lines
24 KiB
Markdown

# 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).