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.
584 lines
24 KiB
Markdown
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).
|