feat(decnet_web/AttackerDetail): attribution state badges (Phase 6)

Per-primitive state badge rendered next to each value in the
Behavioural Primitives panel. Five-state vocabulary, frozen, mirrors
decnet/correlation/attribution/aggregate.py:

  * STABLE      — green, low-key
  * DRIFTING    — amber, draws the eye
  * CONFLICTED  — red
  * MULTI-ACTOR — purple, loudest (cross-primitive escalation lives
                  in attribution.multi_actor_suspected, not the
                  per-primitive badge)
  * UNKNOWN     — neutral border, no fill

Wiring:

* GET /api/v1/attackers/{id}/attribution on mount + on id change.
  Failures swallowed silently (the worker may be off in dev).
* useAttackerStream gains attribution.state_changed +
  attribution.multi_actor_suspected named events. The state-changed
  handler merges by primitive and locks last_change_ts when the
  state did not actually flip (defensive — backend already gates
  these on transition, but a future relaxation shouldn't lie about
  "stable since X" on the badge tooltip).
* multi_actor_suspected is wired but unused by the badges; the
  per-primitive multi_actor signal already shows on each contributing
  primitive. The handler is in place so a future "two operators
  detected" banner has a live source.

Vitest: 4 new tests (badge renders only for mapped primitives, all
five states render with distinct labels, no badge when prop omitted)
on top of the existing 4. 7 of 7 pass; tsc + vite build clean.
This commit is contained in:
2026-05-09 02:28:11 -04:00
parent 5de4b5e290
commit 5253b32319
3 changed files with 276 additions and 4 deletions

View File

@@ -19,6 +19,15 @@
* primitive rides in `payload.primitive`.
* * `fingerprint.rotated` — `attacker.fingerprint_rotated`.
* * `attacker.scored` — score-threshold crossings.
* * `attribution.state_changed` — per-(identity, primitive)
* state-machine transition (Phase 4 of the
* attribution engine: stable / drifting /
* conflicted / multi_actor / unknown).
* Backend filters on `payload.identity_uuid`
* matching the attacker's resolved identity.
* * `attribution.multi_actor_suspected` — cross-primitive correlator
* output (Phase 5): >= 2 primitives flagged
* multi_actor on the same identity.
*/
import { useEffect, useRef } from 'react';
@@ -36,11 +45,39 @@ export interface SnapshotFrame {
observations: ObservationFrame[];
}
export type AttributionState =
| 'unknown'
| 'stable'
| 'drifting'
| 'conflicted'
| 'multi_actor';
export interface AttributionStateChangedFrame {
identity_uuid: string;
primitive: string;
old_state: AttributionState | null;
new_state: AttributionState;
current_value: unknown;
confidence: number;
observation_count: number;
ts: number;
}
export interface AttributionMultiActorFrame {
identity_uuid: string;
primitives: string[];
evidence_summary: string;
confidence: number;
ts: number;
}
export type AttackerStreamEventName =
| 'snapshot'
| 'observation'
| 'fingerprint.rotated'
| 'attacker.scored';
| 'attacker.scored'
| 'attribution.state_changed'
| 'attribution.multi_actor_suspected';
export interface AttackerStreamEvent {
name: AttackerStreamEventName | string;
@@ -57,6 +94,8 @@ export interface UseAttackerStreamOptions {
onObservation?: (data: ObservationFrame) => void;
onFingerprintRotated?: (data: Record<string, unknown>) => void;
onScored?: (data: Record<string, unknown>) => void;
onAttributionStateChanged?: (data: AttributionStateChangedFrame) => void;
onMultiActorSuspected?: (data: AttributionMultiActorFrame) => void;
onError?: () => void;
}
@@ -65,6 +104,8 @@ const NAMED_EVENTS: AttackerStreamEventName[] = [
'observation',
'fingerprint.rotated',
'attacker.scored',
'attribution.state_changed',
'attribution.multi_actor_suspected',
];
const RECONNECT_MS = 3000;
@@ -76,6 +117,8 @@ export function useAttackerStream({
onObservation,
onFingerprintRotated,
onScored,
onAttributionStateChanged,
onMultiActorSuspected,
onError,
}: UseAttackerStreamOptions): void {
const esRef = useRef<EventSource | null>(null);
@@ -84,11 +127,15 @@ export function useAttackerStream({
const onObservationRef = useRef(onObservation);
const onFingerprintRotatedRef = useRef(onFingerprintRotated);
const onScoredRef = useRef(onScored);
const onAttributionStateChangedRef = useRef(onAttributionStateChanged);
const onMultiActorSuspectedRef = useRef(onMultiActorSuspected);
const onErrorRef = useRef(onError);
useEffect(() => { onSnapshotRef.current = onSnapshot; }, [onSnapshot]);
useEffect(() => { onObservationRef.current = onObservation; }, [onObservation]);
useEffect(() => { onFingerprintRotatedRef.current = onFingerprintRotated; }, [onFingerprintRotated]);
useEffect(() => { onScoredRef.current = onScored; }, [onScored]);
useEffect(() => { onAttributionStateChangedRef.current = onAttributionStateChanged; }, [onAttributionStateChanged]);
useEffect(() => { onMultiActorSuspectedRef.current = onMultiActorSuspected; }, [onMultiActorSuspected]);
useEffect(() => { onErrorRef.current = onError; }, [onError]);
useEffect(() => {
@@ -125,6 +172,16 @@ export function useAttackerStream({
case 'attacker.scored':
onScoredRef.current?.(payload);
break;
case 'attribution.state_changed':
onAttributionStateChangedRef.current?.(
payload as unknown as AttributionStateChangedFrame,
);
break;
case 'attribution.multi_actor_suspected':
onMultiActorSuspectedRef.current?.(
payload as unknown as AttributionMultiActorFrame,
);
break;
}
};