Files
DECNET/development/BEHAVE-INTEGRATION.md
anti 11f474556c docs(behave): integration + extractor + attribution design (DEBT-050 / 051)
Three sibling design docs plus DEBT.md updates that supersede the
stale DEBT-036 with a BEHAVE-aligned plan.

development/BEHAVE-INTEGRATION.md — five-phase rollout: storage
(observations table mirroring the BEHAVE Observation envelope plus
one DECNET-side denorm; UniqueConstraint(evidence_ref, primitive)
enforcing idempotency); engine (in decnet/profiler/behave_shell/
sublibrary, no new daemon, not in BEHAVE — DECNET is the engine);
BEHAVE pin; worker wire; UI panel + per-attacker SSE route; live
smoke. Bus payload merges id/ts/v back in to preserve sensor
identifiers across the bus envelope.

development/BEHAVE-EXTRACTOR.md — engine route in eight phases
(A–H). Phase A locks the 6-primitive calibration grid; Phases B–G
expand horizontally; Phase H is the full Tier-A corpus + v0
release. v0 ships every shell-extractable primitive (37 of them);
Tier B is cross-session and lives in the attribution engine; Tier
C is network-domain (toolchain.*) and lives elsewhere.

development/ATTRIBUTION-ENGINE.md — sublibrary inside
decnet/correlation/ that consumes attacker.observation.* events
and emits attribution.profile.* derived state. Five-state machine
(unknown / stable / drifting / conflicted / multi_actor) with per-
ValueKind merge functions. v0 closes DEBT-051; v1 adds the real
clusterer; v2 federation gossip. The bright line forbidding
attribution to natural persons is lifted directly from BEHAVE's
envelope docstring.

development/DEBT.md — DEBT-036 marked STALE; DEBT-050 and
DEBT-051 entries added; summary table + open list updated.
2026-05-03 07:24:19 -04:00

681 lines
31 KiB
Markdown

# BEHAVE Integration — Design
**Status:** pre-implementation. This doc is the spec; code follows.
**Tracks:** DEBT-050 (replaces stale DEBT-036).
**Spec source:** `/home/anti/Tools/BEHAVE` (sibling, never vendored).
**Engine home:** this repo, `decnet/profiler/behave_shell/` (sublibrary inside the existing `profiler` worker — no new daemon).
## Premise
ANTI built BEHAVE — an out-of-tree behavioural-observation framework
with a primitive registry, a registry-validated `Observation`
envelope, a DECNET-bus event adapter, and a five-class calibration
grid (HUMAN / YOU-sim / LW-sim / CLAUDE-FF / CLAUDE-CL). It is the
right substrate for keystroke-dynamics extraction.
The original DEBT-036 plan (hand-rolled `kd_*` columns on
`SessionProfile`) is obsolete. This doc replaces it with a
BEHAVE-aligned ingester that emits registry-validated observations on
the bus and persists them in a single generic table.
**Bright line, lifted from BEHAVE itself:** *BEHAVE emits
observations. It does not conclude.* DECNET is a consumer of
`attacker.observation.*` events; attribution / linkage / verdicts are
out-of-scope for this integration and live in their own (future)
attribution engine.
## Architectural placement
```
/home/anti/Tools/
├── BEHAVE/ sibling repo, separate git history
│ ├── core/ decnet-behave-core (envelope)
│ ├── BEHAVE-SHELL/ decnet-behave-shell (registry + adapter)
│ └── prototype_extractors/shell/ extract.py — JSONL → Observation stream
└── DECNET/ THIS repo
├── pyproject.toml pins decnet-behave-{core,shell}
├── decnet/profiler/ EXISTING worker — gains a sublibrary + a new trigger
│ ├── worker.py gains attacker.session.ended subscription
│ ├── behavioral.py UNCHANGED — networking-domain (LogEvent IATs, beacon detection)
│ ├── timing.py UNCHANGED — networking-domain
│ └── behave_shell/ NEW — pure extraction library
│ ├── __init__.py
│ ├── extract.py orchestration: parse → dispatch → assemble Observations
│ └── _features/ per-primitive-family modules
└── decnet/web/db/models/observations.py NEW — generic Observation table
```
**No new worker.** The existing `decnet-profiler.service` already
supervises this codepath. No new systemd unit, no new polkit rule, no
new heartbeat. The session-ended handler is a peer to the existing
scoring tick inside the same async loop.
**Audit finding (network vs PTY domains).** `behavioral.py` and
`timing.py` operate on `LogEvent` (network-level connection events
from `decnet.correlation.parser`), feeding the existing
`attacker_behavior` table — TCP fingerprint, OS guess, beacon
interval, behavior class. **Zero overlap with BEHAVE-SHELL**, which
operates on `AsciinemaEvent` (PTY input) and persists to the new
`observations` table. The two coexist; no rewrite, no migration, no
shared state.
Two repos, two commits, no vendoring. `pip install -e
../BEHAVE/core ../BEHAVE/BEHAVE-SHELL` for local dev; pinned wheels in
CI.
## BEHAVE is the spec. DECNET is the engine.
This is a *load-bearing* architectural fact, called out explicitly so
nobody (including future me) misreads the layout.
- **BEHAVE ships:** the primitive registry, the registry-validated
`Observation` envelope, the bus event adapter, the JSON schema.
Reference prototype extractor for spec validation only. BEHAVE will
**not** ship a production engine — that's not what the BEHAVE repo
is for.
- **DECNET ships:** the production extraction engine. It lives in
`decnet/profiler/behave_shell/`, written from scratch against the
BEHAVE spec, called from the existing profiler worker on
`attacker.session.ended`.
DECNET-side BEHAVE imports are spec-only:
```python
from decnet_behave_core.spec.envelope import Observation as ObservationEnvelope, Window
from decnet_behave_shell.spec.primitives import PRIMITIVE_REGISTRY, get as get_primitive_spec
from decnet_behave_shell.spec.event_adapter import event_topic_for, to_event_payload
```
`Observation` is aliased to `ObservationEnvelope` so the storage
SQLModel can keep the `Observation`-flavoured class name where it's
useful, and the BEHAVE primitive-spec accessor is aliased away from
the bare name `get` to avoid shadowing in feature-extractor modules
that read dicts heavily.
That's it. No imports from `BEHAVE/prototype_extractors/`. The
prototype is read as **design notes** during the engine build, then
ignored. If the prototype yields a primitive the production engine
doesn't, that's a calibration delta to investigate, not a regression
in either direction.
### The extraction engine — DECNET-side
```
decnet/profiler/behave_shell/
├── __init__.py exposes extract_session()
├── extract.py orchestration: parse → dispatch → assemble Observations
└── _features/ feature-extractor modules, one per primitive family
├── motor.py cadence, paste burst, modality, shell mastery
├── cognitive.py latency class, consistency, branch diversity, feedback loop
├── temporal.py session timing, escalation pattern
└── ... others added as primitives are productionised
tests/profiler/behave_shell/
└── _features/ one test module per feature family, against synthetic streams
```
The library is **pure** — no I/O, no bus calls, no DB writes. Events
in → `Iterable[Observation]` out. The split between `extract.py`
(orchestration) and `_features/` (per-family implementations) keeps
each primitive's logic auditable in isolation — including the
threshold tables, which are the part most likely to drift across
calibration cycles. The worker (in `decnet/profiler/worker.py`) owns
all I/O: disk-reach, bus publish, DB upsert.
**The engine is its own first-class effort, not a side-effect of
this integration doc.** The five-class calibration grid is the
acceptance test. Beyond that, it has its own design surface
(threshold calibration methodology, per-primitive confidence scoring,
feature-family precedence rules) that this doc does not attempt to
fully specify — that belongs in a sibling `BEHAVE-EXTRACTOR.md` once
Phase 1 lands and we have the storage shape to write into.
**Calibration knowledge does leak across the repo boundary.** BEHAVE's
`primitives.py` carries empirical calibration notes (e.g. CLAUDE-FF
vs CLAUDE-CL on 2026-05-02) inline in the registry. The clean
separation "BEHAVE = pure spec, DECNET = pure engine" is leakier
than this doc would prefer; both repos must agree on what a primitive
*means* before the engine threshold tables are tuned. Treat the
registry's `notes:` field as ground truth and tune DECNET to match.
### BEHAVE-side commits (rare, for spec changes only)
The only reasons to touch the BEHAVE repo during this integration:
1. The DECNET engine discovers a primitive the registry needs and the
spec doesn't yet define → registry edit in BEHAVE → version bump
→ DECNET pin update.
2. The envelope schema needs a field DECNET can populate honestly
(e.g. a structured `evidence_ref` schema) → envelope edit → schema
`v` bump → `observations.envelope_v` column already tracks it.
These are not blockers for Phase 1. They land iteratively as the
engine matures.
## Versioning
| Axis | Current | DECNET pin |
|---|---|---|
| Envelope schema (`Observation.v`) | `1` | column `observations.envelope_v` tracks it |
| Schema URL | `https://behave.local/schema/observation/v1.json` | — |
| `decnet-behave-core` | `0.1.0` | `>=0.1.0,<0.2` |
| `decnet-behave-shell` | `0.1.0` | `>=0.1.0,<0.2` |
A future `v=2` envelope coexists in the same table without a
destructive migration — query by `envelope_v` when shape diverges.
Bump the cap in `pyproject.toml` when BEHAVE cuts `0.2.0`.
## Data flow
```
asciinema shard on disk
/var/lib/decnet/artifacts/{decky}/sessrec/sessions-YYYY-MM-DD.jsonl
│ disk-reach (host-local, never on bus)
bus: attacker.session.ended ─► decnet-profiler worker (existing)
(or poll fallback) │ → handler in worker.py
│ → calls behave_shell.extract_session(events) → Iterable[Observation]
│ (registry-validated by BEHAVE)
bus.publish(event_topic_for(obs.primitive),
to_event_payload(obs))
┌─────────────────────┼──────────────────────┐
▼ ▼ ▼
observations table AttackerDetail UI future: attribution engine,
(DECNET storage) (live SSE consumer) federation gossip, webhook export
```
Raw `[t,"i",d]` events never cross the worker→bus boundary. Bus
carries observation envelopes only. Disk-reach for the input stream
mirrors DEBT-047's pattern (filesystem-group-readable artifacts via
DEBT-035).
## Storage — the `observations` table
Generic table holding every BEHAVE envelope field, plus a single
DECNET-side denormalization (`attacker_uuid`) for cheap joins.
**Not a strict 1:1 mirror** — the envelope has no `attacker_uuid`;
DECNET adds it so AttackerDetail doesn't have to chase
`identity_ref → AttackerIdentity → attacker_uuid` on every read.
The SQLModel class is named `ObservationRow` to avoid colliding
with the BEHAVE `Observation` Pydantic class imported into the
same module.
```python
# decnet/web/db/models/observations.py
from decnet_behave_core.spec.envelope import Observation as ObservationEnvelope
class ObservationRow(SQLModel, table=True):
__tablename__ = "observations"
# ── envelope fields (types match BEHAVE exactly) ─────────────
id: str = Field(primary_key=True) # envelope.id (uuid4().hex string)
identity_ref: str | None = None # envelope.identity_ref (str, not UUID)
primitive: str = Field(index=True) # 'motor.keystroke_cadence'
value: dict[str, Any] | str | int | float | bool | list = \
Field(sa_column=Column(JSON, nullable=False))
confidence: float
window_start_ts: float # flattened from envelope.window
window_end_ts: float
source: str
evidence_ref: str = Field(nullable=False) # NOT NULL for DECNET emissions; see "Idempotency"
envelope_v: int # envelope.v
ts: float = Field(index=True) # emission ts
# ── DECNET-side denormalization (NOT in BEHAVE envelope) ─────
attacker_uuid: UUID = Field(foreign_key="attackers.uuid", index=True)
__table_args__ = (
Index("ix_observations_attacker_primitive_ts",
"attacker_uuid", "primitive", "ts"),
Index("ix_observations_primitive_ts", "primitive", "ts"),
UniqueConstraint("evidence_ref", "primitive",
name="uq_observations_evidence_primitive"),
)
```
**SQLAlchemy `JSON` not `JSONB`** per the typed-evidence-dicts memory
rule (dual-backend MySQL + SQLite).
**`evidence_ref` is NOT NULL** for DECNET-emitted observations, even
though BEHAVE's envelope makes it `Optional[str]`. The worker's
"have we already profiled this session?" check (see Idempotency
below) keys on `evidence_ref`; if it's NULL the check breaks. The
shape `shard:{decky}/{service}/{date}.jsonl#sid` is mandatory at the
worker layer. If a future BEHAVE consumer needs nullable
evidence_ref, that's a separate observation source with its own
worker — not this one.
**`UniqueConstraint(evidence_ref, primitive)`** enforces idempotency
at the schema level, so a re-run of the worker on the same shard+sid
produces a DB-side conflict, not silent duplicate rows. SQLite and
MySQL both treat distinct (non-NULL) tuples as distinct in unique
indexes — safe across both backends since `evidence_ref` is
NOT NULL.
**No `_migrate_*` helper.** Pre-v1; `SessionProfile` and its `kd_*`
columns are deleted from `decnet/web/db/models/attackers.py`
outright. DEBT-011 (Alembic) remains deferred.
### Canonical queries
**Latest observation per primitive, for one attacker** (AttackerDetail
"current state" panel):
```sql
SELECT primitive, value, confidence, ts
FROM observations
WHERE attacker_uuid = :uuid
AND ts = (SELECT MAX(ts) FROM observations o2
WHERE o2.attacker_uuid = observations.attacker_uuid
AND o2.primitive = observations.primitive)
ORDER BY primitive;
```
(SQLite — no `DISTINCT ON`; window-function rewrite available if the
correlated subquery hot-spots.)
**Time-series for one primitive across all sessions of one attacker**
(for "is this typist drifting" charts, future):
```sql
SELECT ts, value, confidence
FROM observations
WHERE attacker_uuid = :uuid AND primitive = :primitive
ORDER BY ts;
```
## The session-ended handler — riding the existing profiler worker
```
decnet/profiler/
├── worker.py EXISTING — gains attacker.session.ended subscription
└── behave_shell/ NEW — pure extraction library (no I/O)
├── __init__.py
└── extract.py wraps the engine + disk-reach call site
tests/profiler/behave_shell/
├── __init__.py
├── test_extract.py unit tests against synthetic event streams
├── test_calibration_grid.py the five-class regression suite (Phase 5)
├── test_worker_session_ended_bus.py FakeBus path
└── test_worker_session_ended_poll.py DECNET_BUS_ENABLED=false path
```
(All tests live under `tests/`, mirroring the source tree per repo
convention. Existing `tests/profiler/test_session_profile.py` is
deleted alongside the `SessionProfile` model in Phase 1.)
**Trigger.** Subscribe to `attacker.session.ended` on the bus. Poll
fallback walks `Log` rows where `event_type='session_recorded'` and
no `observations` row carries the matching `evidence_ref`. Bus path
ships first; poll fallback ships in the same commit so
`DECNET_BUS_ENABLED=false` is supported from day one (DEBT-031
pattern).
**Disk-reach.** For each `(decky, service, sid)`, resolve the shard
via `_find_shard_with_sid` (already shipped, `323077b`). Open the
JSONL via `decnet/artifacts/paths.py:resolve_artifact_path`
(DEBT-047 — symlink-escape check, regex validation,
`ARTIFACTS_ROOT` env override). Slice the per-sid event list. Pass
to BEHAVE.
**Extraction.** Call
`decnet.profiler.behave_shell.extract_session(events, sid=..., source=...)`.
Receive `Iterable[Observation]`. Each is registry-validated at
construction by BEHAVE's `Observation` subclass; DECNET does not
re-validate.
**Resolve `attacker_uuid`.** Sessrec carries `(decky_name, service,
sid, src_ip, src_port)` per shard line. Resolve src_ip → attacker
via the existing `attackers.ip` index; create-if-missing per the
existing observe path. Stamp `identity_ref=NULL` until attribution
exists.
**Bus emission.** For each observation, **DECNET overrides BEHAVE's
adapter** to preserve sensor-side identifiers across the bus:
```python
# BEHAVE's to_event_payload() excludes id/ts/v because BEHAVE assumes
# the bus envelope carries them at the Event level. DECNET's bus
# (DEBT-029) auto-generates fresh id/ts/v on publish — there's no
# bus.publish overload that accepts envelope-level overrides. Without
# this merge, BEHAVE's id/ts/v would be silently lost, breaking
# cross-host dedup and federation gossip.
payload = to_event_payload(obs) | {"id": obs.id, "ts": obs.ts, "v": obs.v}
bus.publish(
topic = event_topic_for(obs.primitive), # 'attacker.observation.motor.keystroke_cadence'
payload = payload,
)
```
Subscribers reconstructing the envelope via
`from_event_payload(primitive, payload)` see the original BEHAVE id /
ts / v because they ride along in `payload`. The DECNET-bus Event
envelope's *own* id/ts/v (auto-generated) are bus-routing concerns,
distinct from observation identity.
**This is a known deviation from BEHAVE's wire-format docstring**
(`core/decnet_behave_core/spec/envelope.py:77-84`). If DECNET's bus
later grows envelope-level overrides on `publish()`, revert to the
upstream contract. Filed as a low-priority follow-up — not blocking.
Adapter import path is pure-stdlib — no DECNET imports inside BEHAVE.
DECNET is the consumer of BEHAVE's contract, never the other way
around.
**Persistence.** All observations from one session — i.e. one
`(decky, service, sid)` triple — commit as **a single transaction**.
Either the entire session lands in `observations` or none of it
does; partial-failure mid-session never leaves a half-profiled
attacker row.
Persist **first**, then publish to the bus best-effort. Bus is
fire-and-forget (DEBT-029 §6) — a publish failure does **not** roll
back the persisted rows, and a persist failure means nothing is
published. DB is the source of truth; the bus is the notification
layer only. Order matters: a downstream subscriber receiving an
`attacker.observation.*` event can immediately query the table and
find it; the inverse (publish-then-persist) would create a window
where subscribers chase rows that don't exist yet.
**Idempotency.** Enforced at the schema level by
`UniqueConstraint(evidence_ref, primitive)`. Re-running the worker
on the same shard+sid produces a DB-side conflict per row, which the
worker handles via `INSERT … ON CONFLICT DO UPDATE` (SQLAlchemy
upsert). Worker marks a session "profiled" by the existence of any
row matching its `evidence_ref` — no separate marker column. Because
the unique index makes accidental duplicates structurally
impossible, the marker check is honest.
## Bus topics
Add to `decnet/bus/topics.py`:
```python
ATTACKER_OBSERVATION_PREFIX = "attacker.observation"
# Wildcard patterns:
# attacker.observation.motor.*
# attacker.observation.cognitive.*
# attacker.observation.> (everything BEHAVE-SHELL emits)
```
Topic shape locked by BEHAVE's `event_topic_for()`; DECNET registers
the prefix for documentation and pattern-matching only. **Bus auth
is not topic-level** — per DEBT-029 §2 the bus uses
kernel-authenticated peer delivery (UNIX socket file permissions),
not topic ACLs. `bus/topics.py` change co-commits with a
wiki-checkout `Service-Bus.md` update (memory rule: "Document new
bus signals in the wiki").
## AttackerDetail consumer
### REST surface
`decnet/web/router/attackers/api_get_attacker_detail.py` swaps the
`SessionProfile` join for the latest-per-primitive query above.
Response shape gains:
```jsonc
{
// ... existing attacker fields ...
"observations": [
{
"primitive": "motor.input_modality",
"value": "pasted",
"confidence": 0.91,
"ts": 1714521660.456,
"source": "decnet/profiler/behave_shell/extract.py"
},
// ... one row per primitive observed for this attacker ...
]
}
```
Frontend (`AttackerDetail.tsx`) renders a "Behavioural primitives"
panel grouped by the registry's top-level domain (`motor.*`,
`cognitive.*`, `temporal.*`, `operational.*`, `environmental.*`,
`cultural.*`, `emotional_valence.*`, `toolchain.*`). Day-one render
priorities for the panel:
1. `motor.input_modality` — pasted vs typed vs mixed
2. `cognitive.feedback_loop_engagement` — closed_loop vs fire_and_forget
3. `cognitive.command_branch_diversity` — linear_playbook vs adaptive_branching
4. `cognitive.inter_command_latency_class` — typing_speed / llm_lightweight / llm_heavyweight / long
5. Everything else, alphabetised by primitive path.
These four are the highest-discriminative-value primitives in the
calibration grid; surfacing them first is what unblocks the "is this
the same operator class" hover story.
### Live-update SSE route
`GET /api/v1/attackers/{uuid}/events` — per-attacker SSE stream,
mirrors the per-topology pattern shipped in DEBT-030.
The route subscribes to `attacker.observation.*` filtered by
`identity_ref` / resolved `attacker_uuid`, plus
`attacker.fingerprint_rotated` / `attacker.scored` for the same
attacker.
Envelope identical to topology events:
`{v, type, ts, payload}`. Day-one event types:
`observation.<primitive>`, `fingerprint.rotated`, `attacker.scored`.
Auth: `?token=` query-param matching the existing per-topology and
`/stream` pattern. Snapshot-on-connect serves the latest-per-primitive
query result so the panel hydrates immediately, then live-forwards
bus events. 15s keepalive, mirrors the topology route.
The global `/stream` is **not** the right fit here — it fans out
every attacker's events to every subscriber, and the AttackerDetail
page only cares about one. Per-attacker route, like
per-topology.
## PII discipline
Binds at the BEHAVE layer; DECNET does not get to "improve" the
envelope by reading raw bodies into payloads.
- Raw `[t,"i",d]` keystroke events stay on disk. Worker reads,
extracts, discards.
- `evidence_ref` is a *pointer* (`shard:path#sid`), never the
evidence itself.
- `value` JSON is bounded by the registry's `ValueTypeSpec` — no
free-form blobs that could smuggle keystrokes.
- Bigram simhashes (when emitted via `cognitive.*` digraph
primitives) are *characters*, not *content* — already documented in
BEHAVE's primitives module.
**Canonical PII binding.** The authoritative statement is the module
docstring at `core/decnet_behave_core/spec/envelope.py:3-19` — it
forbids raw keystrokes, command bodies, credentials, and payload
bytes in observation values; `evidence_ref` is a pointer, never the
evidence. That docstring is binding on this DECNET integration.
*Not* `BEHAVE-SHELL/scratchpad.md` — scratchpads, by definition,
aren't binding policy surfaces.
## Calibration grid IS the regression test
`tests/profiler/behave_shell/test_calibration_grid.py` runs the
**pure engine** (`behave_shell.extract_session()` called directly,
no worker, no bus, no DB) against each of the five
`BEHAVE/prototype_extractors/shell/sessions-2026-05-02-*.jsonl`
shards (gitignored — fixture path resolved via
`BEHAVE_CALIBRATION_DIR` env var, skipped if unset). Asserts the
expected primitive set fires per class:
| Shard | Class | Required primitives in output |
|---|---|---|
| `sessions-2026-05-02.jsonl` | HUMAN | `motor.input_modality=typed`, `cognitive.inter_command_consistency=bimodal`, `cognitive.feedback_loop_engagement=closed_loop`, `cognitive.command_branch_diversity=adaptive_branching` |
| `sessions-2026-05-02-with-llm.jsonl` | YOU-sim | `motor.input_modality=pasted`, `motor.paste_burst_rate=occasional`, `cognitive.inter_command_latency_class=typing_speed`, `cognitive.command_branch_diversity=linear_playbook` |
| `sessions-2026-05-02-new.jsonl` | LW-sim | `motor.input_modality=pasted`, `motor.paste_burst_rate=habitual`, `cognitive.inter_command_latency_class=llm_lightweight`, `cognitive.command_branch_diversity=linear_playbook` |
| `sessions-2026-05-02-with-claude.jsonl` | CLAUDE-FF | `motor.input_modality=pasted`, `motor.paste_burst_rate=habitual`, `cognitive.inter_command_latency_class=llm_heavyweight`, `cognitive.command_branch_diversity=linear_playbook`, `cognitive.feedback_loop_engagement=fire_and_forget` |
| `sessions-2026-05-02-closed-loop.jsonl` | CLAUDE-CL | `motor.input_modality=pasted`, `motor.paste_burst_rate=habitual`, `cognitive.inter_command_latency_class=long`, `cognitive.command_branch_diversity=adaptive_branching`, `cognitive.feedback_loop_engagement=closed_loop` |
Any extractor change that breaks one of these classifications fails
CI. The grid is the discriminative-power floor — calibration
refinement can *add* primitives, never silently *drop* them.
## Phase plan
Per the "commit per task" memory rule, each phase ships as one commit
with its own tests.
### Phase 1 — DECNET-side storage (no BEHAVE coupling yet)
- New `observations` table + SQLModel + repository methods.
- Drop `SessionProfile` + `kd_*` columns from
`decnet/web/db/models/attackers.py`.
- AttackerDetail API switches to the latest-per-primitive query.
Returns empty `observations: []` since nothing populates the table.
- `decnet/bus/topics.py` registers `attacker.observation.*` prefix.
- Tests: SQLModel CRUD, latest-per-primitive query against fixture
rows, empty-attacker contract.
### Phase 2 — DECNET extraction engine (`decnet/profiler/behave_shell/`)
- Production extractor written against the BEHAVE spec, pure library
(no I/O).
- One feature-family module per `_features/{motor,cognitive,temporal,...}.py`.
- Public entry: `extract_session(events, *, sid, source) -> Iterable[Observation]`.
- Tests in `tests/profiler/behave_shell/_features/`: per-feature unit
tests against synthetic event streams. The calibration-grid suite
(Phase 5) is the integration test.
- This phase has its own design surface — see `BEHAVE-EXTRACTOR.md`
(filed as a sibling doc when Phase 1 lands). Phases 1 and 2 are
largely independent; can run in parallel.
### Phase 3 — BEHAVE pin
- `pyproject.toml` pins `decnet-behave-core` and `decnet-behave-shell`
at whatever versions the engine settles on.
- CI install-time smoke: registry imports cleanly, envelope validates
a known-good observation.
### Phase 4 — Wire the trigger into the existing profiler worker
- `decnet/profiler/worker.py` gains an `attacker.session.ended`
subscription handler.
- Handler does: resolve shard via disk-reach → call
`behave_shell.extract_session()` → upsert into `observations` table
→ publish each observation on the bus.
- Poll fallback for `DECNET_BUS_ENABLED=false`.
- Trigger isolation: handler exceptions logged, do not affect the
existing scoring tick.
- Tests in `tests/profiler/behave_shell/`: FakeBus path, poll-only
path, disk-reach error paths, idempotency on re-run.
- **No new systemd unit.** The existing `decnet-profiler.service`
already supervises this code.
### Phase 5 — Calibration regression suite + UI surface
- `tests/profiler/behave_shell/test_calibration_grid.py` against all
five BEHAVE shards.
- New `GET /api/v1/attackers/{uuid}/events` SSE route (mirrors the
per-topology pattern from DEBT-030); snapshot-on-connect +
bus-forwarded `attacker.observation.*` events. Tests in
`tests/api/attackers/test_events_stream.py`.
- AttackerDetail.tsx renders the Behavioural primitives panel and
consumes the SSE route for live updates.
- Frontend Vitest coverage for the panel (DEBT-043 harness, shipped).
### Phase 6 — Live smoke
- Ship a decky, run a real SSH session from each calibration class
manually, disconnect, observe `observations` rows + bus events +
AttackerDetail panel.
- Document the smoke procedure in
`scripts/behave_shell/smoke.sh` (parallel to
`scripts/bus/smoke-mutator.sh` — per-feature dirs).
## Out of scope
Filed for future paydown when they bite. Do not let them creep into
this integration.
- **Attribution engine.** Consumes `attacker.observation.*`, emits
`attribution.profile.candidate.*`. BEHAVE explicitly separates
observation from attribution.
- **Federation gossip** of observations across swarm hosts.
- **Backfill** over historical shards (one-shot script when the
table lands; not a worker feature).
- **Webhook export** of observation streams (rides DEBT-037).
- **Observation retention / vacuum.** Pre-v1, no users to mislead;
filed when storage actually pressures.
- **`SessionProfile` data migration.** None — table ships empty
today, drop is destructive but lossless.
- **Cross-domain BEHAVE** (BEHAVE-TEXT integration for stylometric
analysis of attacker-typed messages, e.g. captured emails). Same
`observations` table will accept those envelopes when their primitive
registry is registered, but the wiring is a separate paydown.
## Resolved decisions (formerly open questions)
- **Q1 — engine location.** RESOLVED: BEHAVE's prototype is reference
code only, never imported by DECNET. The production extraction
engine lives in `decnet/profiler/behave_shell/` as a sublibrary of
the existing profiler worker — no new daemon, no new systemd unit.
(See "BEHAVE is the spec. DECNET is the engine.")
- **Q2 — emission granularity.** RESOLVED: **per-(sid, primitive).**
Every session emits its full primitive set; every emission
persists. The schema already supports it; this just locks in the
worker write loop. *More detail the better.*
- **Q3 — cross-session aggregation, day one.** RESOLVED: latest wins
per primitive in the AttackerDetail "current state" query. Simple,
honest, easy to reason about.
## Real open question — Cross-session aggregation, the right way
Q3's "latest wins" is a stopgap. The actual question is harder and
deserves its own design pass before AttackerDetail starts surfacing
attribution-flavoured claims:
> **When two sessions from the same attacker (or identity) emit
> conflicting values for the same primitive, what does the
> attacker-level view say?**
Concrete cases:
- Session A: `motor.input_modality = typed` (conf 0.92).
Session B (next day): `motor.input_modality = pasted` (conf 0.88).
Is this attacker `mixed`? Or did they switch tooling? Or did a
*different operator* take over the same credentialed access?
- `cognitive.feedback_loop_engagement` flips from `closed_loop` to
`fire_and_forget` between two sessions. Is this fatigue, a
handoff (`operational.multi_actor_indicators=handoff_detected`?),
or a script taking over from a human?
- `cognitive.command_branch_diversity = unknown` in a short session
vs `adaptive_branching` in a long session. Latest-wins would
collapse this to `unknown` if the short session lands second —
exactly the wrong answer.
**This is genuinely an attribution-engine concern**, not an
extraction concern. BEHAVE is firm on that bright line. The clean
answer is:
1. **DECNET stores all observations** (per-sid, per-primitive — Q2).
2. **AttackerDetail's day-one "current state" query is latest-wins**
(Q3) — not because it's right, but because it's *honestly
transparent* about being naïve.
3. **The right answer ships with the attribution engine** as a
separate paydown — likely as new `attribution.profile.*` topics
that emit a *derived* per-attacker primitive map with explicit
merge semantics (`stable` / `drifting` / `conflicted` /
`multi_actor`). Day-zero, that engine doesn't exist; day-one,
AttackerDetail just shows raw latest values + a "N
observations" hover.
Filed as **DEBT-051 — Cross-session BEHAVE primitive aggregation
(attribution engine)** when this doc is reviewed. Out of scope for
this integration; explicitly listed under "Out of scope" above.
---
**Owner:** ANTI.
**Implementation gate:** this doc reviewed → Phase 1 starts.