## Why RedLog
@@ -57,17 +53,15 @@ Penetration testers need a complete, tamper-evident record of every action taken
**Key differentiators:**
- **Zero-friction capture** — installs, starts recording, stays out of your way
-- **AI agent native** — MCP server + shell hooks let Claude Code, Codex, and GPT log directly into your timeline
+- **AI agent native** — shell hooks let Claude Code, Codex, and GPT log directly into your timeline
- **Scope-aware** — root-domain matching alerts you when tools touch out-of-scope targets without false positives on unrelated hosts
-- **Per-operator identity** — every event carries an operator id resolved from an API token; teammates and agents get their own tokens ([details](docs/operators.md))
+- **Operator identity** — every event carries an operator id resolved from the API token
- **Evidence chain + OpenTimestamps** — append-only SHA-256 chain, hourly anchored to public OpenTimestamps calendars, one-command `.ots` bundle export for third-party `ots verify` ([details](docs/audit-trail.md))
- **Ghostwriter-compatible event schema** — first-class `dns`, `credential_use`, `c2_checkin`, `file_transfer` types plus standard `dest_ip / dest_host / mitre_ttp / description` keys ([details](docs/event-schema.md))
- **Signed evidence bundle** — `redlog-cli export bundle` produces a self-contained directory with SHA-256 manifest ready for delivery
- **Entropy-based redaction** — high-entropy tokens and per-project allow/denylist patterns are auto-redacted from captured output
- **Clock hardening** — every event carries wall-clock, monotonic, and NTP offset
-- **Deconfliction webhook** — optional signed feed to the blue team's SOC on marker / scope-violation / cred-use events ([details](docs/deconfliction.md))
- **asciinema terminal recording** — every RedLog terminal pane produces a `.cast` file with SHA-256 stored on session end; the timeline exposes both **per-command replay** (▶ Replay stdout) and **full-session replay** (▶ Replay entire session) — critical when the operator ssh'd into a remote host and the local `ssh` command_end is the only chain entry from that stretch
-- **Built-in MCP server (HTTP)** — the app hosts its own MCP endpoint, live the moment RedLog opens; agents operate the app (markers, scope, anchoring) without spawning a subprocess ([details](docs/agent-integration.md#2-mcp-server-operate-the-app))
- **One-click proxied browser** — launches Chromium through your mitmproxy with CDP enabled and a project-local profile, so captured traffic and QuickMarks work without touching your daily browser ([details](docs/agent-integration.md#proxied-browser))
- **Internal-pivot awareness** — auto-detects ligolo-ng / chisel / `ssh -D/-L/-R` / sshuttle / proxychains from shell commands and records a first-class `pivot` event (intermediate node, route, SOCKS port, MITRE T1090/T1572) so the timeline shows the lateral-movement topology; plain `ssh user@host` also fires a pivot so the timeline reflects "attention moved to a remote host" ([details](docs/event-schema.md#pivot-events))
- **VPS deployment** — `hooks/vps-deploy.sh install user@vps && hooks/vps-deploy.sh tunnel user@vps` copies the shell hook to a remote box and opens a reverse-tunnel session, so every command run on the VPS lands in the local chain in real time
@@ -221,56 +215,6 @@ SHELL=/path/to/redlog/hooks/codex-wrapper.sh codex run "scan the target"
./hooks/codex-wrapper.sh nmap -sV target.com
```
-### Layer 2: MCP Server (operate the app) — control plane, not logging
-
-RedLog hosts its own MCP server **over HTTP**, so it's live whenever the app is open — no subprocess to spawn. Use it to *operate* RedLog: create markers, check scope, anchor the chain, read history. Hooks do the logging; MCP does not duplicate it.
-
-Set up in **Settings ▸ Team & Integrations ▸ MCP Server**, then:
-
-```bash
-claude mcp add --transport http redlog http://127.0.0.1:6660/mcp \
- --header "Authorization: Bearer "
-```
-
-(A stdio bridge is available as a fallback — see [docs](docs/agent-integration.md#2-mcp-server-operate-the-app).)
-
-```bash
-claude mcp add redlog -- node /path/to/redlog/mcp/redlog-mcp-server.js
-```
-
-**18 available tools (HTTP or stdio):**
-
-| Tool | Description |
-|------|-------------|
-| `redlog_status` | IP state, event count, scope violations |
-| `redlog_whoami` | Confirm which operator token is loaded |
-| `redlog_operators_list` | List every registered operator |
-| `redlog_mark` | Create a timestamped marker (finding, phase change, note) |
-| `redlog_log_event` | Log a raw event with custom type and data |
-| `redlog_search` | Full-text search across all events |
-| `redlog_events` | Query recent events by type/target |
-| `redlog_scope` | Get scope config and violations |
-| `redlog_config` | Get project configuration |
-| `redlog_quickmark` | Bookmark a URL/finding |
-| `redlog_quickmarks_list` | List all bookmarks |
-| `redlog_loot_scan` | Scan text for credentials/secrets |
-| `redlog_screenshot` | Capture desktop screenshot |
-| `redlog_recording` | Pause/resume/toggle recording |
-| `redlog_chain_status` | Chain length + latest OTS anchor |
-| `redlog_chain_anchor_now` | Anchor current chain head to OpenTimestamps |
-| `redlog_chain_verify` | Fast prefix check on latest anchor |
-| `redlog_chain_upgrade` | Fetch upgraded OTS proofs for pending anchors |
-
-Ship-ready skill file at [`docs/skills/redlog-pentest.md`](docs/skills/redlog-pentest.md) — copy to `~/.claude/skills/` to opt an agent into the full flow.
-
-**Example agent interaction:**
-
-```
-User: "Check if this target is in scope, then run a port scan and log the results"
-
-Agent calls: redlog_scope → confirms target → runs nmap → redlog_mark with findings
-```
-
### Layer 3: HTTP API (Universal)
Direct REST API for scripts, custom agents, and non-MCP tools.
@@ -299,7 +243,7 @@ curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:$PORT/api/status
| POST | `/api/loot/scan` | Scan text for secrets |
| POST | `/api/screenshot` | Trigger manual capture |
| GET | `/api/whoami` | Operator identity for this token |
-| GET/POST/PATCH/DELETE | `/api/operators[/…]` | Operator token management |
+| GET | `/api/operators` | List operators (attribution) |
| GET | `/api/chain` | Chain length + last anchor |
| GET/POST | `/api/anchors` | List / trigger OpenTimestamps anchoring |
| GET | `/api/anchors/verify` | Fast integrity check |
@@ -326,10 +270,8 @@ See [`docs/codex-tools.json`](docs/codex-tools.json) for OpenAI-compatible funct
### More reading
- [Agent integration](docs/agent-integration.md) — full REST + MCP + hook reference
-- [Operators & tokens](docs/operators.md) — multi-operator identity, token lifecycle
- [Audit trail](docs/audit-trail.md) — hash chain + OpenTimestamps + full re-walk + bundle export
- [Event schema](docs/event-schema.md) — standard agent_type + data keys (Ghostwriter-compatible)
-- [Deconfliction webhook](docs/deconfliction.md) — when/how to feed the blue team
- [Plugin development](docs/plugin-development.md) — extend RedLog: 🟢 declarative packs + 🔴 trust-gated MCP tools
- [Skill: redlog-pentest](docs/skills/redlog-pentest.md) — ready-to-copy Claude Code skill
@@ -377,7 +319,6 @@ Hooks
└── codex-wrapper.sh Shell wrapper for Codex/GPT
MCP Server
- └── redlog-mcp-server.js 18-tool MCP server (stdio transport)
```
## Project Structure
@@ -421,7 +362,6 @@ hooks/
shell-preexec-hook.sh zsh/bash preexec → RedLog
codex-wrapper.sh shell wrapper for any agent
mcp/
- redlog-mcp-server.js MCP server (18 tools, stdio transport)
cli/
redlog-cli.js CLI tool for external integration
shell/
diff --git a/cli/redlog-cli.js b/cli/redlog-cli.js
index bd978bf1..ca78e83f 100755
--- a/cli/redlog-cli.js
+++ b/cli/redlog-cli.js
@@ -118,7 +118,7 @@ Usage:
redlog-cli health
redlog-cli token
redlog-cli whoami
- redlog-cli operators [list|add |rotate |revoke |delete ]
+ redlog-cli operators [list]
redlog-cli chain [status|anchor|verify [--full]|anchors|export-ots [--out ]|upgrade [|--all]]
redlog-cli export bundle
@@ -132,7 +132,6 @@ Examples:
redlog-cli search "password"
redlog-cli loot "root:x:0:0:root:/root:/bin/bash"
redlog-cli whoami
- redlog-cli operators add "Codex agent"
redlog-cli chain anchor
curl -s -H "Authorization: Bearer $(redlog-cli token)" http://127.0.0.1:6660/api/events
`)
@@ -368,47 +367,17 @@ Examples:
case 'operators': {
const sub = positional[0] || 'list'
- if (sub === 'list') {
- const res = await request('GET', '/api/operators')
- if (res.status === 200) {
- for (const op of res.data.operators) {
- const tag = op.isPrimary ? '[PRIMARY]' : op.revokedAt ? '[REVOKED]' : ' '
- console.log(` ${tag} ${op.id.padEnd(28)} ${op.name}`)
- }
- } else { console.error(`Error ${res.status}:`, res.data); process.exit(1) }
- } else if (sub === 'add') {
- const name = positional.slice(1).join(' ')
- if (!name) { console.error('Usage: redlog-cli operators add '); process.exit(1) }
- const res = await request('POST', '/api/operators', { name })
- if (res.status === 201) {
- console.log(`Created: ${res.data.operator.id}`)
- console.log(`Token (save now — not shown again):`)
- console.log(` ${res.data.token}`)
- } else { console.error(`Error ${res.status}:`, res.data); process.exit(1) }
- } else if (sub === 'rotate') {
- const id = positional[1]
- if (!id) { console.error('Usage: redlog-cli operators rotate '); process.exit(1) }
- const res = await request('POST', `/api/operators/${encodeURIComponent(id)}/rotate`)
- if (res.status === 200) {
- console.log(`New token for ${id}:`)
- console.log(` ${res.data.token}`)
- } else { console.error(`Error ${res.status}:`, res.data); process.exit(1) }
- } else if (sub === 'revoke') {
- const id = positional[1]
- if (!id) { console.error('Usage: redlog-cli operators revoke '); process.exit(1) }
- const res = await request('POST', `/api/operators/${encodeURIComponent(id)}/revoke`)
- console.log(res.status === 200 ? `Revoked ${id}` : `Error ${res.status}: ${JSON.stringify(res.data)}`)
- if (res.status !== 200) process.exit(1)
- } else if (sub === 'delete') {
- const id = positional[1]
- if (!id) { console.error('Usage: redlog-cli operators delete '); process.exit(1) }
- const res = await request('DELETE', `/api/operators/${encodeURIComponent(id)}`)
- console.log(res.status === 200 ? `Deleted ${id}` : `Error ${res.status}: ${JSON.stringify(res.data)}`)
- if (res.status !== 200) process.exit(1)
- } else {
- console.error(`Unknown operators subcommand: ${sub}. Use list|add|rotate|revoke|delete`)
+ if (sub !== 'list') {
+ console.error(`Unknown operators subcommand: ${sub}. Only 'list' remains.`)
process.exit(1)
}
+ const res = await request('GET', '/api/operators')
+ if (res.status === 200) {
+ for (const op of res.data.operators) {
+ const tag = op.isPrimary ? '[PRIMARY]' : op.revokedAt ? '[REVOKED]' : ' '
+ console.log(` ${tag} ${op.id.padEnd(28)} ${op.name}`)
+ }
+ } else { console.error(`Error ${res.status}:`, res.data); process.exit(1) }
break
}
diff --git a/cli/redlog-sign.js b/cli/redlog-sign.js
deleted file mode 100755
index 9662493e..00000000
--- a/cli/redlog-sign.js
+++ /dev/null
@@ -1,179 +0,0 @@
-#!/usr/bin/env node
-// redlog-sign — sign a plugin tarball for the RedLog marketplace registry.
-//
-// Publishers run this locally after tarring their plugin. It:
-// 1. Reads the tarball, computes sha256.
-// 2. Signs "sha256:" with an Ed25519 private key.
-// 3. Emits a JSON snippet suitable for pasting into the registry index.
-//
-// Usage:
-// redlog-sign keygen -> writes keypair.json (pub/priv b64)
-// redlog-sign sign --key [--id ] [--version ]
-// -> prints the signed index entry
-//
-// Design intent:
-// - Ed25519 only (spec §5). No knobs.
-// - No network. No touching ~/.redlog. Operator-side utility.
-// - Same trust model as publisher-trust.ts: SPKI b64 public key, base64
-// signature over the ASCII string "sha256:".
-
-const fs = require('fs')
-const path = require('path')
-const crypto = require('crypto')
-
-function fail(msg) {
- console.error(`redlog-sign: ${msg}`)
- process.exit(1)
-}
-
-function parseArgs(argv) {
- const args = argv.slice(2)
- const cmd = args[0]
- const rest = args.slice(1)
- const positional = []
- const flags = {}
- for (let i = 0; i < rest.length; i++) {
- const a = rest[i]
- if (a.startsWith('--')) {
- const key = a.slice(2)
- const val = rest[i + 1] && !rest[i + 1].startsWith('--') ? rest[++i] : true
- flags[key] = val
- } else {
- positional.push(a)
- }
- }
- return { cmd, positional, flags }
-}
-
-function keygen(flags) {
- const out = flags.out || 'redlog-plugin-keypair.json'
- if (fs.existsSync(out) && !flags.force) fail(`refuse to overwrite ${out} — pass --force to override`)
- const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519')
- const pubDer = publicKey.export({ format: 'der', type: 'spki' })
- const privDer = privateKey.export({ format: 'der', type: 'pkcs8' })
- const record = {
- algorithm: 'ed25519',
- publicKey: pubDer.toString('base64'),
- privateKey: privDer.toString('base64'),
- createdAt: new Date().toISOString(),
- label: flags.label || undefined
- }
- fs.writeFileSync(out, JSON.stringify(record, null, 2), { mode: 0o600 })
- const fpHex = crypto.createHash('sha256').update(pubDer).digest('hex')
- const fp = fpHex.match(/.{2}/g).slice(0, 16).join(':')
- console.log(`wrote keypair → ${out} (mode 0600)`)
- console.log(`publicKey fingerprint: ${fp}`)
- console.log()
- console.log('Share the publicKey field with the RedLog registry maintainer;')
- console.log('keep privateKey local — anyone with it can sign plugins as you.')
-}
-
-function sign(positional, flags) {
- const tarballPath = positional[0]
- if (!tarballPath) fail('sign: missing tarball path')
- const keyPath = flags.key
- if (!keyPath) fail('sign: --key is required')
- if (!fs.existsSync(tarballPath)) fail(`sign: no such file: ${tarballPath}`)
- if (!fs.existsSync(keyPath)) fail(`sign: no such key: ${keyPath}`)
-
- const bytes = fs.readFileSync(tarballPath)
- const hexHash = crypto.createHash('sha256').update(bytes).digest('hex')
- const message = Buffer.from(`sha256:${hexHash}`, 'utf-8')
-
- const keypair = JSON.parse(fs.readFileSync(keyPath, 'utf-8'))
- if (keypair.algorithm && keypair.algorithm !== 'ed25519') {
- fail(`sign: unsupported key algorithm: ${keypair.algorithm}`)
- }
- const privKey = crypto.createPrivateKey({
- key: Buffer.from(keypair.privateKey, 'base64'),
- format: 'der',
- type: 'pkcs8'
- })
- const signature = crypto.sign(null, message, privKey).toString('base64')
-
- // Try to sniff id + version from the tarball's plugin.json if the flags
- // weren't supplied. Best-effort — a signer with an unusual tarball layout
- // can still pass them explicitly.
- let id = flags.id
- let version = flags.version
- if ((!id || !version) && !flags['no-sniff']) {
- try {
- const parsed = sniffManifest(tarballPath)
- if (!id) id = parsed?.id
- if (!version) version = parsed?.version
- } catch { /* leave fields blank */ }
- }
-
- const entry = {
- id: id || '',
- version: version || '',
- publisher: flags.publisher || '',
- tarball: flags.url || '',
- sha256: hexHash,
- signature,
- sizeKb: Math.round(bytes.length / 1024)
- }
- if (flags.name) entry.name = flags.name
- if (flags.description) entry.description = flags.description
- if (flags.homepage) entry.homepage = flags.homepage
- if (flags.tags) entry.tags = String(flags.tags).split(',').map((s) => s.trim()).filter(Boolean)
-
- // Order the keys so the JSON is stable and easy to diff.
- const orderedKeys = ['id', 'name', 'description', 'homepage', 'publisher', 'version', 'tarball', 'sha256', 'signature', 'sizeKb', 'tags']
- const ordered = {}
- for (const k of orderedKeys) if (k in entry) ordered[k] = entry[k]
-
- console.log(JSON.stringify(ordered, null, 2))
-}
-
-// Peek inside a .tar.gz to pull { id, version } out of plugin.json without
-// extracting to disk. Falls back gracefully if node's tar isn't around — we
-// just use gunzip + streaming tar parse via headers.
-function sniffManifest(tarballPath) {
- const zlib = require('zlib')
- const buf = fs.readFileSync(tarballPath)
- const tar = zlib.gunzipSync(buf)
- // Tar block size is 512.
- for (let off = 0; off + 512 <= tar.length;) {
- const nameEnd = tar.indexOf(0, off)
- const name = tar.slice(off, nameEnd < 0 || nameEnd > off + 100 ? off + 100 : nameEnd).toString('utf-8').replace(/\0+$/, '')
- if (!name) break
- // Octal size at offset +124 (12 bytes ASCII).
- const sizeStr = tar.slice(off + 124, off + 136).toString('ascii').trim().replace(/\0/g, '')
- const size = parseInt(sizeStr, 8)
- off += 512
- if (isNaN(size)) break
- if (name.endsWith('plugin.json')) {
- const body = tar.slice(off, off + size).toString('utf-8')
- try { return JSON.parse(body) } catch { return null }
- }
- off += Math.ceil(size / 512) * 512
- }
- return null
-}
-
-function help() {
- console.log(`redlog-sign — sign a plugin tarball for the RedLog marketplace.
-
- keygen [--out ] [--label ] [--force]
- Generate an Ed25519 keypair. Default: ./redlog-plugin-keypair.json (0600).
-
- sign --key
- [--id ] [--version ]
- [--publisher ] [--url ]
- [--name ] [--description ] [--homepage ]
- [--tags foo,bar]
- Print a registry index entry with sha256 + Ed25519 signature.
-
- help
- This message.`)
-}
-
-const { cmd, positional, flags } = parseArgs(process.argv)
-switch (cmd) {
- case 'keygen': keygen(flags); break
- case 'sign': sign(positional, flags); break
- case 'help':
- case undefined: help(); break
- default: fail(`unknown command: ${cmd}`)
-}
diff --git a/docs/ALERT-ROLES.md b/docs/ALERT-ROLES.md
new file mode 100644
index 00000000..7b408971
--- /dev/null
+++ b/docs/ALERT-ROLES.md
@@ -0,0 +1,726 @@
+# Alert Roles — how RedLog warns without lying
+
+Written 2026-08-14. Applies `DECOMPOSITION-METHOD.md` to the **alerting** subsystem
+(`ip-monitor.ts`, `scope-monitor.ts`, and the badge/flash/event/webhook surfaces they
+feed). It is the 10th subsystem in the map and the first one whose members are
+*verdicts*, not components. Its job: **every warning RedLog raises is one of two
+roles, carries an explicit authority (fact / inferred / unknown), and sits at a named
+distance from the operator's declared intent — so "add an alert" is filling a
+template, and no verdict can present an inference as a fact.**
+
+RedLog never blocks. An alert is therefore the *entire* intervention — if it is
+wrong-coloured, there is no second line of defence.
+
+## Principle
+
+> **A green light must be a fact. Everything else is a shade of "I don't know yet."**
+
+The failure mode this subsystem exists to prevent is not a missed alert — it is a
+**confident green**. An operator glancing at the status bar and seeing SAFE while
+their VPN has silently dropped is worse than no indicator at all, because it
+converts uncertainty into false assurance.
+
+## The two roles (mechanism axis: signal shape)
+
+```
+"Where am I?" — periodic poll of my own egress position → SELF ALARM (state machine)
+"What did I hit?" — per-action evaluation of a target → TARGET ALARM (event stream)
+```
+
+The shapes are genuinely different and that difference explains every asymmetry in
+today's code:
+
+| | **Self alarm** | **Target alarm** |
+|---|---|---|
+| Trigger | timer (`checkInterval`, default 10s) | each `command_start` with an extracted target |
+| Output | a *level* that persists until changed | a *record* that happened once |
+| Needs hysteresis? | **yes** — flapping egress must not flap the badge (`confirmations`, `settling`) | **no** — every occurrence is its own fact |
+| Needs distance? | **no** — you are either at an expected address or not | **yes** — a target can be near-miss or unrelated |
+| Failure to read | must degrade the level (stale) | no reading = no event |
+| Instance | `IPMonitor` | `ScopeMonitor` |
+
+**Do not port hysteresis to Target alarms** (each command is discrete) and **do not
+port the distance ladder to Self alarms** (there is no "nearly my VPN").
+
+## The tier axis (authority → the rules, not the shape)
+
+Inherited from §3 / K1 (`DECOMPOSITION-BACKLOG.md`): every verdict declares whether
+it is observed or inferred.
+
+| Tier | Meaning | Rules it carries |
+|---|---|---|
+| **fact** | a configured rule matched literally | cannot be silenced by a preference toggle; renders solid; always forwards to deconfliction |
+| **inferred** | derived by proximity or by absence-of-match | must render dashed/qualified; silenceable per engagement; must name the inference in the event |
+| **unknown** | insufficient data (no lists, no reading, no extractable target) | is its **own colour** — never collapses into safe |
+
+The single largest defect in the subsystem today is a tier violation: an **inferred**
+verdict ("your IP isn't on the blacklist") is rendered with the **fact** presentation
+(solid green SAFE). See A-3 and A-9 below.
+
+---
+
+# Part A — Self alarm: the IP verdict combination matrix
+
+## A.1 The base combination (what is configured × what matched)
+
+Four independent bits produce the whole space: `W` = whitelist configured, `B` =
+blacklist configured, `w` = IP ∈ whitelist, `b` = IP ∈ blacklist. Nine reachable
+combinations (`w`/`b` are undefined when the corresponding list is empty):
+
+| # | W | B | b | w | Should be | Tier | `classify()` today | Verdict |
+|---|:-:|:-:|:-:|:-:|---|---|---|---|
+| A-1 | ✗ | ✗ | – | – | `unknown` | unknown | `unknown` | ✅ |
+| A-2 | ✗ | ✓ | ✓ | – | `exposed` | fact | `exposed` | ✅ |
+| A-3 | ✗ | ✓ | ✗ | – | `presumed_safe` | **inferred** | `presumed_safe` | ✅ **fixed** (G-A2) |
+| A-4 | ✓ | ✗ | – | ✓ | `safe` | fact | `safe` | ✅ |
+| A-5 | ✓ | ✗ | – | ✗ | `off_profile` | fact | `off_profile` | ✅ **fixed** (G-A2) |
+| A-6 | ✓ | ✓ | ✓ | ✓ | `exposed` + conflict | fact | `exposed` + `listConflict` | ✅ **fixed** (G-A2) |
+| A-7 | ✓ | ✓ | ✓ | ✗ | `exposed` | fact | `exposed` | ✅ |
+| A-8 | ✓ | ✓ | ✗ | ✓ | `safe` | fact | `safe` | ✅ |
+| A-9 | ✓ | ✓ | ✗ | ✗ | `off_profile` | fact | `off_profile` | ✅ **fixed** (G-A1 then G-A2) |
+
+**A-9 was the dangerous cell — fixed (G-A1).** `classify()` used to fall through to
+`if (this.blacklist.length > 0) return 'safe'` *after* the whitelist test had already
+failed. Concretely: whitelist `10.8.0.0/24` (my VPN), blacklist `1.2.3.4` (my home
+IP), actual egress `5.6.7.8` (VPN dropped, now on café NAT) → **SAFE, solid green**.
+The blacklist-mode shortcut was written for the blacklist-only case (A-3) and was
+never re-scoped when a whitelist is also present.
+
+The rule now reads: **declaring a whitelist declares an expectation; being outside it
+is never `safe`.** G-A1 closed the false green by routing A-5 and A-9 to `unknown`;
+G-A2 then gave them the verdict they actually deserve. Amber said "I don't know",
+where the truth is the stronger "you are not where you said you would be" — an
+observed deviation filed as missing information.
+
+**A-5 is the mirror mistake.** Declaring a whitelist *is* declaring an expectation;
+being outside it is an observed deviation (fact), not a lack of information
+(`unknown`). Today it lands in the same amber bucket as "nothing configured at all."
+
+## A.2 The verdict vocabulary this implies
+
+Three states cannot express nine cells. The closed set is five, plus modifiers:
+
+| Verdict | Tier | Meaning | Tone | Qualified? |
+|---|---|---|---|---|
+| `exposed` | fact | on the blacklist — real identity is leaking | red | no (flashes) |
+| `off_profile` | fact | a whitelist exists and this is not in it | orange | no |
+| `safe` | fact | on the whitelist | green | no |
+| `presumed_safe` | **inferred** | blacklist-only, no match — "not obviously you" | green | **yes** — hollow, unglowing |
+| `unknown` | — (no claim) | nothing configured / no reading | amber | no |
+
+**Five verdicts, four tones.** `presumed_safe` shares the `safe` tone and is
+separated by `qualified`, so a surface paints four colours rather than five and an
+inference can never render as a solid green fill. That reuses the `lib/ip-badge.ts`
+mechanism G-A3 built for `stale`/`settling` — the precedence runs from "how much do
+we know" outward: no reading beats an unconfirmed reading beats a confirmed reading
+we can only infer from.
+
+`verdictAuthority()` maps each verdict onto K1's `Authority`, so no surface
+re-derives "is this an inference?" from the verdict's name. `unknown` returns `null`:
+it asserts nothing, so it makes no claim to a tier. The `ip_transition` event stamps
+the result per event — a transition into `presumed_safe` records an inference while
+every other one records an observation, the same split-authority shape as
+`scope_violation`.
+
+**A-6 (`listConflict`)** is reported *alongside* the verdict, not as one. The verdict
+is `exposed` and correct — the blacklist wins — but a red badge looks like every
+other red badge, so the operator would never learn their two lists contradict each
+other. It says something about the CONFIG, not about where the operator is.
+
+## A.3 The confidence modifiers (orthogonal — they multiply, not add rows)
+
+These do **not** create new cells; they qualify any cell. This is the part that keeps
+the matrix from exploding.
+
+| Modifier | Source | Status |
+|---|---|---|
+| `settling` | new address seen < `confirmations` times | ✅ **shipped** — badge shows the last stable verdict, hollow dot + reason |
+| `stale` | `consecutiveFailures` reached `network.staleAfter` | ✅ **shipped** — verdict decays to `unknown`, last address retained |
+| `family` | reading is IPv6 | ❌ silently mis-classified (below) — G-A5 |
+
+**`stale` was a live hazard.** A VPN kill-switch dropping the network is exactly when
+external-IP lookup fails — and the badge kept showing the last verdict (often green)
+indefinitely, with only `lastCheck` moving. Neither modifier was even *declared* on
+the renderer's `IPStatus`, so no surface could have shown them.
+
+Three rules the fix pins down:
+
+1. **The decay is uniform.** An `exposed` verdict decays too. Holding a red alarm on
+ a reading we can no longer confirm is the same dishonesty as holding a green one —
+ `unknown` is what we actually know. The last seen address stays on screen, so the
+ decay re-labels rather than erases.
+2. **`staleAfter` (default 2) is deliberately tighter than `confirmations`
+ (default 3).** The thresholds look alike but point opposite ways: being slow to
+ *promote* a new address is safe — you keep showing a verdict you verified. Being
+ slow to *expire* an old one is not. One failure can be a provider hiccup; two in a
+ row at the shipped 60s poll is two minutes of no contact.
+3. **One decision, three surfaces.** `lib/ip-badge.ts` maps (verdict, settling,
+ stale) → (tone, qualified, reason). StatusBar, IPStatusCard and the HUD keep their
+ own styling but cannot disagree about the verdict. `qualified` renders as a hollow,
+ unglowing dot — a filled dot is reserved for "this is what we see right now".
+
+**Two further defects surfaced while fixing this**, both in the same
+unchanged-address branch of `check()`, which reset the flags but never re-ran
+`classify()`:
+
+- A decayed verdict **never recovered** if the network came back on the *same* exit IP
+ — the common case for a VPN blip. The badge stayed amber indefinitely.
+- Editing the safe/exposed lists in Settings **did not move the badge** until the
+ external address happened to change.
+
+The verdict is a pure function of (address, lists), so it is now derived on every
+poll rather than only on address change.
+
+**IPv6 was structurally broken — fixed (G-A5).** `ipInCIDR()` compared an IPv6
+address to a CIDR by string-equality against the network part, so `2001:db8::1` ∈
+`2001:db8::/32` returned **false**. Any v6 whitelist never matched (→ A-5/A-9), and a
+v6 egress against a v4-only blacklist took the A-3 fall-through → `safe`. The A-9 fix
+did not reach v6 at all.
+
+`ip-match.ts` now does real prefix matching for both families, and is the **single**
+matcher for the subsystem — `ip-monitor.ts` and `scope-monitor.ts` each carried their
+own `ipToLong` + CIDR copy before. It also compares parsed values rather than strings,
+so `2001:db8::1` matches the fully expanded `2001:0db8:0000:...:0001`, and reports an
+IPv4-mapped address (`::ffff:10.8.0.5`) as IPv4 — providers return that form, and an
+operator whose whitelist says `10.8.0.0/24` means it to match. Malformed input is
+`false`, never an accidental wildcard.
+
+**The scope side had the same defect in a worse direction (G-B5).** `scope-monitor`'s
+`IP_RE` matched only dotted quads, so a v6 target was routed through the *domain*
+matcher: a v6 CIDR scope entry never matched, and on the adjacency path a v6 host fell
+out as `unrelated` — **silent**. Per Part D that is the one direction this subsystem
+must not fail in, so the fix covers both monitors rather than only the one the gap row
+named. A single-IP v6 scope entry expands to its **/64** rather than to
+`proximityBits`: v6 subnets are /64 near-universally, so there is no operator choice
+to expose (Part C.4).
+
+**Remaining v6 limitation (not a defect):** the DNS lookup path filters to v4
+(`IPV4_RE`), so a v6 external address is only ever *read* in `http` / fallback mode.
+
+## A.4 The unclassified half — fixed (G-A4)
+
+`internalIP` was collected, displayed, and **never classified**. A laptop that
+silently reassociated to a guest SSID mid-engagement read exactly like one still on
+the client VLAN, and the external verdict cannot catch it: the egress can be
+perfectly fine while you are on the wrong network.
+
+`network.lanProfile` declares the internal segments the engagement expects, and the
+prediction in this section held — **the same whitelist machinery serves it**, with no
+new verdict vocabulary and no new severity step. `classifyIP` runs with the profile
+as the whitelist and no blacklist, so exactly three of the nine cells are reachable:
+
+| lanProfile | Internal address | Verdict |
+|---|---|---|
+| unset | anything | `unknown` — nothing declared, nothing claimed (A-1) |
+| set | inside a listed CIDR | `safe` (A-4) |
+| set | outside every listed CIDR | `off_profile` (A-5) |
+
+**There is deliberately no LAN blacklist.** "This is my own segment" is what the
+profile already says, from the other direction — adding a second list would give the
+same fact two contradictory homes.
+
+**`lanSafety` never goes stale.** It is a local read of the network interfaces, so
+nothing about it expires when an external lookup fails. Fixing that surfaced a
+pre-existing defect: `Promise.all` discarded a perfectly good internal read along
+with the external rejection. The failure path now re-reads it — losing your LAN
+verdict because the *internet* died is exactly backwards, since dropping off the
+client VLAN is more likely precisely when the network is misbehaving.
+
+A move between internal segments joins the existing `ip_transition` event
+(`from_lan_safety` / `to_lan_safety`), because it is the same class of drift signal
+as the egress changing.
+
+**Considered and not done:** feeding `internalIP` into the Target alarm's D2
+subnet-proximity rule — "the target is on *my* segment but not in scope". The
+containers there are derived from *scope entries*, which is a statement about what
+was authorised; deriving one from wherever the operator's laptop happens to sit is a
+different claim, and adding D2 sources is exactly what the ladder decomposition
+exists to keep deliberate. It would need its own row, not a quiet widening of this
+one.
+
+---
+
+# Part B — Target alarm: the distance ladder
+
+## B.1 The four distances
+
+The mechanism is **distance from declared intent**, and it is the same ladder for IPs
+and for domains:
+
+| | Distance | Condition | Tier | Alert |
+|---|---|---|---|---|
+| **D0** | in scope | matches `targets` | — | none |
+| **D1** | forbidden | matches `excludeTargets` | fact | **always** — not silenceable |
+| **D2** | adjacent | in a scope entry's *container*, not in `targets` | inferred | warn (silenceable via `warnOnViolation`) |
+| **D3** | unrelated | outside every container | — | silent (count only) |
+
+D1 outranks D0 by design (an explicit exclusion inside a broad CIDR must still fire) —
+that ordering is already correct in `checkTarget()`.
+
+## B.2 The container rule (this is the whole of "same subnet / same domain")
+
+D2 needs a definition of "near." The rule is symmetric across both address families
+and closes in one line:
+
+> **A single point expands one level into its container. An entry that already *is* a
+> container does not expand.**
+
+| Scope entry | Kind | Container (= the D2 zone) | Rationale |
+|---|---|---|---|
+| `192.168.1.10` | single IP | `192.168.1.0/24` | a bare IP carries no boundary; deriving one *fills a gap* |
+| `10.0.0.0/8` | CIDR | *(none)* | the operator stated a boundary; widening it *invents authorisation* |
+| `10.0.5.0/24` | CIDR | *(none)* | same |
+| `www.example.com` | single host | `*.example.com` (registrable domain) | sibling subdomains are the near-miss zone |
+| `*.staging.example.com` | wildcard | `*.example.com` (registrable domain) | **still expands** — see the asymmetry below |
+
+**Correction (2026-08-14, during implementation).** An earlier revision of this
+table said a wildcard entry does *not* expand, by analogy with CIDRs. That was
+wrong, and the counterexample is the case D2 most exists for: scope
+`*.staging.example.com`, operator hits `prod.example.com`. Under "no expansion"
+that is D3 — silent. It must be D2.
+
+The asymmetry is real and deliberate: **the domain container is always the
+registrable domain, because that domain is the *ownership* boundary the
+authorisation is actually about.** A wildcard is a sub-boundary *within* something
+owned, not the ownership boundary itself. A CIDR has no comparable ownership
+boundary recoverable from the string — `10.0.0.0/8` tells you nothing about whether
+the org also owns `192.168.0.0/16` — which is why it cannot expand. The rule at the
+top of this section governs the IP side; the domain side always expands.
+
+So both requested alerts fall out of one rule:
+
+- **同網段但非指定 IP** — scope lists `192.168.1.10`, `192.168.1.20`; operator hits
+ `192.168.1.55` → inside the derived `/24` container, not in `targets` → **D2**.
+ Almost always "right subnet, wrong box" — the highest-value warning in the ladder.
+- **同 domain 但非指定 subdomain** — scope lists `www.example.com`; operator hits
+ `admin.example.com` → inside `*.example.com`, not in `targets` → **D2**.
+
+The container prefix length for single IPs must be configurable
+(`scope.proximityBits`, default 24): a /24 assumption is right for lab and internal
+ranges and wrong for a routed /30 hand-off.
+
+## B.3 Where `ScopeMonitor` sits today
+
+| Case | Ladder | Status |
+|---|---|---|
+| in `targets` | D0 | ✅ |
+| in `excludeTargets` | D1 | ✅ fires regardless of `warnOnViolation` |
+| domain, shares registrable domain | D2 `adjacent_domain` | ✅ — but the registrable-domain approximation is still wrong (B.4 / G-B2) |
+| domain, different root | D3 | ✅ counted |
+| single-IP scope entry, same segment | D2 `adjacent_subnet` | ✅ **shipped (G-B1)** |
+| IP outside every container | D3 | ✅ **shipped (G-B3)** — was "violation, always" |
+
+Before G-B1/G-B3 the `if (!isIP)` guard in `checkTarget()` meant the proximity filter
+applied to domains only; IPs skipped it and every out-of-scope IP raised. That is the
+classic alerting death spiral — the noisy channel gets muted, and the D2 signal dies
+with it. Both branches now walk the same ladder via the pure `classifyDistance()`.
+
+**D3 is counted, not dropped** (`getUnrelatedCount()`): "silent" must be
+distinguishable from "not looking", and the adherence report (G-D1) needs the
+denominator. Note also that a D3 target still lands in the timeline as
+`detectedTarget` like any other command — only the *alert* is suppressed, never the
+record.
+
+## B.4 The registrable-domain defect — fixed (G-B2)
+
+`getRootDomain()` took the last two labels. So `shop.example.co.uk` → `co.uk`, and
+`anything.bbc.co.uk` → `co.uk` as well. With scope `*.example.co.uk`, **every
+`.co.uk` host in the world became D2-adjacent.** The platform half bit harder in
+practice: a scope of `target.github.io` made every GitHub Pages site a near-miss,
+and the same for `*.s3.amazonaws.com`, `*.azurewebsites.net`, `*.herokuapp.com`.
+
+`public-suffix.ts` now derives the true registrable domain (public suffix + one
+label, longest suffix wins) from a curated table, with `scope.publicSuffixes` for
+per-engagement additions.
+
+**Why a curated table and not the full PSL.** The full list means a runtime
+dependency (~3 MB; `tldts` is present only as a `jsdom` dev-transitive, not in the
+shipped app). RedLog ships eight runtime dependencies deliberately — for an evidence
+tool, supply-chain surface is a cost every user pays. The deciding argument is the
+**failure direction**, which is asymmetric:
+
+| Table error | Effect | Verdict |
+|---|---|---|
+| suffix **missing** | falls back to last-two-labels → over-match → noisier D2 | exactly today's behaviour — degrades to the status quo, never below it |
+| suffix **wrongly present** | same-owner hosts get different registrable domains → D3 → **silence** | unacceptable: Part D means there is no second line of defence |
+
+So the table carries only unambiguous suffixes; anything doubtful is left out,
+costing noise rather than silence, and operators extend it per engagement. That
+property is pinned by a test, not just asserted here.
+
+**Known gap:** regional S3 forms (`s3.us-east-1.amazonaws.com`) are absent and fall
+back to `amazonaws.com` — over-match, per the safe direction above.
+
+## B.5 What D2 must say when it fires
+
+A D2 alert is **inferred**, so per the tier axis it must name its inference. The
+`reason` vocabulary is now closed (G-B4) — it used to write `out_of_scope` for both
+the domain-adjacent case and (via the IP path) the unrelated case, leaving them
+indistinguishable downstream:
+
+`excluded_target` (D1, fact) · `adjacent_subnet` (D2, inferred) ·
+`adjacent_domain` (D2, inferred) · `unrelated` (D3 — counted, not emitted)
+
+The `scope_violation` event also carries `authority: 'fact' | 'inferred'` — the data
+half of the §3 split. K1's minimal slice has since landed the rest: `core/authority.ts`
+resolves authority for **every** event type (per-event stamp first, then
+`EventTypeDef.authority`, then a built-in table, then `fact`), `insertEvent` writes
+`inferred` into the hashed row, and `lib/dotShape.ts` renders it as a dashed,
+unfilled dot — the same statement the phase ribbon was already making, no longer
+phase-only.
+
+`scope_violation` is the case that forced per-event precedence: it is `fact` for an
+excluded target and `inferred` for a proximity match, so no type-level default can be
+right for it. That corrected a mis-classification in `EVENT-TYPE-VOCABULARY.md`, which
+had filed it under detector-derived (uniformly inferred) *and* under `system`
+(uniformly authoritative).
+
+That pair now drives the deconfliction feed (G-C2). Both fields ride **outside** the
+`includeData` PII gate, because they are bounded enums: a receiver must be able to
+triage a `scope_violation` without being handed the command text. `authorityFloor:
+'fact'` holds inferences back; the default still forwards both, labelled.
+
+**Why the default is not fact-only.** Both tiers describe activity that really
+happened — the tier is about how confident RedLog is that it *matters*, not about
+whether it occurred. Quietly telling the blue team less about real out-of-scope
+activity is the wrong direction to fail in, so narrowing an outward feed is an
+explicit act.
+
+**Wire-format note:** consumers parsing exported violations will see
+`adjacent_domain` where they previously saw `out_of_scope`.
+
+---
+
+## Completeness
+
+Every warning-producing site in the codebase maps to exactly one role:
+
+| Site | Role | Tier | Surface |
+|---|---|---|---|
+| `ip-monitor.ts` `classify()` | Self | fact / inferred (A-3) | `IPStatus.ipSafety` |
+| `ip-monitor.ts` `settling` | Self (modifier) | inferred | (unrendered) |
+| `ip-monitor.ts` `error` | Self (modifier) | unknown | `IPStatus.error` |
+| `main/index.ts` safety-change event | Self → record | fact | `system` event → deconfliction |
+| `scope-monitor.ts` excluded branch | Target | fact | `scope_violation` |
+| `scope-monitor.ts` root-domain branch | Target | inferred | `scope_violation` |
+| `scope-monitor.ts` IP out-of-scope branch | Target | **mis-tiered** (fact presentation, D3 content) | `scope_violation` |
+| `StatusBar` / `IPStatusCard` / overlay `flashOnExposed` | Self, render | — | badge/flash |
+| `ScopeStatus` badge, `Timeline` diamond | Target, render | — | badge/glyph |
+
+**Honest boundary:** complete over the *current* surface. Two things the operator may
+reasonably call "an alert" are deliberately out of this decomposition because they are
+other subsystems' members: loot/credential detections (`DETECTOR-ROLES.md`) and
+capture-health "dark/partial" (a health verdict, not a warning).
+
+## Per-role template
+
+Both roles fill the same headings:
+
+1. **Contract** — the closed verdict vocabulary it may emit.
+2. **Tier per verdict** — fact / inferred / unknown, declared, not implied.
+3. **Degradation** — what the verdict becomes when the input is missing. (Self:
+ decays to `unknown`. Target: emits nothing.)
+4. **Silenceability** — fact verdicts are never silenceable; inferred ones name the
+ config flag that mutes them.
+5. **Surface binding** — which badge/glyph/event/webhook renders it, and how the tier
+ changes the rendering (solid vs dashed/outline).
+6. **Test seam** — Self: a table-driven test over the combination matrix. Target: a
+ table-driven test over the distance ladder.
+
+## The severity scale (G-C1)
+
+The two roles grew their colour vocabularies independently and they did not line up.
+The Self alarm had four tones, one per verdict. The Target alarm had an **on/off
+light** — `scopeViolations > 0 ? red : green` — so a D1 hit on an explicitly
+forbidden host and a D2 proximity inference rendered identically. G-B4 made them
+distinguishable in the data and G-C2 on the wire; the operator's eye was where the
+distinction stopped.
+
+One scale, four steps, both roles:
+
+| Severity | Colour | Self alarm | Target alarm |
+|---|---|---|---|
+| `critical` | red | `exposed` | D1 `excluded_target` |
+| `warn` | orange | `off_profile` | D2 `adjacent_subnet` / `adjacent_domain` |
+| `unknown` | amber | `unknown` | — |
+| `notice` | grey | — | D3 `unrelated`, only under `alertFloor: 'all'` (G-C3) |
+| `ok` | green | `safe`, `presumed_safe` | D0, silenced D3 |
+
+`notice` ranks below `unknown`: "here is something that happened, out of scope but
+unrelated" asks less of the operator than "I cannot tell you whether you are safe".
+
+D1 sits level with `exposed` deliberately: both are the thing the operator must not
+do, both observed, both non-silenceable — one is an OPSEC failure, the other an
+authorisation failure.
+
+**Severity and authority are orthogonal — do not fold one into the other.**
+
+- **severity** (this scale) — how loudly to shout. Sets the **colour**.
+- **authority** (K1) — observed or inferred. Sets the **fill**: solid, or hollow
+ and unglowing.
+
+That is why `presumed_safe` and a D2 near-miss are both inferred yet look nothing
+alike: `ok` + inferred is a hollow green, `warn` + inferred a hollow orange.
+Collapsing the axes would force one of those two to lie. It is also why
+`presumed_safe` is `ok` and not `warn` — it is the *good* answer, merely an inferred
+one, and the caveat belongs on the authority axis rather than in an inflated colour.
+
+`lib/alertSeverity.ts` owns the levels and both colour tables (`SEVERITY_HUD` for the
+overlay, `SEVERITY_CLASS` for the app), replacing three hand-maintained per-verdict
+maps. `worstSeverity()` gives the summary rule: one observed rule match outranks any
+number of inferences.
+
+## Gaps (backlog)
+
+Fifteen in total, split across two tables — twelve here (A/B/C) and three under
+`D.5` (`G-C3`, `G-D1`, `G-D2`). All fifteen are now closed. Which commit closed
+what, so the arithmetic is checkable rather than asserted:
+
+| Commits | Gaps closed | Count |
+|---|---|---|
+| `4cc4ca7`, `594156b`, `9518079` | G-A1, G-A3, G-A5, G-B1, G-B2, G-B3, G-B4, G-B5 | 8 |
+| `5e155a1` | *(none — the K1 `authority` primitive the rest depend on)* | 0 |
+| `c99d78f` | G-A2, G-A4, G-C1, G-C2, G-C3, G-D1, G-D2 | 7 |
+
+> **Errata.** `c99d78f`'s commit message says it closes "the remaining fourteen
+> gaps". It closes **seven** — the number above. The message is wrong and is left
+> as written: correcting it means rewriting history on a shared branch, which
+> costs more than the error does. This table is the accurate record.
+
+| # | Gap | Kind | Notes |
+|---|---|---|---|
+| **G-A1** | ~~`classify()` returns `safe` when both lists are configured and neither matches (A-9)~~ | ✅ **fixed** | A whitelist miss is never `safe`. Verdict is `unknown` pending the `off_profile` state (G-A2). Test seam: exported pure `classifyIP()`, table-driven over A-1..A-9 in `test/ip-monitor.test.ts`. |
+| **G-A2** | ~~No `off_profile` / `presumed_safe` verdicts — 3 states cannot encode 9 cells~~ | ✅ **fixed** | Five verdicts, four tones, `verdictAuthority()` mapping onto K1's `Authority`. A-6 surfaced as `listConflict`. All nine cells now encodable. |
+| **G-A3** | ~~`settling` and `error` never reach the badge; verdict never decays~~ | ✅ **fixed** | Decay to `unknown` after `network.staleAfter` (default 2); shared `lib/ip-badge.ts` renders both modifiers on all three surfaces. Also fixed: verdict never re-derived on an unchanged address. |
+| **G-A4** | ~~`internalIP` collected but never classified; no lan-profile~~ | ✅ **fixed** | `network.lanProfile` + `IPStatus.lanSafety`, reusing `classifyIP` — no new vocabulary. Also fixed: a failed external poll used to discard the local internal read. Feeding it into D2 proximity was considered and declined (A.4). |
+| **G-A5** | ~~IPv6 CIDR matching is string-equality — v6 whitelists never match~~ | ✅ **fixed** | Shared `ip-match.ts` (both families, parsed-value comparison, v4-mapped handling). Replaced two duplicate matchers. |
+| **G-B1** | ~~No container derivation for single-IP scope entries~~ | ✅ **fixed** | Single-IP entries expand to `scope.proximityBits` (default 24). CIDR entries never widen. |
+| **G-B2** | ~~`getRootDomain()` = last two labels → `.co.uk`/`.com.tw` over-match~~ | ✅ **fixed** | Curated table in `public-suffix.ts` + `scope.publicSuffixes`. No new runtime dependency; incomplete → noise, never silence. |
+| **G-B3** | ~~IP out-of-scope bypasses the proximity filter~~ | ✅ **fixed** | Both branches walk one ladder via pure `classifyDistance()`. D3 counted via `getUnrelatedCount()`. |
+| **G-B4** | ~~`reason` vocabulary is not closed~~ | ✅ **fixed** | Closed to 3 values + `authority` on the event. G-C2 (tier-aware forwarding) is now unblocked. |
+| **G-B5** | ~~`IP_RE` is dotted-quad only, so a v6 target is routed to the domain matcher and falls out as `unrelated` — silent~~ | ✅ **fixed** | The scope-side twin of G-A5, found while fixing it. Same shared matcher; v6 single-IP entries expand to /64. |
+| **G-C1** | ~~No shared severity vocabulary between Self and Target alarms~~ | ✅ **fixed** | Four-step scale in `lib/alertSeverity.ts`, both roles mapped onto it. The Target alarm had no scale at all — it was an on/off light — so D1 and D2 now differ on screen, and violations carry `reason` + `authority` to the UI. |
+| **G-C2** | ~~Deconfliction forwards `scope_violation` wholesale~~ | ✅ **fixed** | Every forwarded event carries `authority` + `reason` outside the `includeData` gate; `deconfliction.authorityFloor` can hold inferences back. Default still forwards both. |
+
+## Cross-references
+
+- **`DECOMPOSITION-METHOD.md`** — the method; add alerting as the 10th row.
+- **`DECOMPOSITION-BACKLOG.md` K1** — the `authority: fact | inferred` primitive.
+ This subsystem is its second consumer; G-A2/G-B4 should land on top of it, not
+ beside it.
+- **`SPEC-SCOPE-AWARE-LIFECYCLE.md`** — `classifyTarget()` is the *pure* sibling of
+ `checkTarget()`. The distance ladder must be added there too, or sanitize and
+ alerting will disagree about what "out of scope" means.
+- **`DELIVERY-TARGETS.md`** — the deconfliction Stream is an alert consumer (G-C2).
+- **`DETECTOR-ROLES.md`** — loot/credential detections are Detectors, not Alerts.
+
+---
+
+# Part C — Toggles: what may be silenced, and at what granularity
+
+## C.1 The rule (falls out of the tier axis)
+
+> **Verdicts have no switches. Surfaces do.**
+> Within surfaces: **fact-tier alerts may be de-emphasised, never removed;
+> inferred-tier alerts may be silenced.**
+
+`overlay.flashOnExposed` is the existing correct precedent: it does not make
+`exposed` disappear, it only stops the flash. Any new toggle must be checkable
+against that shape — if flipping it can make a fact-tier verdict *unobservable*,
+it is the wrong toggle.
+
+## C.2 Self alarm: zero new toggles
+
+| Candidate | Verdict |
+|---|---|
+| "silence `exposed`" | ✗ fact — never |
+| "silence `off_profile`" | ✗ fact — never |
+| "opt out of `presumed_safe` outlining" | ✗ not a toggle — it is a *presentation of uncertainty*; making it optional re-creates the false green (A-3) |
+| "hide `settling` / `stale`" | ✗ these are bug fixes (G-A3), not preferences |
+| lan-profile whitelist (G-A4) | ✓ but it is a **config field**, not a toggle — same shape as `ipWhitelist` |
+
+The IP side needs **no new booleans**. Every gap in Part A is a correctness fix or
+a new list field.
+
+## C.3 Target alarm: one threshold, replacing the existing boolean
+
+The distance ladder is **ordered** (D1 > D2 > D3). An ordered space needs a floor,
+not N independent booleans — N booleans would let an operator construct incoherent
+states ("warn on unrelated but not on adjacent") and would violate §9 (surface
+weight mirrors necessity).
+
+```
+scope.alertFloor: 'excluded_only' | 'adjacent' | 'all' // default: 'adjacent'
+```
+
+| Value | Emits | Severity of the new rung | Use case |
+|---|---|---|---|
+| `excluded_only` | D1 | — | recon-heavy phase; third-party services dominate the target stream |
+| `adjacent` **(default)** | D1 + D2 | `warn` | normal engagement — "right subnet/domain, wrong host" is the signal you want |
+| `all` | D1 + D2 + D3 | `notice` | strict authorisation: *any* target not on the list is on the record |
+
+D1 is absent from every "off" position by construction — that is the fact-tier rule
+made structural rather than remembered, and it is pinned by a test.
+
+**Migration is a two-hop chain**, both hops still running on load:
+`enforcement` → `warnOnViolation` → `alertFloor`. `warnOnViolation: true` →
+`'adjacent'`; `false` → `'excluded_only'`. Note `false` does **not** map to a "none"
+— D1 already fired regardless of `warnOnViolation`, so the boolean was always this
+floor under another name and the mapping preserves behaviour exactly.
+
+`'all'` is the pre-G-B3 IP path's *accidental* behaviour made into a deliberate,
+named choice. Two things follow from letting D3 emit at all:
+
+- **It needs its own `reason`.** `unrelated` joins the closed vocabulary, at **fact**
+ tier: unlike D2 there is no proximity heuristic involved, just an observed
+ non-match against a stated list.
+- **It needs its own severity step.** `notice` (grey) was added to the scale for it.
+ Giving D3 `warn` would put the noise G-B3 removed back *inside* the violation list;
+ giving it `ok` would render a recorded departure green. On the record, not shouting.
+
+The D3 **count** (`getUnrelatedCount()`) is kept at every floor regardless — "silent"
+must stay distinguishable from "not looking", and the adherence report (G-D1) needs
+the denominator.
+
+## C.4 The one tuning parameter (not a toggle)
+
+```
+scope.proximityBits: number // default 24 — container width for single-IP entries
+```
+
+**There is deliberately no domain-side counterpart.** The domain container is the
+registrable domain — there is no coherent "expand N labels" knob, and offering one
+would invite `*.co.uk`-shaped scopes. The asymmetry is intentional; record it so
+nobody adds `proximityLabels` for symmetry's sake.
+
+`scope.publicSuffixes` is **not** that counterpart. It does not tune how far the
+container reaches; it corrects *where the boundary is* for a suffix the built-in
+table does not know. Width knob vs. correctness input — different kinds.
+
+**Net: one enum replaces one boolean, plus one integer. No new switches.**
+
+---
+
+# Part D — Prevention is a non-goal
+
+## D.1 The boundary
+
+> **RedLog records and warns. It does not prevent, gate, or block.**
+
+This is not a limitation to be worked around later — it is the boundary that
+defines the subsystem. Everything in Parts A–C is *the whole intervention*, and
+Part D exists to say so explicitly, because "surely it should also stop me" is the
+first thing everyone asks.
+
+`enforcement: block` was **removed** — `config.ts` migrates only `'warn' | 'log'`.
+Three reasons, stronger than citing the law:
+
+1. **The seam is fire-and-forget by construction.** `shell/redlog-hook.zsh`
+ `_redlog_preexec` does fire *before* execution — the seam exists — but it posts
+ with `curl -sf ... &!`: backgrounded, response discarded, 1s connect timeout. To
+ gate, the shell would have to *wait on a localhost round-trip before every
+ command*, and a wedged RedLog would wedge the operator's terminal mid-engagement.
+2. **Coverage is heuristic, and a 70%-coverage gate is worse than none.**
+ `extractTargetWithProvenance()` cannot see targets inside pipelines, loops,
+ scripts, or custom tooling. A gate the operator *trusts* but that silently passes
+ a third of traffic converts a caught mistake into an uncaught one.
+3. **A blocked action leaves the weaker record.** RedLog's deliverable is "provably
+ did not exceed scope," not "was unable to exceed scope." The former survives a
+ bypassed path; the latter is refuted by one `curl`.
+
+## D.2 The consequence: the alert-quality bar goes *up*, not down
+
+Declining to block does not lower what the alerting subsystem owes — it raises it.
+With no second line of defence:
+
+- **A false green is unrecoverable.** Nothing downstream catches what the badge got
+ wrong. This is why G-A1 (the A-9 false green) is the highest-severity item in the
+ whole subsystem, not a cosmetic issue.
+- **Noise is a safety defect, not an annoyance.** A muted channel is a removed
+ defence. G-B3 (D3 alerting as loudly as D2) is therefore a correctness bug.
+- **Latency is the only "prevention" available.** A D2 warning raised at
+ `command_start` reaches the operator within seconds — the drift-to-discovery
+ window *is* the whole mitigation. Shortening it is legitimate work; adding a gate
+ is not.
+
+## D.3 What RedLog *does* contribute to staying in scope
+
+Entirely within record-and-warn:
+
+| | Contribution | Mechanism | Status |
+|---|---|---|---|
+| **before** | the scope is correct and legible, sourced from the authorisation document rather than typed — **and attributable to it** | `scope.scopeFile` + `readScopeFile` provenance | ✅ shipped |
+| **during** | a fast, correctly-tiered warning the operator can act on (Parts A–C) | `IPMonitor` / `ScopeMonitor` | 🟡 gaps open |
+| **after** | **positive adherence proof**, not just a violation list | `scope-adherence.ts` + export | ✅ shipped |
+
+The *after* column used to be the underbuilt one. `data:exportViolations` proves
+violations happened — the **accusation** half — and a client reading it cannot tell
+three near-misses out of 250 targets from three out of five. `scope-adherence.ts`
+builds the other half: **every target touched, its distance classification, and the
+denominator** — "247 targets, 244 in scope, 0 excluded, 3 adjacent", with per-target
+first/last seen, action counts and command samples.
+
+Three things make it defensible rather than merely reassuring:
+
+1. **It re-classifies from the event stream**, not from the alert log, so D0 targets
+ — the ones that never fired anything — are counted. Those are the proof.
+2. **Recorded violations travel alongside**, exactly as they were chained at the
+ time, under whatever scope was in force then.
+3. **Re-classification uses the *current* scope, and the report says so.** Any
+ `config_changed` that touched a `scope.*` key is listed, and any target whose
+ live classification disagrees with what was recorded is called out. A scope
+ edited mid-engagement is a caveat stated out loud, not a silent error.
+
+**Scope provenance (G-D2).** The report states "judged against this scope", and
+`readScopeFile` now makes that scope attributable: a **sha256 of the file bytes**,
+the entry count, its mtime, and when RedLog read it. A chained `scope_loaded` event
+records the same at the two *authoritative* load points — project open and config
+save — deduped on digest so reopening a project does not fill the timeline with
+identical rows. The read-only re-reads that build an export deliberately do **not**
+emit: an export must not manufacture history.
+
+The digest is the join between the report and the authorisation document a reviewer
+was handed. Without it, "the scope was `/engagements/acme/scope.txt`" is a claim
+taken on trust.
+
+It also closes a silent failure: a scope file that parses to **zero entries**
+contributes no targets, and "scope active" read exactly like a correctly loaded one.
+The entry count makes that visible, and the panel says so.
+
+**In the signed bundle too.** Alongside the standalone export
+(`data:exportAdherence`) and the live summary in Scope & Evidence, the report ships
+as `scope-adherence.json` inside the evidence bundle — a hashed entry in
+`manifest.files`, so it inherits the manifest sha256 and the HMAC and travels signed
+with the rest of the evidence. A loose JSON file claiming "244 of 247 in scope"
+proves nothing; the same file under the bundle's integrity does. The manifest also
+carries the headline (`scopeAdherence`), so a reviewer reading only `manifest.json`
+sees the claim, and the bundle README tells them how to check the scope digest
+against the document they issued.
+
+**The invariant that keeps it honest:** the report is built from the rows **as
+written to the bundle**, after the layer-4 sanitize swap — never from the raw DB. A
+`client-deliverable` bundle sanitizes out-of-scope bodies, and a report built from
+raw rows would have been a side channel straight around that gate. A property test
+asserts every command sample in the report also appears in `events.jsonl`, so the
+guarantee survives future changes to the sanitize rules.
+
+Both profiles get it: an internal bundle that cannot state what it stayed inside of
+is missing the same proof. No scope configured means **no file and a null claim** —
+an empty report would read as "nothing was out of bounds".
+
+## D.4 Explicit non-goals (considered, rejected — do not re-propose)
+
+| Non-goal | Why not |
+|---|---|
+| Blocking / gating command execution | D.1 — all three reasons |
+| Confirmation prompts before a D1 target, even in an opt-in wrapper | Friction is prevention wearing a costume; it also trains reflex-confirmation, which degrades D1's meaning |
+| Exporting scope into tool-enforceable formats (nmap `--excludefile`, Burp scope, deny-lists) | Legitimate work, but it is *prevention tooling*, not record-and-warn. Out of RedLog's remit. |
+| Proxy/DNS-level interception of out-of-scope traffic | Same, plus it makes RedLog a network component with an availability contract it must not have |
+
+## D.5 Additional gaps
+
+| # | Gap | Kind | Notes |
+|---|---|---|---|
+| **G-C3** | ~~`scope.alertFloor` enum replacing `warnOnViolation`, + `proximityBits`~~ | ✅ **fixed** | Three-value floor + two-hop migration + Settings selector. `'all'` needed a `unrelated` reason (fact tier) and a `notice` severity step. `proximityBits` landed with G-B1. |
+| **G-D1** | ~~Scope-adherence report (positive proof, not just violations)~~ | ✅ **fixed** | `scope-adherence.ts` + loose export + live summary + `scope-adherence.json` in the signed bundle with a `scopeAdherence` manifest headline. Built from post-sanitize rows, so it cannot bypass the client-deliverable gate. |
+| **G-D2** | ~~Scope provenance — which file, loaded when, by whom, with what diff~~ | ✅ **fixed** | `readScopeFile` returns a sha256 + entry count + mtime; a chained `scope_loaded` event records it at the two authoritative load points; the adherence report embeds it. Also catches a scope file that parses to zero. |
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 06c01113..3fb6d760 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -1,8 +1,8 @@
# Architecture
Current as of **v0.9.3**. This page is the map the README's ASCII diagram
-stopped being (that diagram predates the tailer host, plugin host,
-marketplace and cloud share). Read `event-schema.md` for what lands on the
+stopped being (that diagram predates the tailer host and plugin host).
+Read `event-schema.md` for what lands on the
timeline and `audit-trail.md` for why it can't be quietly edited.
## 1. Process / layer model
@@ -26,9 +26,9 @@ timeline and `audit-trail.md` for why it can't be quietly edited.
┌──────────────┴─ core (src/core/) — zero Electron imports, unit-testable ──────┐
│ db/{index,events,operators,findings} event-bus clock signing │
│ chain-anchor evidence-chain bundle-export sanitize redaction │
-│ api-server (REST + HTTP MCP) mcp-tools capture-health deconfliction │
+│ api-server (REST) capture-health │
│ loot- / pivot- / technique- / command- / target- detectors scope-monitor │
-│ ip-monitor retention cloud-share plugins/{loader,manifest,trust,host,…} │
+│ ip-monitor retention cast-index plugins/{loader,manifest,trust,host,…} │
└───────────────────────────────────────────────────────────────────────────────┘
▲ HTTP 127.0.0.1:6660 (Bearer) ▲ file watch
hooks/ · shell/ · cli/ · mcp/ · ~/.claude · ~/.codex · opencode storage
@@ -61,7 +61,7 @@ untested — see `AUDIT-2026-08-08.md` §4).
loadConfig → initDB(projectDir) timeline.db, WAL, triggers
→ configure ip / screenshot / scope / loot / redaction
→ setPluginHost → setTailerContributionSink → initPlugins()
-→ configureDeconfliction · setVpnAdapters · configureTerminal
+→ setVpnAdapters · configureTerminal
→ sweepRetention() expire .cast / screenshots, emit *_pruned events
→ recoverOrphanSessions() LEFT JOIN to close terminals killed by a crash
→ replay ~/.redlog/pending/* shell-hook offline spool
@@ -74,9 +74,8 @@ loadConfig → initDB(projectDir) timeline.db, WAL, triggers
→ create overlay window
```
-Shutdown (`stopProject()` / `will-quit`) flushes the deconfliction batch,
-kills terminals **before** closing the DB so `session_end` still writes, then
-unwinds every monitor and calls `closeDB()`.
+Shutdown (`stopProject()` / `will-quit`) kills terminals **before** closing the
+DB so `session_end` still writes, then unwinds every monitor and calls `closeDB()`.
## 3. Data model
@@ -158,8 +157,7 @@ insertEvent (src/core/db/events.ts:230)
· clock-anomaly detection folded into data
· canonicalStringify (recursive key sort) → SHA-256 → hash → Ed25519 sig
main/index.ts:1063
- · send 'events:new' per event (HUD + deconfliction)
- · notifyDeconfliction() 500 ms / 100-event batches, HMAC-SHA256
+ · send 'events:new' per event (HUD)
· batchBuffer + setImmediate → 'events:new-batch' (one per frame)
· recompute getActivePivots() on pivot / command_end → 'pivots:changed'
```
@@ -268,11 +266,6 @@ signing keys. Trust is pinned to a content hash covering the manifest plus
every privileged code file; changing either the code or the requested
capabilities revokes it automatically.
-Marketplace install: revocation check → HTTPS fetch (5 MB cap, one redirect,
-20 s timeout) → sha256 match → Ed25519 verify against a pinned publisher key
-→ tar-escape pre-check → extract to scratch → manifest re-validation → tier
-post-check → atomic rename with the previous version snapshotted.
-
`tailers` is the exception and currently does **not** follow this path — see
`AUDIT-2026-08-08.md` §2 (P1-3).
diff --git a/docs/CAPTURE-SOURCE-TAXONOMY.md b/docs/CAPTURE-SOURCE-TAXONOMY.md
new file mode 100644
index 00000000..610182b7
--- /dev/null
+++ b/docs/CAPTURE-SOURCE-TAXONOMY.md
@@ -0,0 +1,193 @@
+# Capture Source Taxonomy — built-in vs plugin, AI-era
+
+Written 2026-08-13. Companion to `SPEC-SCOPE-AWARE-LIFECYCLE.md`. Answers three
+questions before the lifecycle work: (1) **what capture items must ship built-in**,
+(2) **what the AI era adds to the pentest/red-team tool surface** and whether
+RedLog covers it, (3) **where the built-in/plugin line sits** and why the plugin
+interface is general enough for the long tail. Everything here is constrained by
+one fact: **RedLog runs on the operator's own laptop**, so every capture item is
+judged on CPU, disk, IO, and install/config friction, not just coverage.
+
+## The organizing lens (DESIGN-PRINCIPLES §2)
+
+Capture items split by **who initiates the bytes**, which decides default-on vs
+opt-in and the exposure profile:
+
+- **Operator-deliberate** (default-on): commands, tool-calls, navigation, targets
+ — the operator's own actions. Bounded exposure (fires per-action).
+- **Ambient vacuum** (opt-in): clipboard, periodic screenshots — run continuously,
+ carry a standing exposure window, so the operator consciously accepts the bet.
+
+The key design move for the AI era: capture the **substrate** (command / packet /
+tool-call / screen), not the specific tool. A substrate-level item covers a whole
+class of tools; only a tool with its own out-of-band channel needs a dedicated
+plugin (see Part 4).
+
+## Part 1 — Built-in capture inventory (17 `agent_type`s)
+
+| # | agent_type | What it captures | Heavy artifact | Local cost | Install / config |
+|---|---|---|---|---|---|
+| 1 | `shell`/`terminal` | commands (hook or built-in PTY) + output | **`.cast` recording** | disk hog; PTY cheap | shell-source into `.zshrc`, or built-in terminal |
+| 2 | `http` | mitmproxy request/response | **io body (sidecar)** | TLS intercept per-flow; dedup+2MB cap | mitmproxy addon + **CA cert trust** (per-OS) |
+| 3 | `http_navigation` | page loads in CDP browser | io body | CDP session | launch proxied browser |
+| 4 | `browser` | proxied browser capture/nav | io body | as above | as above |
+| 5 | `scanner` | mitmproxy / port / vuln scan output (open type) | io body | varies by tool | via API / addon |
+| 6 | `dns` | DNS queries | — | light | resolver hook / passive |
+| 7 | `agent` | **AI agent tool-calls** (Claude Code `PostToolUse`, etc.) | transcript | tailer light | `claude-settings` hook (Tier A) |
+| 8 | `process` | process spawn/exit (ps polling) | — | **polling CPU** | macOS/Linux ps monitor, opt-in |
+| 9 | `file_transfer` | ingress/exfil, auto-detected from shell | — | free (rides shell) | none (auto) |
+| 10 | `pivot` | tunnels/SOCKS, auto-detected from shell | — | free | none (auto) |
+| 11 | `cleanup` | anti-forensics, auto-detected from shell | — | free | none (auto) |
+| 12 | `loot` | detected secrets (derived) | — | regex per-event | none (built-in detector) |
+| 13 | `marker` | operator finding notes | — | free | operator UI |
+| 14 | `screenshot` | desktop capture (periodic + on-demand) | **`.jpg` files** | **capture+encode CPU, disk hog** | opt-in periodic; on-demand free |
+| 15 | `clipboard` | clipboard changes (sampled) | — | **poll interval CPU** | **default OFF** (highly sensitive) |
+| 16 | `system` | scope_violation / sanitized / *_pruned / pause | — | free (meta) | n/a |
+| 17 | *(watchPaths)* `file_transfer` via fs watch | filesystem drops in watched dirs | — | fs-watch cost | needs `watchPaths` list |
+
+**Substrate coverage:** items 1–8 + 14–15 are tool-agnostic — they record the
+*medium* (shell, HTTP, DNS, tool-call, screen, clipboard, process) regardless of
+which specific tool produced it. Items 9–12 are auto-detections layered on the
+shell substrate.
+
+## Part 2 — AI-era tool surface → capture mapping
+
+The 2026 tool stack (per web research) mapped to what it *emits* and which
+built-in item captures it. "Gap → plugin" marks the long tail.
+
+| Phase | Common tools (2026) | Emits | Built-in covers | Gap → plugin |
+|---|---|---|---|---|
+| Recon | Nmap, Shodan, theHarvester, subfinder/httpx/katana, Maltego | shell cmd + output; DNS/HTTP | `shell`+`dns`+`http`, target extract | structured **nmap XML / nuclei JSON** parsing |
+| Vuln scan | Nessus, OpenVAS, Nikto, nuclei | shell/scanner output | `shell`/`scanner` | structured findings ingest |
+| Web/exploit | Burp Suite Pro, SQLMap, Metasploit | HTTP; interactive consoles | `http` (via proxy), `shell` for SQLMap | **Burp proxy overlap, msfconsole RPC** |
+| C2 | Cobalt Strike, Sliver, Mythic | beacon check-ins, own logs | — (out-of-band channel) | **C2 log ingest** (Sliver log / CS Aggressor) |
+| Post-ex | BloodHound, Mimikatz, LinPEAS/WinPEAS, Empire | shell cmd; graph data | `shell`+`loot` | **BloodHound graph ingest** |
+| **AI-agent** | **PentestGPT, Nebula, hackingBuddyGPT, CAI** | tool-calls; some shell-out | `agent` (native hook) / `shell` (fallback) | tool-specific **native hooks** for richer attribution |
+| **AI-MCP** | **HexStrike, PentestMCP** (MCP servers) | MCP tool-calls | `agent` tool-call capture | **🔴 `mcpTools`** for RedLog-as-tool |
+| **AI-target** | **DeepTeam, LLM/Agentic red teaming** (OWASP LLM/Agentic Top 10, MITRE ATLAS) | HTTP API calls to target LLM; injection payloads | `http` + `agent` | **semantic labels** (prompt-injection / tool-call-hijack) as `eventType`/loot-pattern |
+
+### The AI-era shift (the load-bearing finding)
+
+The capture surface moved from **"commands + packets"** to also include **agent
+tool-calls, MCP server interactions, and LLM API exchanges**. RedLog is
+architecturally ready — it already has `agent` capture, the agent-hook framework
+(Tier A native / Tier C shell fallback, `docs/plugin-development.md`), and 🔴
+`mcpTools`. What the *common built-in* set must explicitly own:
+
+1. **AI-agent tool-call capture** — have it (`agent` + `PostToolUse` hook).
+2. **MCP tool-call visibility** — the operator's AI running MCP security tools
+ (HexStrike, PentestMCP) is now a primary evidence stream; its tool-calls should
+ land like any other action. Partly via `mcpTools`, but capturing *another*
+ MCP server's calls is plugin territory.
+3. **LLM-target exchanges** — testing an AI *target* is captured as `http`
+ (API calls) + `agent` (attacker-LLM transcript); the injection-attempt
+ *labelling* is a `eventType`/loot-pattern contribution, not a new source.
+
+Everything tool-specific (Burp, msfconsole, Sliver/CS, BloodHound, per-tool AI
+hooks, structured scan parsers) is correctly **long tail → plugin**.
+
+## Part 3 — Local-execution constraints (perf / space / install)
+
+Grouped by the real bottleneck on a laptop:
+
+- **Disk hogs (space):** `.cast` (terminal), `.jpg` (periodic screenshots), io
+ bodies. → governed by `SPEC-SCOPE-AWARE-LIFECYCLE.md` (compress→tier→prune,
+ size+age triggers). Screenshots/`.cast` are Tier-3 (size/time only), the io
+ bodies are Tier-1 (scope-prioritized). Dedup + 2 MB ceiling already cap bodies.
+- **CPU (perf):** process ps-polling, clipboard polling, mitmproxy TLS intercept,
+ screenshot encode. → interval-tunable + opt-in per §2; the two continuous
+ vacuums (clipboard, periodic screenshot) are opt-in precisely for this.
+- **Install friction (setup):** the hard ones are **mitmproxy CA cert trust**
+ (per-OS, per-browser), **shell hook into `.zshrc`**, **`claude-settings` hook**,
+ **CDP browser launch**. Everything else is passive/auto. → `installMethod`
+ (`shell-source` / `claude-settings` / `manual`) + `verify` command must make
+ each self-describing (see Part 4).
+
+## Part 4 — The built-in / plugin boundary + interface generality
+
+**Ships built-in (common, substrate-level, tool-agnostic):** items 1–15 above +
+auto-detections. Rule: *if it captures a medium (shell/HTTP/DNS/tool-call/screen/
+clipboard/process) rather than parsing one tool's proprietary output, it's
+built-in.*
+
+**Plugin (long tail, tool-specific or personal):** parsing a specific tool's
+output/console/DB, or a bespoke in-house tool. Grounded in the existing
+🟢/🔴 trust model (`redlog-plugin-system`):
+
+| Need | Contribution | Tier | Runs code? |
+|---|---|---|---|
+| Recognize a new tool's targets | `targetExtractors` | 🟢 | no |
+| Stamp fields on its shell events | `commandTags` | 🟢 | no |
+| Give its events a timeline identity | `eventTypes` | 🟢 | no |
+| Teach loot detector its secret format | `lootPatterns` | 🟢 | no |
+| Add allow/deny redaction | `redaction` | 🟢 | no |
+| Capture an out-of-band channel (C2 log, Burp, BloodHound) | `capture` script → local API | 🟢 | no (out-of-process, unprivileged) |
+| Let an AI agent operate RedLog | `mcpTools` | 🔴 | yes (isolated utilityProcess, capability-scoped `ctx`, hash-pinned consent) |
+
+**Why the interface is general enough for the long tail:** because built-in
+capture is *substrate-level*, a new tool usually needs only a 🟢 `targetExtractor`
++ `eventType` — a few lines of **data**, no code, loaded automatically, unable to
+touch the evidence chain. A full `capture` script (still 🟢, out-of-process,
+POSTs to the authenticated local API) is needed only when the tool has its own
+channel RedLog isn't already sitting on (a C2 beacon, Burp's proxy, BloodHound's
+graph DB). Only code that runs *inside* RedLog for an AI agent is 🔴 — isolated,
+capability-scoped, human-approved. So: **common = built-in substrate; rare/personal
+= 🟢 data or out-of-process script; agent-operable = 🔴 isolated.** The trust cost
+scales with the actual risk, and the long tail lands almost entirely in trust-free
+🟢.
+
+**Plugin install generality:** every `capture` contribution declares its own
+`installMethod` (`shell-source` / `claude-settings` / `manual`), `manualSteps`
+(with the `{hookFile}` placeholder RedLog resolves to an absolute path), and a
+`verify` command — so the long tail is self-describing about setup even without a
+one-click path, matching the local-install reality of Part 3.
+
+### The install/enable separation (four gates, not two)
+
+The lifecycle has **four separate, differently-consequential, independently
+reversible** states — the design deliberately splits *enablement inside RedLog*
+from *installing a hook into the operator's environment*:
+
+| Gate | State | Where it lives | Blast radius | Reversibility |
+|---|---|---|---|---|
+| 1 | **Present** | folder in `~/.redlog/plugins//` or bundled | files only | delete folder |
+| 2 | **Enabled** | `state.json` `disabled[]`; Settings ▸ Enable/Disable | in-RedLog process; contributions merged | flip a flag, zero external footprint |
+| 3 | **Trusted** (🔴 only) | `trust.json` content-hash + capability pin | privileged code may run | revoke consent |
+| 4 | **Capture hook installed** | `.zshrc` / `~/.claude/settings.json` via `installMethod`; tracked by `capture-health` `hookInstalled` | **mutates files OUTSIDE RedLog that persist when it's closed** | separate uninstall |
+
+**Why gates 2 and 4 must be separate:** enabling merges data into RedLog's own
+process — cheap, reversible, no footprint outside the app. Installing a capture
+hook mutates *external* system state (`.zshrc`, `~/.claude/settings.json`) that
+outlives RedLog's process, so it needs its own consent and its own uninstall.
+Conflating them forces a bad choice: either enabling silently edits your shell
+config (unrequested footprint), or disabling can't clean up because it never
+tracked what it wrote. The split lets the operator vet and enable a plugin safely
+in-process **first**, then take the consequential environment-wiring step
+deliberately.
+
+**Gotcha to preserve — Disable ≠ Uninstall.** `setPluginEnabled(false)`
+(`plugins/index.ts`) only flips `state.json`; it does **not** remove the hook
+line from `.zshrc`. Hook uninstall must be its own UI action, or orphaned hook
+lines accumulate in the operator's shell config (and keep firing on every shell,
+whether or not RedLog is open). Gate 4 (hook install) *is* the "install friction"
+of Part 3 (mitmproxy cert, `.zshrc`, `claude-settings`, CDP browser) — isolating
+it as one deliberate, separately-reversible step is what makes the local-install
+reality manageable.
+
+## Recommendation
+
+- **Ship built-in:** the 15 substrate items + auto-detections. Add nothing
+ tool-specific to core.
+- **AI-era, promote to first-class built-in emphasis:** `agent` tool-call capture
+ and MCP tool-call visibility — this is where 2026 evidence increasingly lives.
+- **Everything else → plugin**, and the 🟢-first interface already makes the long
+ tail cheap (data/script, trust-free) with 🔴 reserved for agent-operable code.
+- **Local cost is handled by the lifecycle spec**, not by dropping capture: disk
+ hogs (`.cast`/screenshots/bodies) rotate; continuous vacuums stay opt-in.
+
+## Sources
+
+- Pentest stack 2026 — [PlexTrac](https://plextrac.com/the-most-popular-penetration-testing-tools-this-year/), [Axis Intelligence](https://axis-intelligence.com/best-penetration-testing-tools-tested-guide/), [HackerDNA](https://hackerdna.com/blog/penetration-testing-tools)
+- AI red-team frameworks — [Synack](https://www.synack.com/blog/best-ai-red-teaming-tools/), [Straiker](https://www.straiker.ai/blog/top-6-ai-red-teaming-and-adversarial-testing-tools)
+- AI pentest agents — [Ostorlab](https://blog.ostorlab.co/8-open-source-ai-pentest-tools-2026.html), [awesome-ai-pentesting](https://github.com/skyvanguard/awesome-ai-pentesting), [appsecsanta](https://appsecsanta.com/research/ai-pentesting-agents-2026)
+- Operator logging / evidence — [Red Team Guide — Operator Log](https://redteam.guide/docs/Templates/oplog/), [Hacking Articles](https://www.hackingarticles.in/guide-to-red-team-operations/)
diff --git a/docs/CLOUD_SHARE_BUNDLE.md b/docs/CLOUD_SHARE_BUNDLE.md
deleted file mode 100644
index fd78c21a..00000000
--- a/docs/CLOUD_SHARE_BUNDLE.md
+++ /dev/null
@@ -1,185 +0,0 @@
-# Cloud Share Bundle — design spec (draft)
-
-Status: **shipped** since v0.6.69. Items still marked *(deferred)* are unbuilt. The threat model in §11 was corrected in v0.11.0 — one of its claims described a server-side check that does not exist.
-
-## 1. Goal
-
-After finishing an engagement, the operator wants to hand the sanitized evidence bundle to a client or an internal manager. Today they click **Export bundle** ([`src/core/bundle-export.ts`](../src/core/bundle-export.ts)), get a directory of `events.jsonl + screenshots/ + casts/ + manifest.json`, zip it, and email or Slack-drop it. That flow breaks in five obvious ways: bundles routinely exceed the 25 MB attachment ceiling most mail providers enforce; corporate mail gateways quarantine `.zip` attachments; the client's phone can't unzip an evidence pack in an airport; the sender has no way to *revoke* the file once it's out; and the sender has no signal that the recipient actually opened it. A short share URL fixes all five: no attachment (no quarantine), no size ceiling other than what our backend allows, mobile-viewable in a browser, one-click revoke, and a single "opened" ping so the operator knows the deliverable landed.
-
-## 2. Non-goals (v1)
-
-- **No viewer analytics beyond "opened at T, from IP prefix P".** Not tracking dwell time, per-file access, geolocation, or heatmaps. Anything richer feels like surveilling the client.
-- **No in-browser interactive replay.** `.cast` playback and chain-verify UI live in v2. v1 is *download the zip*.
-- **No multi-tenant collaboration.** No comments, no per-viewer permissions, no shared workspace. The share URL is a one-way delivery, not a workspace.
-- **No re-encryption at rest by the backend.** The bundle is treated as opaque bytes. Optional client-side encryption is a v2 idea (Open Question 3).
-- **No CI/webhook triggers.** Operators click a button in the app. Programmatic uploads via CLI are v2.
-
-## 3. Backend hosting
-
-**Recommendation: option D — a RedLog-hosted default on top of Cloudflare R2 + Workers, with a "bring your own bucket" escape hatch that points at any S3-compatible endpoint (R2, S3, MinIO).**
-
-Comparison:
-
-| Option | Pros | Cons |
-|---|---|---|
-| A. R2 + Workers | Zero egress cost (bundles are download-once-ish), signed URLs and edge auth trivial, one vendor | R2 durability/availability track record shorter than S3 |
-| B. S3 + CloudFront + Lambda | Most mature, operators trust AWS | Egress cost real, IAM/CloudFront/Lambda sprawl for what is a small service |
-| C. Self-hosted MinIO only | Data never leaves the operator's org | Every team has to run infra before they can use the button; kills the "click Share, get URL" UX |
-| D. RedLog-hosted default (R2+Workers) + BYO-bucket config | Ships as one click for new users; teams that can't legally use us point at their own MinIO/R2/S3 | RedLog project becomes a data custodian for the default tier |
-
-**Legal implication of running the default tier.** Hosting sanitized evidence for other people's engagements makes the RedLog project a data processor for that content. That means: a published DPA, a documented retention policy (see §11), a jurisdiction statement (R2 buckets pinned to EU or US at signup), and a delete-my-data endpoint. Teams that can't legally hand evidence to a third party — most consultancies with client NDAs, and anyone doing gov work — MUST be able to flip a single settings toggle to point uploads at their own bucket, at which point the RedLog project sees zero bundle bytes and stores only the share-URL registration (or nothing, if they self-host the KV too). BYO-bucket is not an afterthought; it's how we make the default tier acceptable to ship at all.
-
-## 4. Bundle format on the wire
-
-**Same `.zip` as local export, plus a top-level `bundle.json` manifest.** No new streaming format. Rationale: the existing bundle already has a `manifest.json` with per-file SHA-256 and a chain-head anchor; wrapping it in a zip is the smallest possible delta and keeps the downloaded artifact byte-identical to what `redlog-cli export bundle` produces locally.
-
-`bundle.json` (new, wraps the existing manifest for the backend's benefit — the operator can read it without unzipping):
-
-```json
-{
- "schema": "redlog.share/1",
- "engagementId": "…",
- "engagementSlug": "acme-external-2026q3",
- "createdAt": "2026-08-01T14:22:00Z",
- "bytes": 87342112,
- "sha256": "…",
- "eventCount": 4812,
- "chainHead": { "hash": "…", "eventCount": 4812 },
- "lastAnchor": { "id": "…", "status": "confirmed", "createdAt": 1754060400 },
- "sanitized": { "events": 47, "totalInDb": 47 }
-}
-```
-
-**Bounded upload size: 100 MB default, override in Settings ▸ 分享 up to a hard cap of 2 GB.** The chain-head hash and sanitized counts travel in `bundle.json` so a reviewer can compare them against what the operator saw in the pre-upload dialog. This is a record, not an enforcement point — the Worker does not read `bundle.json` (see §11 threat 3).
-
-## 5. Upload flow
-
-Client:
-
-1. Operator clicks **Share to cloud** in the engagement toolbar.
-2. App runs the existing sanitize pass — same code path as `redlog-cli export bundle`, layer 4 of the redaction design.
-3. App renders the **redaction preview** dialog (§9). Upload button is disabled until the operator ticks *I have reviewed*.
-4. App zips the bundle to a temp dir, computes SHA-256.
-5. App calls `POST /api/share/init` with the manifest summary.
-6. App `PUT`s the zip bytes to the returned signed URL.
-7. App calls `POST /api/share/complete` with the object key and observed SHA-256.
-8. App gets back `{ shareUrl, expiresAt }`, shows it, offers *Copy link / Open / Revoke*.
-
-API sketch:
-
-```
-POST /api/share/init
- auth: Bearer
- body: { engagementSlug, bytes, sha256, eventCount, chainHead, expiryDays }
- 200: { putUrl, objectKey, shareId, headers: {…} } // signed PUT, ~15 min TTL
-
-PUT {putUrl}
- body:
- headers include an x-amz-checksum-sha256 the backend re-verifies
-
-POST /api/share/complete
- body: { shareId, sha256 }
- 200: { shareUrl, expiresAt }
-
-GET /e/{engagementSlug}-{suffix} // download page (HTML)
-GET /e/{engagementSlug}-{suffix}.zip // signed download of the object
-POST /api/share/{shareId}/revoke // uploader only
-GET /api/share/mine // list operator's active shares
-```
-
-## 6. Share URL structure
-
-`https://share.redlog.dev/e/{engagement-slug}-{suffix}`
-
-- `engagement-slug` — human-readable, e.g. `acme-external-2026q3`. Comes from the engagement record; operator can override at share time. Not treated as a secret.
-- `suffix` — 8 characters of base32-Crockford, drawn from a CSPRNG. 8 chars × 5 bits = **40 bits of entropy**, matches the spec floor. This is the secret — URL is capability, no other auth by default.
-
-Why not plain UUIDs: unreadable, and when a client forwards the URL in a chat channel it looks like malware bait. Why not just the suffix: makes the URL more anonymous but destroys the operator's ability to eyeball "which engagement is this" in their shares list.
-
-Guessability check: 40 bits with a per-slug rate limit (say 5 requests/minute/IP + 100/day/IP for `404`s) makes online enumeration infeasible. If we ever host high-volume, bump the suffix to 12 chars for 60 bits.
-
-## 7. Expiry & revocation
-
-- **Default: 30 days.** Picker at share time: 24h / 7d / 30d / 90d / never.
-- **Never** is *(deferred)* — probably keep it out of v1; forcing an explicit re-share every 90 days is a feature.
-- **My shared bundles** view (Settings ▸ 分享) lists active shares with `slug · created · expires · viewer count · [Revoke]`.
-- Revoke = mark row `revoked=true`, hard-delete the object from R2 immediately, invalidate any cached signed download URL.
-- On expiry: a scheduled Worker cron sweeps `expiresAt < now()`, hard-deletes the object, keeps the row for 30 more days flagged `expired` so the operator's list can show what was there.
-
-## 8. Viewer experience
-
-**v1: a single "Download bundle" page.** Static HTML rendered by the Worker showing: engagement slug, uploader operator name, created-at, expires-at, size, event count, chain-head hash, "Download bundle (87 MB)" button, "Verify chain locally with `redlog verify bundle.zip`" tip. No JavaScript-heavy replay UI.
-
-v2: unzip in-browser, render sessions list, embed the chain-verify WASM, play `.cast` inline via asciinema-player.
-
-v1 is the recommendation because the shape of the client-friendly viewer is the largest open UX question in this whole feature; shipping the download page first gets the primary use case (sending the pack) into operators' hands without committing to a design we'd rewrite.
-
-## 9. Redaction gate (HARD requirement)
-
-Upload button is **disabled** until the operator has seen a preview of what will leave their machine. The dialog contents:
-
-```
-This bundle will upload:
- • 4,812 events (47 sanitized before upload)
- • 128 screenshots (∑ 62 MB)
- • 6 asciinema .cast recordings (∑ 24 MB)
- • 12 quickmark loot entries
- • Chain head: 9c4a…d0e1 (last anchored 2026-07-31 09:00 UTC)
-
-[ Show masked preview of the first 200 events ▸ ]
-
-☐ I have reviewed what leaves this machine.
- [ Cancel ] [ Upload → ]
-```
-
-The masked preview reuses the Timeline's redaction view (`text + redactions` spans) so the operator sees exactly what a viewer would see. The checkbox state is not remembered across sessions. This gate cannot be disabled by settings, cannot be skipped by CLI, and cannot be bypassed by a plugin.
-
-## 10. Auth model
-
-**Uploader** — device-bound short-lived tokens.
-
-1. First-time share: operator enters an email, receives a magic link, clicks it in a browser → backend mints a **device token** bound to `{operatorEmail, deviceFingerprint}` (fingerprint = SHA-256 of Electron machine id + install nonce). Token lifetime 90 days, sliding.
-2. Every `/api/share/*` call carries `Authorization: Bearer `.
-3. Rotating out a device = revoke from the web console.
-
-**Viewer** — three modes, per-share:
-
-- **Public link** (default): URL is the secret, no login. Best for one-shot handoffs to a client.
-- **Email gate**: viewer enters an email address, backend mails a short-lived signed URL to it, that URL is what actually downloads. Prevents leaked-URL-in-Slack from being usable by an outsider.
-- **Password**: uploader sets a passphrase, sent to the recipient out of band. Simple HMAC gate at the Worker.
-
-## 11. Privacy / legal
-
-**Backend logs, on the default hosted tier**:
-
-- Upload: `sha256, bytes, engagementSlug, uploader operator id, upload IP truncated to /24 (v4) or /48 (v6), timestamp`. Kept 180 days for abuse response, then deleted.
-- Downloads: `shareId, download IP truncated, user-agent family, timestamp, bytes served`. Kept 90 days.
-- Bundle bytes: kept until expiry or revocation, whichever is first. Hard-deleted from R2 within 24 h; row retained per §7.
-
-**GDPR delete-my-data**: authenticated endpoint `DELETE /api/account` — removes device tokens, share rows, and any objects still live. Response returns a receipt hash. Turnaround target: 72 h; documented in Terms.
-
-**BYO-bucket tier**: only the share-URL registry row (or nothing, if the team self-hosts the KV too). No bundle bytes ever touch our infra.
-
-## 12. Threat model
-
-1. **Leaked share URL.** Someone forwards the link outside the intended audience. Mitigation: 40-bit suffix + rate limit on 404s, default 30-day expiry, revoke button, optional email/password gate.
-2. **Backend compromise (R2 credentials / Worker secret).** Attacker gains read of all live bundles. Mitigation: bundles are sanitized before upload (§9), sanitize policy pinned per project, plaintext screenshots still leak. Mitigation v2: optional client-side symmetric encryption (Open Question 3).
-3. **Sanitize bypass.** A plugin or a modified build uploads raw bytes labeled as sanitized. Mitigation: the sanitize pass runs in the same code path as `redlog-cli export bundle`, and the review gate cannot be disabled by settings, by the CLI or by a plugin (§9). **The Worker does not check sanitize counts** — it never parses `bundle.json`, so there is no server-side enforcement; the client-side gate is the whole of it. Since v0.11.0 the Worker does verify that uploaded bytes hash to the sha256 they are stored under, which detects corruption or substitution in flight but says nothing about whether the content was sanitized. *(Earlier revisions claimed the Worker rejected count mismatches. It never did.)*
-4. **Malicious viewer / XSS in event body.** Any download-page or v2 inline-replay UI renders operator-controlled strings. Mitigation: viewer serves `Content-Security-Policy: default-src 'none'` for the download page; v2 replay MUST render event bodies as text, never as HTML, and MUST sandbox the asciinema player in a `srcdoc` iframe with `sandbox="allow-scripts"` (no `allow-same-origin`).
-5. **Long-lived shares outliving the engagement.** Operator forgets a `90d` share; three months later the client's laptop is stolen with the URL in email. Mitigation: default 30d, weekly digest email to the uploader listing their active shares, "Revoke all shares older than N days" button.
-6. **Uploader token theft.** Stolen device token uploads bundles under someone's name. Mitigation: 90-day sliding TTL, rotate-on-detection, mag-link rebinds fingerprint.
-7. **Enumeration of shares by slug.** Guessing `/e/acme-external-2026q3-*`. Mitigation: rate-limit as above; suffix entropy makes it infeasible; consider optional slug-hashing for high-sensitivity engagements *(deferred)*.
-
-## 13. Implementation phases
-
-- **v1 MVP** — Share button; sanitize + preview gate; upload to R2 via signed URL; download page (no replay); `My shared bundles`; revoke; 30-day default expiry; magic-link uploader auth; public-link viewer only.
-- **v2** — Inline replay: unzip in browser, sessions list, embedded chain-verify (WASM), asciinema player. Email-gate and password viewer modes. BYO-bucket config.
-- **v3** — Team library: workspace shared across operators, per-share ACL, comment threads, per-viewer identity, audit log for viewer accesses. Programmatic upload via `redlog-cli share`.
-
-## 14. Open questions
-
-1. **Do we ship the default hosted tier at all?** Or launch BYO-bucket-only in v1 and let the RedLog project stay out of the data-custodian business until we have DPAs and a support SLA?
-2. **Slug source of truth.** Engagement records don't currently have a URL-safe slug field. Add one at engagement create, or derive at share time from the engagement name + short hash?
-3. **Client-side encryption.** Should v1 offer age-based symmetric encryption where the recipient's key is sent out of band? Adds a passphrase to the viewer flow but reduces the backend threat surface to ciphertext-only.
-4. **Anchor status on the download page.** Should we surface "Chain-head anchor status: confirmed / pending / unconfirmed" prominently? It's the strongest tampering signal in the bundle and clients don't know to look for it.
-5. **Retention for the *shares registry row* after hard-delete of the bytes.** Proposal is 30 days flagged `expired` so the operator can see history. Is that too long / too short from a privacy stance?
diff --git a/docs/CONTROL-PLANE-FACES.md b/docs/CONTROL-PLANE-FACES.md
new file mode 100644
index 00000000..3445f49d
--- /dev/null
+++ b/docs/CONTROL-PLANE-FACES.md
@@ -0,0 +1,95 @@
+# Control-Plane Faces — one implementation, many thin adapters
+
+Written 2026-08-13. Applies `DECOMPOSITION-METHOD.md` to RedLog's control plane
+(DESIGN-PRINCIPLES §7). This subsystem decomposes **differently** from plugins and
+detectors, and that difference is itself instructive: every face does the *same*
+shape of work (adapt the canonical op set to a transport), so the closed set that
+matters is not "N roles" but **the canonical op catalog + a single Adapter role**.
+
+## The method, adapted
+
+For plugins/detectors the mechanism axis produced *several* roles (different shapes
+of work). Here every face is structurally identical — a thin adapter. So:
+
+- **The closed set = the canonical op catalog** (the evidence-relevant control ops).
+- **The single role = Adapter** (project a subset of ops onto a transport, adding
+ only serialization + auth, never logic).
+- **Faces are then *classified*** along transport × audience × generation — a
+ catalog, not a role zoo.
+
+This is the "one role + canonical catalog" variant of the method (see
+`DECOMPOSITION-METHOD.md`).
+
+## The canonical layer (what everything adapts)
+
+§7 names "the localhost REST handlers" as canonical, but structurally the true
+canonical implementation is **the core op modules** (`insertEvent`, `searchEvents`,
+the chain ops, scope/loot reads). **REST is itself an adapter** over those — the
+first and most privileged one, but not the source of truth. Naming the core (not
+REST) as canonical is what lets MCP, IPC, CLI, and the Codex schema all be *peer*
+adapters that cannot drift from each other, because none is defined in terms of
+another.
+
+## The canonical op catalog (closed, §7 "evidence-relevant only")
+
+Grouped by §7's three purposes:
+
+| Group | Ops | Source |
+|---|---|---|
+| **Write evidence** | `mark`, `quickmark`, `log_event` | `api-server` L125/L136/L169 |
+| **Operate chain / recording** | `chain_status`, `chain_anchor_now`, `chain_verify`, `chain_upgrade`, `recording` (pause/resume) | L199–208, L191 |
+| **Read for decisions** | `status`, `whoami`, `operators_list`, `scope`, `search`, `events`, `config`, `loot_scan`, `screenshot`, `quickmarks_list` | L111–185 |
+
+**Boundary (§7):** the catalog is *evidence-relevant control* only. It **never
+duplicates capture** (hooks own that) and **never grows evidence-irrelevant ops**.
+Delivery ops (`export/bundle`, `sanitize`, `deconfliction`, `anchors`) that appear
+on REST belong to the **Delivery** subsystem (next candidate), not the control
+catalog — see Gaps.
+
+## The Adapter role (the contract every face fills)
+
+- **Declares:** transport, audience, the op-subset it exposes, auth model,
+ serialization.
+- **Contains:** no logic. It marshals a request into a canonical op call and
+ marshals the result back. **A face that makes a decision is a bug** — that is the
+ §7 failure mode, and the review test for any face change.
+- **Test seam:** given a mocked canonical op layer, assert the adapter calls the
+ right op with the right args and serializes the result — no behaviour of its own
+ to test beyond marshalling.
+
+## Face classification (transport × audience × generation)
+
+| Face | Transport | Audience | Generation | Op coverage | Source |
+|---|---|---|---|---|---|
+| **REST** | HTTP `/api/*` | scripts / CLI backend | canonical hand-written | full | `src/core/api-server.ts` |
+| **MCP** | JSON-RPC over HTTP `/mcp` + stdio bridge | AI agents | mirrored (18 `redlog_*` tools) | full control catalog | `api-server` L111–208, `mcp-tools.ts` |
+| **Renderer IPC** | Electron IPC (`window.redlog`) | operator UI | native | UI-relevant subset | `src/main` ↔ `src/preload` |
+| **CLI** | shells out to REST | humans / scripts | thin wrapper | partial (events, marker, search) | `cli/redlog-cli.js` |
+| **Shell helpers** | source into `.zshrc`; curl REST | human shell | thin wrapper | quick ops | `shell/redlog-agent.sh` |
+| **Codex schema** | static JSON function schema | external GPT/codegen | mirrors MCP (18) | full control catalog | `docs/codex-tools.json` |
+
+## Gaps this framework surfaces
+
+| # | Gap | Why it matters | Fix |
+|---|---|---|---|
+| 1 | Is MCP a thin adapter over the *same* canonical ops as REST, or a **parallel `switch`**? | If parallel, the two can drift — the exact §7 risk | verify both dispatch into one shared op layer (the core modules); if not, extract it |
+| 2 | `codex-tools.json` is hand-maintained but must equal the MCP surface | §7 wants faces "ideally generated" — hand-sync drifts | **generate** the Codex schema from the MCP tool registry |
+| 3 | REST mixes control ops with **delivery ops** (`export/bundle`, `sanitize`, `deconfliction`, `anchors`) | blurs the §7 "evidence-relevant control only" boundary | move delivery ops to the Delivery subsystem's face; keep the control catalog minimal |
+| 4 | CLI covers only a subset of ops | fine if intentional, opaque if not | declare the CLI's op-subset explicitly (part of the Adapter contract) |
+| 5 | No single place lists the canonical catalog | new faces guess the op set | this doc's catalog table is the source; keep it in sync with the tool registry |
+
+## Why this decomposition pays
+
+- **Adding an integration surface** (a new agent client, a new script transport)
+ becomes "fill the Adapter contract for these ops on this transport" — no design.
+- **Drift becomes detectable:** any face exposing an op not in the catalog, or
+ containing logic, fails the contract on review.
+- **§7 stops being a slogan:** "one implementation, many thin faces" is now a
+ catalog + a contract you can check a diff against.
+
+## Cross-references
+
+- The method + variants: `DECOMPOSITION-METHOD.md`
+- The principle: `DESIGN-PRINCIPLES.md` §7
+- The full agent surface (transports, setup): `agent-integration.md`
+- Next candidate that owns the delivery ops split out here: Delivery / export targets
diff --git a/docs/DECOMPOSITION-BACKLOG.md b/docs/DECOMPOSITION-BACKLOG.md
new file mode 100644
index 00000000..e3754b9c
--- /dev/null
+++ b/docs/DECOMPOSITION-BACKLOG.md
@@ -0,0 +1,160 @@
+# Decomposition Backlog — the gaps the framework suite surfaced
+
+Written 2026-08-13. Consolidates the "Gaps" tables scattered across the nine
+decomposition docs (`DECOMPOSITION-METHOD.md` names them all) into one prioritized,
+**deduplicated** backlog. The dedup matters: because the frameworks interlock, many
+gaps are *the same underlying work seen from different docs* — so ~30 gap rows
+collapse into a handful of keystones. Each item notes: what, which docs surfaced
+it, code vs doc/example, what it unblocks / depends on, and whether it touches
+another session's active territory (coordinate before editing).
+
+> **Status (updated 2026-08-14):** no longer accurate as written — parts of K1 and
+> K2 have shipped (see the ✅ marks below and `git log`). Treat this as a planning
+> doc, **not** a shipped-state tracker: verify against code before relying on any
+> row. Items marked
+> ⚠️ touch files other sessions are actively editing (`Timeline.tsx`,
+> `Settings.tsx`, `retention.ts`, `plugins/types.ts`) — coordinate first.
+
+## Keystones (do first — each unblocks several other gaps)
+
+### K1 — The §3 inferred-`detection` primitive ⚠️ (plugins/types.ts, renderer)
+The single highest-leverage item. Surfaced **four times**: `PLUGIN-ROLES` gap #3,
+`DETECTOR-ROLES` gap #3, `EVENT-TYPE-VOCABULARY` gap #2, `TIMELINE-ELEMENTS` G4.
+- Define a shared **inferred-`detection` event shape** — confidence, detector
+ attribution, one-click promote-to-marker. **Still open.** Note `authority` and
+ `confidence` turned out to be **orthogonal axes**, not one field: authority is
+ two-valued and decides rendering + forwarding; confidence grades an inference
+ and is meaningless on a fact. `plugins/types.ts` already exports `Confidence`;
+ `core/authority.ts` owns the other axis. Do not merge them.
+- ✅ **Add `authority: 'fact' | 'inferred'` to `EventTypeDef`** — done, plus
+ `EventTypeContribution.authority` for plugins, a built-in default table, and
+ per-event override precedence (`core/authority.ts`). `insertEvent` stamps
+ `inferred` into the **hashed** row, so the label cannot be stripped without
+ breaking the chain; absence means `fact`.
+- 🟡 **Solid-vs-dashed wired to `authority`** — done for timeline event dots
+ (`lib/dotShape.ts`) and already done for the phase ribbon. Other timeline
+ elements and the one-click promote flow beyond phase are still open.
+- **Unblocks:** plugin Labeller role, semantic injection labels (K2/K3), timeline
+ honesty (G4), detector suggestions rendering as promotable. **Code.**
+
+### K2 — Off-chain content unification ⚠️ (retention.ts, new store module)
+Surfaced across `OFF-CHAIN-CONTENT-STORES` (gaps 1–6) + `SPEC-SCOPE-AWARE-LIFECYCLE`
+(G1–G2). ~~The keystone inside it: **io_ref is spec-only, never implemented**.~~ (No longer true — `src/core/io-store.ts` shipped; re-scope this item against code.)
+- Implement the **io_ref Blob store** (the keystone — no runtime code exists today).
+- Wire an **io keep-window + size cap** into `config.ts` (lifecycle G1).
+- Collapse the **three parallel retention branches** (`terminal.castKeepDays` +
+ `screenshots.keepDays` + `agentTranscripts.keepDays`) into **one loop over a
+ `contentStore.` registry** (off-chain gap #2 / retention).
+- Extract **one shared `isInsideDir()` traversal guard** (3 copies today) + one
+ shared "bytes-match-digest / pruned≠tampered" verify.
+- Route **agent-transcripts** through the Stream adapter.
+- **Unblocks:** the entire lifecycle spec (compress/prune/pin needs a real store),
+ scope-aware retention. **Code.**
+
+## Quick wins (🟢 zero core change — immediate value)
+
+### Q1 — `scan-parsers` plugin pack ❌ NOT DOING (2026-08-22)
+`SPEC-AI-ERA-PLUGINS` gap 1 / `PLUGIN-ROLES` Parser. Would parse nmap `-oX` /
+nuclei `-json` into typed `scanner`/`scan_result` events. **Ruled against** by
+`DESIGN-core-and-capture.md` §2.4: turning tool output into structured findings
+is interpretation, and what counts as a finding is per-shop opinion — the same
+line the positioning draws at ATT&CK tagging. The recording is kept and made
+searchable instead (`cast-index.ts`); "which run mentioned 445" is answerable,
+"list every open port" is deliberately not. The PR #8 branch built this pack;
+it was left unmerged on that ruling.
+
+### Q2 — `mcp-tee` example + shim
+`SPEC-AI-ERA-PLUGINS` gap 3 / `PLUGIN-ROLES` Tee. A stdio/HTTP-MCP proxy that
+forwards to a third-party MCP server and POSTs each call. No new API — needs
+`examples/plugins/mcp-tee`. **Example.**
+
+### Q3 — pair-emit lint ⚠️ (Settings.tsx, plugin loader)
+`EVENT-TYPE-VOCABULARY` gap 1. A capture plugin emitting a new `agent_type` without
+a paired `eventTypes` should **warn at load** and surface the undeclared kind in
+Settings ▸ Plugins (else it lands in the "other" lane). **Code (small).**
+
+## Unification / hygiene (real convergence, medium effort)
+
+### U1 — Scope-aware sanitize planner
+`SPEC-SCOPE-AWARE-LIFECYCLE` G3 + `DELIVERY-TARGETS` gap 5. Scope planner + `unknown`
+bucket in front of `sanitize.ts`; becomes the third-party Snapshot's default
+profile. Depends on scope verdict reuse (`ScopeMonitor`). **Code.**
+
+### U2 — Named sanitize profiles across delivery
+`DELIVERY-TARGETS` gap 1. Unify the three ad-hoc sanitize paths (bundle layer-4 /
+cloud-share `RedactionPreview` / deconfliction subtype-filter) into named profiles
+`full` / `scope-sanitized` / `filtered`, shared by all delivery targets. Overlaps U1.
+**Code.**
+
+### U3 — Control-plane face hygiene
+`CONTROL-PLANE-FACES` gaps 1–5 + `DELIVERY-TARGETS` gap 2.
+- Verify MCP dispatches into the **same canonical ops** as REST (not a parallel
+ `switch`) — the core §7 drift risk.
+- **Generate** `codex-tools.json` from the MCP tool registry (stop hand-syncing).
+- Move delivery ops (`export/bundle`, `sanitize`, `deconfliction`, cloud-share)
+ **off** the §7 control catalog onto a Delivery face.
+- Declare the CLI's op-subset explicitly. **Code.**
+
+### U4 — Cloud-share integrity parity
+`DELIVERY-TARGETS` gap 3. Verify cloud-share carries the same Ed25519 + OTS as
+`bundle-export`; make "verifiable snapshot" uniform, not per-transport. **Code (verify + maybe fix).**
+
+## Contribution-surface growth (unblock plugin roles)
+
+### C1 — Implement `monitors` (🔴, reserved)
+`PLUGIN-ROLES` gap 1 / `SPEC-AI-ERA-PLUGINS` gap 4. Isolated 🔴 that reads the event
+stream + appends inferred detections. **Depends on K1.** Unblocks semantic injection
+labels + plugin Monitor path. **Code.**
+
+### ✅ `c2-tailers` pack — BUILT (2026-08-22)
+`SPEC-AI-ERA-PLUGINS` gap 2 / `PLUGIN-ROLES` Tailer. Salvaged from PR #8 and
+verified against the current manifest schema. Follows a Sliver or generic-JSONL
+C2 log shell-side and lands beacon check-ins, task results and pivots on the
+timeline. Unlike Q1 this is *capture*, not interpretation: a C2 log is an event
+stream of things that happened, recorded verbatim (DESIGN-PRINCIPLES §3), where
+scan output is a result set — a set of claims about the world. Tests:
+`test/c2-tailers.test.ts` (parsers + loads-against-schema + shell-side tier).
+
+### C2 — Add `detectionPatterns` (🟢)
+`PLUGIN-ROLES` gap 4 / `DETECTOR-ROLES` gap 2. A 🟢 sibling of `lootPatterns` (or a
+`kind` field) emitting inferred detections for the regex-detectable injection
+subset. **Depends on K1.** **Code.**
+
+### C3 — `exporters` (🔴, reserved) + plugin Stream
+`PLUGIN-ROLES` gap (Exporter) / `DELIVERY-TARGETS` gap 4. Implement `exporters` (first
+instance = scope-sanitized bundle, U1); add a gated plugin Stream for SIEM/blue-team
+forwarders. **Code.**
+
+### C4 — User-plugin `tailers` isolation
+`PLUGIN-ROLES` Tailer (bundled-only today) / `CAPTURE-SOURCE-TAXONOMY`. Land the
+v0.8.3+ isolation so user-authored tailers (e.g. C2 log packs) work, not just
+bundled. Unblocks Q's `c2-tailers` as a user pack. **Code.**
+
+## Small / polish
+
+- **S1** Extractors state an explicit `confidence` field (`DETECTOR-ROLES` gap 1) —
+ §3 "this is inferred" visible, not assumed. **Code (small).**
+- **S2** Generalize the Correlator (`DETECTOR-ROLES` gap 5) — declare linker-field +
+ relation to enable pivot→command / loot→exfil chains. **Code.**
+
+## Already ticketed / owned elsewhere (cross-ref, don't duplicate)
+
+- **Timeline UX** `TIMELINE-ELEMENTS` G1–G3, G5 → existing **T1/T2/T3** tickets +
+ the §8 axis re-bind (redlog-91's active `SPEC-TIMELINE-AXIS` work). G4 = K1 above.
+- **Plugin lifecycle UI** → **PL1** (`UX-BACKLOG-TICKETS.md`).
+- **Instance-ordinal visual** (`TIMELINE-ELEMENTS` instance channel, `[proposed]`) →
+ the disputed uncommitted 159-line `Timeline.tsx` change; **authorship unresolved**,
+ do not touch until the owner commits it.
+
+## Suggested order
+
+1. **K1** (unblocks the most, and makes the timeline honest).
+2. **Q1 / Q2** in parallel (zero-core, ship value immediately).
+3. **K2** (unblocks the whole lifecycle).
+4. **U1/U2** then **C1/C2** (need K1) ; **U3/U4** independently.
+5. **C3/C4**, then **S1/S2** polish.
+
+## Cross-references
+
+- Where each gap came from: the nine docs indexed in `DECOMPOSITION-METHOD.md`
+- Existing tickets: `UX-BACKLOG-TICKETS.md`
diff --git a/docs/DECOMPOSITION-METHOD.md b/docs/DECOMPOSITION-METHOD.md
new file mode 100644
index 00000000..925d594b
--- /dev/null
+++ b/docs/DECOMPOSITION-METHOD.md
@@ -0,0 +1,88 @@
+# The Decomposition Method — how RedLog closes open subsystems
+
+Written 2026-08-13. RedLog has several subsystems that grow over time (plugins,
+capture sources, detectors, integration surfaces). Left ad-hoc, each addition is a
+bespoke design decision and the subsystem drifts. This doc names the **repeatable
+method** already used for plugins (`PLUGIN-ROLES.md`) and capture
+(`CAPTURE-SOURCE-TAXONOMY.md`) so any future subsystem — plugin or not — can be
+decomposed the same way, and lists which RedLog subsystems are next.
+
+## The method (six steps)
+
+1. **Name the open-ended subsystem.** The tell: things get *added* to it over time
+ (new plugin, new detector, new integration), and today each addition is a
+ one-off. If it's a fixed list that never grows, it doesn't need this.
+2. **Find the mechanism axis** — *what shape of work is this?* Usually
+ "what layer does it act on" or "what is its input → output shape." This axis
+ produces the **roles**.
+3. **Find the tier / authority axis** — the orthogonal concern that changes the
+ *rules* not the *shape*: trust (runs code inside RedLog?), authority (§3:
+ authoritative fact vs inferred suggestion?), or origin (built-in vs plugin).
+4. **Enumerate the closed role set** at the axes' intersection, then **prove it's
+ exhaustive** over the actual API/surface — a table mapping every existing slot
+ (every `PluginContributes` field, every `*-detector.ts`) to exactly one role.
+ The proof is what turns "a taxonomy" into "the taxonomy."
+
+ **Variant — one role + a canonical catalog.** Sometimes every member does the
+ *same* shape of work (e.g. control-plane faces are all thin adapters). Then the
+ mechanism axis yields a *single* role, and the closed set that matters is a
+ **canonical catalog** the role adapts (the op set). You still prove
+ exhaustiveness — every face exposes only catalog ops; anything outside is drift.
+ See `CONTROL-PLANE-FACES.md`.
+5. **Give each role a template** — the same headings for every role so building a
+ new one is filling a known shape: contract (what it emits), tier & why, local
+ cost, install/wiring, test seam, and a real instance.
+6. **List the API gaps the framework surfaces** — needs that fit no role are not
+ "unclassifiable," they are places the *surface* must grow. Naming them turns
+ unknowns into a backlog.
+
+## What makes a decomposition *good* (the invariants)
+
+- **Closed:** a small, named set — not "here are some examples."
+- **Exhaustive with proof:** a mapping table showing nothing falls outside. If you
+ can't build the table, the axes are wrong — pick different ones.
+- **One axis per concern:** mechanism decides the role; tier decides the rules.
+ Mixing them (a role that's half-shape, half-trust) is the usual failure.
+- **Template per role:** identical headings, so the docs are diff-able and a new
+ contributor fills a form, not a blank page.
+- **Gaps named, not hidden:** every "doesn't fit yet" is an explicit API-gap row.
+
+## Why it pays
+
+- **Design:** "should we build X?" becomes "which role is X?" — and if none, "which
+ gap does X open?" Both have answers; the ambiguous middle disappears.
+- **Build:** each role has a test seam and a contract, so implementation is
+ mechanical and reviewable against a template.
+- **AI-navigability:** a coding agent (or a new dev) routes a task to a role in one
+ lookup instead of reading the whole subsystem.
+- **Laws enforced structurally:** when every detector role must emit a §3
+ suggestion, honesty stops depending on each author remembering §3 — the role
+ contract carries it.
+
+## RedLog decomposition map
+
+| Subsystem | Status | Mechanism axis (→ roles) | Tier / authority axis | Doc |
+|---|---|---|---|---|
+| **Plugins** | ✅ done | what layer × what shape → 7 roles | runs code inside RedLog (🟢/🔴) | `PLUGIN-ROLES.md` |
+| **Capture sources** | ✅ done | which medium → 15 substrate items | operator-deliberate vs ambient (§2); built-in vs plugin | `CAPTURE-SOURCE-TAXONOMY.md` |
+| **Sanitize/retention/rotation** | ✅ specced | which lifecycle stage → hot/warm/cold | pinned vs unpinned (scope/marker); chain vs artifact | `SPEC-SCOPE-AWARE-LIFECYCLE.md` |
+| **Detectors / inference** | 🟡 next (this batch) | input→output shape → Extractor/Classifier/Monitor/Correlator | all §3-inferred; built-in vs plugin | `DETECTOR-ROLES.md` |
+| **Control-plane faces (§7)** | ✅ done | *one role* (Adapter) + canonical op catalog; classified by transport × audience × generation | which core ops exposed; auth level | `CONTROL-PLANE-FACES.md` |
+| **Delivery / export targets** | ✅ done | timing/cardinality → Snapshot vs Stream | sanitize profile by audience (full / scope-sanitized / filtered) | `DELIVERY-TARGETS.md` |
+| **Off-chain content stores** | ✅ done | content shape → Blob (io_ref, screenshot) vs Stream (`.cast`) | on-chain digest vs off-chain bytes; content- vs path-addressed | `OFF-CHAIN-CONTENT-STORES.md` |
+| **Timeline surface** | ✅ done (structural lens) | 6 encoding channels × 4 interaction modes | fact vs inferred suggestion (§3, solid/dashed) | `TIMELINE-ELEMENTS.md` (defers to `SPEC-TIMELINE-AXIS.md` for design) |
+| **Alerting (IP + scope)** | 🟡 decomposed (code gaps open) | signal shape → Self alarm (state) vs Target alarm (event) | fact / inferred / unknown (§3); distance ladder D0–D3 | `ALERT-ROLES.md` |
+| **Event-type vocabulary** | ✅ done | origin → authored/captured/agent/derived/system/plugin | authoritative vs inferred (§3); declares timeline identity | `EVENT-TYPE-VOCABULARY.md` (field ref: `event-schema.md`) |
+
+**Already closed elsewhere (cross-link, don't redo):** trust tiers 🟢/🔴;
+capabilities (least-authority set, `types.ts` L119); four-layer redaction
+(`redaction-design.md`); timeline surfaces §8 (map / I/O reader / HUD / search);
+Settings by necessity tier × front door (F3 ticket, §9).
+
+## Cross-references
+
+- Worked instances: `PLUGIN-ROLES.md`, `CAPTURE-SOURCE-TAXONOMY.md`,
+ `DETECTOR-ROLES.md`, `SPEC-SCOPE-AWARE-LIFECYCLE.md`
+- The laws a decomposition must carry: `DESIGN-PRINCIPLES.md` (§2 capture, §3
+ facts-vs-suggestions, §7 one-implementation faces, §8 timeline surfaces)
+- Deep-module vocabulary (seams, interfaces): the `codebase-design` skill
diff --git a/docs/DELIVERY-TARGETS.md b/docs/DELIVERY-TARGETS.md
new file mode 100644
index 00000000..7f771501
--- /dev/null
+++ b/docs/DELIVERY-TARGETS.md
@@ -0,0 +1,96 @@
+# Delivery Targets — how evidence leaves RedLog
+
+Written 2026-08-13. Applies `DECOMPOSITION-METHOD.md` to the delivery/export
+subsystem (`bundle-export`, `cloud-share`, `deconfliction`, `signing`). It also
+absorbs the boundary that `CONTROL-PLANE-FACES.md` split out: delivery ops
+(`export/bundle`, `sanitize`, `deconfliction`, cloud-share) are **not** control
+ops — they live here. Its job: **every way evidence leaves RedLog is one of two
+roles, declares a sanitize profile by audience, and carries a shape-appropriate
+integrity contract — so "add a delivery target" is filling a template, and no path
+can leak un-sanitized or unverifiable evidence.**
+
+## The two roles (mechanism axis: timing / cardinality)
+
+```
+Produce a complete, self-verifiable artifact of the store at a point in time? → SNAPSHOT
+Forward selected events in real time as they happen? → STREAM
+```
+
+Everything else (destination, audience, sanitize level) is a *classification*, not
+a role — see the axes below.
+
+## Master table
+
+| Target | Role | Audience | Sanitize profile | Timing | Transport | Integrity | Source |
+|---|---|---|---|---|---|---|---|
+| **Evidence bundle** | Snapshot | third-party / court / client | layer-4 sanitized (raw→sanitized swap) or full (opt-in) | batch | local file (out dir) | Ed25519 sig + OTS anchor + manifest + sanitized-reconciliation + **scope-adherence proof** (`ALERT-ROLES.md` G-D1) | `bundle-export.ts` |
+| **Cloud share** | Snapshot | operator / shareable link | redaction-gated (`RedactionPreview`) | batch | remote PUT (Worker/S3) + `expiresAt` | manifest + sanitize counts (verify it also signs/anchors — gap #3) | `cloud-share.ts` + `-uploader.ts` |
+| **Deconfliction feed** | Stream | blue team | subtype-filtered (`scope_violation` default) + `includeData` flag | real-time per-event | signed webhook (HTTP POST) | HMAC per body | `deconfliction.ts` |
+
+## The two classifying axes
+
+- **Audience** → sets the **sanitize profile** (the tier axis): third-party (must
+ not leak out-of-scope/PII) / blue-team (filtered subset only) / operator-link
+ (redaction-gated). Audience is *why* a profile is chosen.
+- **Transport** → local file / remote upload+expiry / webhook. Independent of role.
+
+## The uniform contract every target fills
+
+1. **Declare audience → sanitize profile.** Not ad-hoc per target — one of the
+ named profiles (see Gaps #1). A Snapshot for a third party runs the scope-aware
+ sanitize planner (`SPEC-SCOPE-AWARE-LIFECYCLE.md` Part B); a Stream declares its
+ event/subtype filter.
+2. **Carry shape-appropriate integrity.** Snapshot → signed + anchored + a manifest
+ that reconciles every sanitized swap (a stripped bundle is *detectable*, not
+ silently smaller). Stream → per-body signature so each forwarded event is
+ authenticable.
+3. **Append an audit event for the delivery action** (export/upload/flush), so the
+ act of delivering is itself on the chain.
+4. **Test seam:** the artifact/payload builder is pure — `build(store, profile) →
+ artifact` — testable without the transport (cloud-share already splits builder
+ from uploader "to keep the redaction gate testable", `cloud-share.ts` L16).
+
+## Per-role reference
+
+### Snapshot — a complete, self-verifiable artifact
+
+- **Produces:** the whole store (or a scoped slice) as a signed, anchored,
+ manifest-described artifact a third party can verify **without RedLog**
+ (`bundle-export` ships `signerPubKey` for `tools/redlog-verify.py`, L122).
+- **Sanitize:** runs the profile before write — layer-4 raw→sanitized swap
+ (`bundle-export` L86–101), each swap reconciled by a paired `system.sanitized`
+ event so tampering (stripping bytes) is detectable.
+- **Transport variants:** local file (bundle) vs remote upload with expiry
+ (cloud-share) — same role, different transport.
+- **Instances:** evidence bundle, cloud share. **Plugin face:** the reserved 🔴
+ `exporters` role (`PLUGIN-ROLES.md`) — a plugin-contributed Snapshot format; its
+ first instance is the scope-sanitized client bundle.
+
+### Stream — a real-time per-event feed
+
+- **Produces:** selected events forwarded as they land, each independently signed.
+- **Sanitize:** by *filter*, not redaction — only whitelisted subtypes
+ (`scope_violation` default, `deconfliction.ts` L21) and an `includeData` toggle
+ decide what leaves. Buffered with a shutdown flush (L158).
+- **Transport:** signed webhook. Config (URL/secret/filter) is frozen per batch to
+ avoid mid-batch drift (L137).
+- **Instances:** deconfliction feed. **Plugin face:** none yet — a plugin SIEM/
+ blue-team forwarder would be a gated plugin Stream (Gap #4).
+
+## Gaps this framework surfaces
+
+| # | Gap | Role | Fix |
+|---|---|---|---|
+| 1 | Sanitize is ad-hoc per target (bundle: layer-4 `sanitized_events`; cloud-share: own `RedactionPreview`; deconfliction: subtype filter) | both | unify into **named sanitize profiles** (`full` / `scope-sanitized` / `filtered`) shared across all targets — the same planner as lifecycle Part B |
+| 2 | Delivery ops sit on the REST control face | — | move `export/bundle`, `sanitize`, `deconfliction`, cloud-share off the §7 control catalog onto a Delivery face (`CONTROL-PLANE-FACES.md` gap #3) |
+| 3 | Cloud-share integrity may be weaker than bundle | Snapshot | verify cloud-share carries the same Ed25519 + OTS as `bundle-export`; a "verifiable snapshot" contract should be uniform, not per-transport |
+| 4 | No plugin path for either role | both | `exporters` (🔴, reserved) = plugin Snapshot; add a gated plugin Stream for SIEM/blue-team forwarders |
+| 5 | Snapshot scope-sanitize planner not wired | Snapshot | implement `SPEC-SCOPE-AWARE-LIFECYCLE.md` Part B as the third-party Snapshot's default profile |
+
+## Cross-references
+
+- The method: `DECOMPOSITION-METHOD.md`
+- Sanitize/retention/rotation this shares profiles with: `SPEC-SCOPE-AWARE-LIFECYCLE.md`
+- The plugin Snapshot role: `PLUGIN-ROLES.md` (Exporter)
+- The control-plane boundary this closes: `CONTROL-PLANE-FACES.md` gap #3
+- Bundle threat model + verify: `audit-trail.md`
diff --git a/docs/DESIGN-PRINCIPLES.md b/docs/DESIGN-PRINCIPLES.md
new file mode 100644
index 00000000..5ff509b2
--- /dev/null
+++ b/docs/DESIGN-PRINCIPLES.md
@@ -0,0 +1,195 @@
+# Design Principles
+
+> **§1 was superseded on 2026-08-22.** This document was written before the
+> core revision recorded in [`DESIGN-core-and-capture.md`](DESIGN-core-and-capture.md),
+> which moved the irreducible core from *defensibility* (a tamper-evident
+> record a third party can verify) to *knowability* (nothing missing, findable
+> afterwards — the operator reconstructing and writing up their own engagement
+> is the primary consumer). Tamper-evidence is kept, but it is no longer what
+> the product is for, and §1's test below reads a feature's worth by the wrong
+> yardstick.
+>
+> Where §1 and the core note disagree, the core note wins — which inverts the
+> "the principle wins and the doc is the bug" rule two paragraphs down, for §1
+> and only §1. Every other principle here still holds and several are cited by
+> shipped code.
+>
+> Salvaged from PR #8 unmerged rather than rewritten: the reasoning is worth
+> reading in the form it was argued, and rewriting history to agree with a
+> later decision hides that the decision was made.
+
+Written 2026-08-11. These are the durable laws behind RedLog's design — the
+things that should still be true five versions from now, and the yardstick every
+"should we build X?" question answers to. They were settled in a decision review
+(the grilling of 2026-08-10/11) and are the source of truth that
+`PRODUCT-POSITIONING.md`, `ROADMAP.md` and the UX docs elaborate. When a specific
+doc disagrees with a principle here, the principle wins and the doc is the bug.
+
+Each principle carries a one-line *why* and, where it matters, the *test* it
+gives you for future decisions.
+
+---
+
+## 1. RedLog is an evidence tool; passive capture is the feeder
+
+The irreducible core is the tamper-evident record a third party can verify:
+hash chain + OpenTimestamps + operator attribution + signed bundle. Passive
+capture is **how the chain gets fed**, not the point. It is necessary — a record
+you must remember to make has holes exactly where the interesting things
+happened, and a holey record is not defensible — but its necessity is *derived
+from* the evidentiary core, not independent of it.
+
+**Test:** a feature earns its place iff it (i) strengthens the chain, (ii) feeds
+capture that would otherwise be lost, or (iii) serves the live-OPSEC front door
+(§4). Anything else is a frozen secondary identity (§3) or scope creep.
+
+## 2. Capture broad, sanitize/prune later — and invest in detection
+
+For an evidence tool, capturing *more content* is not safer: exhaustive capture
+vacuums up client PII, credentials and out-of-scope data, which is evidentiary
+*liability*. But capturing *narrowly* loses reconstruction you can't get back,
+and you cannot always know at capture time what will matter. RedLog resolves this
+the way `redaction-design.md` already prescribes: **capture full, sanitize on
+export.** Bytes never enter the chain (only their sha256); bodies live in a
+prunable sidecar; sanitization is a downstream, chained, attributable action.
+
+This makes the **detection/sanitize/prune machinery necessary, not optional** —
+loot detector, entropy redaction, scope monitor, io sidecar, retention pruning,
+export sanitize. Broad capture without strong detection is just an
+undifferentiated pile of sensitive data in your evidence store.
+
+**Corollary — default-on vs opt-in by source:** capture the operator's *own
+deliberate actions* (commands, agent tool calls, navigation, targets) on by
+default; ambient *vacuums* (clipboard, periodic screenshots) opt-in — not because
+they shouldn't be captured, but because they must run continuously and carry an
+exposure window, so the operator should consciously accept that bet. (RedLog
+already does this: clipboard off by default.)
+
+## 3. Two-tier attributes: record facts, never write interpretation as fact
+
+RedLog records **facts** — including the fact "detector D fired at T with
+confidence C." It **never** records an *interpretation* as authoritative ground
+truth. Authoritative assertions come from exactly two places: the **operator**
+(a marker) or **primary capture** (a command was actually run). Everything
+interpreted — phase, target when ambiguous, loot type, scope verdict, any MITRE
+tag — is a **suggestion**: labelled `inferred`, confidence-scored, attributable
+to the detector, and **promotable by the operator** to an authoritative marker.
+
+This is not a new philosophy — loot detection ("looks like an AWS key,
+confidence high"), scope violations and redaction spans already work this way. It
+is the law that keeps RedLog honest as it adds convenience.
+
+**Test — every "should RedLog auto-detect X?" has one answer:** *yes, as a
+labelled suggestion; never as fact.* Inference lives in the derived/review layer
+or as a typed detection event; it never mutates the underlying event's data, and
+it is always visually distinct from an operator's assertion (e.g. a dashed
+segment vs. a solid one), with one-click promotion.
+
+## 4. Two front doors, one reason to exist
+
+RedLog has two entry points:
+
+- **Evidence recorder** (primary) — the reason RedLog exists. Remove it and
+ you have an OPSEC widget, not RedLog.
+- **Live-OPSEC HUD** (co-headline) — a genuinely standalone-usable second front
+ door. Some operators use *only* the HUD (external-IP/EXPOSED alarm, pivot
+ chain, capture-health) and never record an engagement.
+
+The HUD is a *main* feature — polished, marketed, usable on its own — but its
+OPSEC scope is **frozen** (§3-of-the-freeze-list): it does not grow new OPSEC
+features. "Main" means important and standalone-capable, not ever-expanding.
+
+**Consequences:** the IP/OPSEC/pivot monitors are *doubly* necessary (they feed
+the chain *and* the HUD); there must be a **first-class HUD-only runtime mode**
+(main window closed, monitors + overlay + tray keep running); and first-run
+leads with the zero-setup HUD value while **stating plainly that evidence
+recording is the core**, with the Capture Readiness checklist as the invited next
+step. Value-first, commitment-later — but never misrepresenting which is the
+point.
+
+## 5. Accept but freeze
+
+A secondary identity can be *legitimate yet capped*. This is the principled line
+that lets RedLog say no to the next almost-free add-on without pretending the
+existing one shouldn't exist. Currently frozen (kept, not grown):
+
+- **Live-OPSEC** — HUD at current scope (§4).
+- **Team affordances** — multi-operator tokens, deconfliction webhook, config
+ profile sync. **No central server, no shared DB, no team dashboard.**
+- **Local code plugins** — drop-in + trust gate; **no** exporters/monitors
+ contributions until there is demand.
+- **Cloud share** — opt-in BYO-bucket worker; not invested in, not core surface.
+
+**Shelved** (a different verdict — zero demand × high ongoing cost × dangerous if
+half-maintained): the **plugin marketplace** (signed registry, publisher trust,
+revocation). RedLog is a **product, not a platform**. Revisit only when a real
+"someone published a plugin, someone wants to install another's plugin" signal
+appears.
+
+**Kept as necessary extensibility:** *declarative* plugins (loot/redaction/target
+patterns, event types) — they extend the detection layer §2 deems necessary.
+
+## 6. Agents are a capture source, not a product direction
+
+Agent transcripts are *a capture source*, same chain, same attribution, same
+pause semantics as the shell hook — not a new product line. The "AI-agent-native"
+angle is real and worth marketing (natively recording agent actions into a
+tamper-evident log is a genuine 2026 wedge), but it is a pillar of the **capture
+layer**, not of the **control layer**. It never justifies growing an
+agent-operations platform.
+
+## 7. Control plane: one implementation, many thin faces
+
+Different consumers legitimately need different transports — agents want MCP,
+scripts want REST, humans want shell functions. The rule is **one canonical
+implementation** (the localhost REST handlers) with every other surface a
+**thin, ideally generated, adapter** that cannot drift: MCP is the blessed
+agent-native face; shell functions and the Codex JSON schema are thin wrappers.
+The control-op set stays minimal and **evidence-relevant only** — write evidence
+(mark, quickmark), operate the chain (anchor, recording pause/resume), read for
+agent decisions (scope, status, search, whoami). It never duplicates capture
+(hooks own that) and never grows evidence-irrelevant operations.
+
+## 8. The timeline is a reconstruction surface, not a live monitor
+
+Live awareness belongs to the HUD (§4). The timeline's job is **post-hoc
+reconstruction and review** — "reconstruct what happened, to whom, in what order;
+prove scope-compliance; hand it to a third party." This lets it shed live-ops
+weight (follow-mode urgency, live-tail primacy) and its two consequences follow:
+
+- **Organizing axis = target / phase, not source-type.** Source-type (the 18
+ lanes) is a live-firehose axis answering "which streams are active" — the HUD's
+ job now. Review asks "what happened to *this target*" and "reconstruct *this
+ phase*." Source-type demotes to a filter; TargetView folds in as the target
+ grouping; the 18-colour problem dissolves because far fewer groups are active
+ at once. (Phase per §3: operator-marker segments, with inferred dashed
+ suggestions.)
+- **Timeline = event map; transcript/exchange = I/O reader.** The timeline shows
+ *what happened & to whom* and surfaces I/O only as a presence glyph; the
+ actual input/output content is read in the paired transcript/exchange view via
+ drill-down. Neither tries to be the other. This also rationalises the
+ Timeline/TargetView/Transcript/Search sprawl into three views with distinct
+ jobs.
+
+## 9. Sequence, don't just add — surface weight mirrors necessity
+
+RedLog's complexity problem is that P2/P3/expert surfaces are presented to the
+solo operator (P1) at equal weight from the first screen — under-sequenced, not
+over-featured (with the marketplace the one genuine over-build, §5). The fix is
+**progressive disclosure**: essentials prominent, frozen secondary identities
+behind "advanced," shelved features removed. Settings, onboarding and the
+timeline all follow this — importance decides visual weight, and "find it"
+(search) is a complement to sequencing, never a substitute for it.
+
+---
+
+## Using these
+
+For any proposed change, walk the tests: Does it serve §1's core (or §4's second
+front door)? If it's capture, does §2's default-on/opt-in line apply? If it
+interprets anything, is it a §3 suggestion, not a fact? If it's a secondary
+identity, is it §5-frozen or genuinely core? Does it keep the timeline a §8
+reconstruction surface and the control plane a §7 single implementation? And does
+it earn its §9 surface weight? A change that can't answer these is either scope
+creep or an unstated change to the principles — make the latter explicit and
+deliberate, never a side effect.
diff --git a/docs/DESIGN-core-and-capture.md b/docs/DESIGN-core-and-capture.md
new file mode 100644
index 00000000..b9bd0932
--- /dev/null
+++ b/docs/DESIGN-core-and-capture.md
@@ -0,0 +1,448 @@
+# Core purpose and capture coverage — 2026-08-21
+
+Outcome of a design review that started from "what is this project actually
+for" and ended up revising the core. **This note contradicts
+`PRODUCT-POSITIONING.md` on one load-bearing point** (see §1) — that document
+declares itself the tie-breaker, so it needs updating before it misleads the
+next reader. Everything below is agreed; the positioning rewrite is not yet
+done.
+
+---
+
+## 1. The core, restated
+
+> RedLog records **what actually happened** during a red-team engagement —
+> every command, connection, file and screen, whoever or whatever produced it —
+> so that afterwards you can replay it, hand a purple team something they can
+> judge traffic from, and, above all, **find evidence of the things you were
+> not watching when they happened.**
+
+The everyday consumer of all this is **the operator themselves, writing the
+engagement up a day later** — not the purple team and not a lawyer. Writing a
+report means going back for the screenshot of a finding and the exact command
+with its output. That is the trip every captured thing has to survive, and it
+is why the output-capture gaps below (§2.3, §2.4) matter more than their size
+suggests: a command without its output is not evidence of anything.
+
+The third clause is the motive, and it is general. Work happens that you did
+not see: a scan running for four minutes, a background process, a teammate's
+session on the shared VPS, a command whose output scrolled past. Later you
+need to find out, and the evidence has to be there.
+
+**An AI agent is one producer among several, not half the picture.** It is the
+sharpest instance of the problem — you delegate, it does three hundred things,
+you know none of them — which is why it gets a summary card (§4b) and why the
+transcript tailer exists. But the same gap opens without an agent anywhere
+near it, and the model treats agents as one `agent_type` among eighteen
+lanes, which is correct. Earlier drafts of this note put "people and AI
+agents" in the core sentence; that over-weighted one source.
+
+### What changed
+
+`PRODUCT-POSITIONING.md` builds everything on tamper-evidence — "what
+separates RedLog from a scratchpad" — with the evidence consumer (S1, who
+"never opens the app") as the ultimate stakeholder.
+
+**Tamper-evidence is demoted to an incidental guarantee.** The hash chain,
+OpenTimestamps anchoring, operator signing, the signed bundle and the verifier
+all keep working. They stop being the design centre, and they stop earning
+prominent UI.
+
+The load-bearing property is no longer *defensibility* but **knowability**:
+completeness still matters, and matters more than ever, but it serves "so you
+can find out what happened" rather than "so it survives a legal challenge".
+
+### What this does to the wedge
+
+The old wedge was local + passive + tamper-evident + AI-native. Drop the third
+term (demoted) and demote the fourth (agents are one source, not the
+definition), and what is left is **capture at the point of work, locally,
+readable afterwards**. The nearest neighbour becomes RedEye rather than
+Ghostwriter, and the distinction holds:
+
+> RedEye parses C2 logs after the fact — the residue of someone else's system.
+> RedLog captures at the shell and the agent as it happens, so it holds things
+> a C2 log never contains: which file the agent read, which command it ran,
+> what came back.
+
+### Consequences for existing conclusions
+
+Several earlier judgements were built on the old core and invert:
+
+- **Compressing idle gaps** is no longer a contradiction. "A 20-minute gap is
+ *right*" belongs to the completeness-for-defensibility framing. For
+ after-action review, nobody wants to scroll through 20 idle minutes of an
+ eight-hour engagement. Gap compression is a core convenience.
+- **A reading posture in the app** is core, not scope creep. The earlier
+ argument against it rested on S1 never opening the app; S1 is no longer the
+ ultimate stakeholder, and the reviewer is the operator.
+ (The *audit mode* shape — chained-only + UTC + locked — still does not hold,
+ because all three ingredients are tamper-evidence concerns.)
+- **Timeline size** is justified investment. The timeline is the product.
+- **Search is core.** "Being able to find the evidence later" *is* search.
+
+## 2. Capture coverage — four gaps
+
+Nine sources exist: shell-hook, agent-tailer, builtin-terminal, mitmproxy,
+browser-console, screenshot, clipboard, file-watcher, process-monitor.
+Measured against "come back to the timeline and see what was done at that
+moment", four gaps. All four are to be closed.
+
+### 2.1 Non-HTTP traffic
+
+mitmproxy only sees what is proxied. Scanning, SMB, LDAP, RDP, reverse shells
+and C2 beacons produce none of it. A timeline can show `nmap -sV 10.10.11.24`
+and then nothing about what it did.
+
+**Decision: connection-level first, pcap later.** Connection-level records who
+connected to which `IP:port`, over what protocol, for how long — no payload,
+no root, and it maps onto the existing `pivot` detection which already watches
+connections.
+
+**Known limitation, to be surfaced honestly in the UI.** `nmap -sS` never
+completes a handshake, so socket-table polling cannot see it. Connection-level
+capture covers *established* connections — reverse shells, beacons, sessions,
+transfers — and structurally misses SYN scans. The command is recorded; the
+packets it produced are not, and the timeline must say so rather than imply
+coverage.
+
+**pcap is complementary, not a replacement.** It sees TLS as opaque bytes;
+mitmproxy terminates TLS and sees plaintext. The "HTTP exchange" column of the
+purple-team export can only come from mitmproxy.
+
+Cost of adding pcap later — architecturally cheap, operationally not:
+
+| | |
+|---|---|
+| Event model | Free. `io: { stream: 'cast', ref, off, len }` already discriminates on `stream`; `stream: 'pcap'` slots in, and `retention.ts` already sweeps large artifacts and writes `system.*_pruned`. |
+| Lane | Free. `scanner` already exists and is documented as an open agent_type for scan output. |
+| Privileges | Root / `CAP_NET_RAW`. Runs straight into positioning risk #1 (first-run friction). |
+| Windows | npcap — a driver install, and antivirus will have opinions. |
+| Redaction | The four-layer model handles text fields. Credentials inside binary protocol payloads are a different problem, and payloads in a bundle carry client data. |
+| Readiness model | Needs a fourth state (below). |
+
+### 2.2 Commands on remote hosts
+
+Smaller than it looks. `terminal-manager.ts` records the **entire PTY byte
+stream** to the `.cast`, so everything typed inside an `ssh` or `ligolo`
+session, and everything that came back, is already recorded. What is missing
+is the structured `command_start` / `command_end` pair, because the shell hook
+is not running on the remote host.
+
+So the gap is not "no evidence" but "evidence that cannot be queried".
+
+**Decision: (a) *and* (b) + (c), split by who owns the host.**
+
+`hooks/vps-deploy.sh` already deploys the hook to a remote box and tunnels
+events back over `ssh -R`, through the same code path as the local hook, with
+the envelope's `hostname` distinguishing origin. That is the right answer for
+infrastructure **you** own — your VPS, a redirector, a team jump box — and it
+yields complete structured events.
+
+It is the wrong answer for a **target** host: writing a hook onto a machine
+you are assessing is an OPSEC footprint and, under many rules of engagement,
+not permitted. There, parse the local PTY stream and mark what was parsed.
+
+The earlier objection to hook deployment ("writing to a target host") only
+ever applied to half the cases.
+
+No new concept is needed: `data.source` already carries provenance
+(`builtin-terminal`, `shell`, `file-watcher`, `http`, …), so a parsed command
+is `source: 'pty-parsed'`. The UI already has a vocabulary for "this row is a
+different grade of evidence" — the tier glyph.
+
+**Hard constraint: prefer a miss to a mistake.** A parse error does not
+produce a gap, it produces a *fabrication* — a command on the timeline that
+the operator never typed, which is worse than its absence, because this
+product's foundation is that the record can be trusted. The parser stays
+conservative: only unambiguous shell prompts, and never guess inside
+multi-line input, pasted blocks, or full-screen TUI programs (vim, less).
+Where it cannot tell, it emits nothing and the recording still holds the
+bytes.
+
+Pairs with making `.cast` recordings full-text searchable, so an unstructured
+remote session is still findable.
+
+### 2.3 Windows is thin
+
+More specific than "thin": `shell-hook.ps1` captures command, exit code and
+duration automatically, but **output only if the operator prefixes the command
+with `Redlog-Run`** — PowerShell cannot cleanly split stdout/stderr from a
+post-command hook. Remembering a prefix is note-taking discipline, which is
+the exact thing §1's second half exists to remove. macOS and Linux capture
+output automatically.
+
+**Decision: PowerShell `Start-Transcript` plus a tailer.** Same shape as the
+agent tailer — something else writes a file, we follow it — so it reuses
+`tailer-host.ts` rather than adding a mechanism.
+
+Rejected: script-block logging (4104 events) captures the most, including
+deobfuscated payloads, but needs administrator rights and a group-policy
+change, which lands straight on positioning risk #1. Also rejected:
+auto-wrapping every command in `Redlog-Run`, which changes how commands
+execute (stream redirection) and would break interactive tools.
+
+`process-monitor` polling still misses short-lived processes. Open.
+
+### 2.4 Tool output is replayable but not queryable
+
+**Decision: full-text search over recordings, plus keep the artifacts. No
+parsers.**
+
+`.cast` recordings become full-text searchable, and `file-watcher` keeps the
+tool's own output files (`nmap.xml` and friends) with their SHA-256.
+
+This answers "where is the output of that scan" and "which runs mention 445".
+It does not answer "list every open port", and that is the correct place to
+stop: parsing nmap XML into structured findings is *interpretation*, the same
+shape as the opinionated ATT&CK tagging the positioning lists as a non-goal.
+What counts as a finding is per-shop opinion; what was on screen is not.
+
+Full-text search over `.cast` closes two gaps at once — it is also what makes
+an unstructured remote session (§2.2) findable — which makes it the
+highest-leverage single capability in this note.
+
+**Built 2026-08-22** (`src/core/cast-index.ts`). Three decisions worth
+recording, because each had a plausible alternative:
+
+*A separate database file, not a table in `events.db`.* The index is derived,
+mutable and rebuildable; the evidence DB is none of those, carries
+append-only triggers, and is copied verbatim into evidence bundles. The
+deciding argument was retention: recordings are swept after N days, and text
+surviving in an index inside the evidence DB would make the retention policy
+a lie — the operator is told the recording aged out while its contents stay
+searchable. `retention.ts` now calls `pruneCast`, and a test in
+`test/retention.test.ts` fails if that link is ever cut.
+
+*Token search, not trigram.* Trigram gives grep semantics, which is what an
+operator reaching for this expects, at roughly 3x the indexed text on disk.
+Across an engagement of 50 MB recordings that is the difference between an
+index that ships and one that fills the disk. unicode61 with prefix indexes
+covers what operators actually type — an IP, a port, a hostname, a tool name
+— because those are token-initial. The UI states the limit rather than
+letting a miss read as absent bytes.
+
+*The index status is surfaced, not hidden.* A project whose recordings are
+still being read returns fewer hits than it will in a minute. For this
+product that is the one failure mode that must never be silent, so the search
+view says how many recordings are still pending — including on the "no
+results" screen, which is exactly where a silent partial index would do its
+damage.
+
+## 3. Point and span
+
+Not a new model — the data already carries it and the timeline does not show
+it.
+
+- A single connection or command → a **point**.
+- A scan or bulk operation (nmap, dirb, gobuster) → a **span** covering its
+ duration.
+- In both cases **only the command is listed.** The connections it produced
+ are its contents, reachable by reference, never rendered individually.
+
+This preserves causality, which capture-time aggregation into a synthetic
+"scan activity" event would have destroyed. The command *is* the event.
+
+Already present:
+
+- `_causes` — a first-class link, used by the tier rules ("does it earn keep
+ via `_causes` to a chained row?").
+- `duration_sec` on `command_end` — a twelve-second command is already a span
+ in the data.
+- `bands` with `x0`/`x1` in `Timeline.tsx` — the span rendering path exists,
+ currently used for terminal session boundaries.
+
+Connections with no parent command — beacon heartbeats, implant callbacks,
+GUI-tool traffic — follow:
+
+| | |
+|---|---|
+| Has a parent command | attach; do not draw separately |
+| No parent, instantaneous | point |
+| No parent, sustained | span — a reverse shell open from 14:32 to 15:11 is a long line, and that shape is itself the information |
+
+**Open question:** a span crossing a compressed idle gap. Proposal: keep it
+continuous but draw the compressed stretch dashed, so length stops implying
+elapsed time exactly where the axis stopped being proportional.
+
+## 4. Purple-team export
+
+An **add-on**, not the core. The core is that all of this is recorded in the
+first place; the export is a way to hand it over.
+
+Columns: time, source, target, command, HTTP exchange. Across **all** capture
+sources, not just AI agents — ordinary red-team work needs command-and-time
+logging just as much.
+
+Mostly a projection over what already exists. The envelope carries
+`timestamp`, `operator`, `hostname`, `agent_type` and `target_id` on **every**
+event, which covers time/source/target uniformly; command and HTTP exchange
+are per-lane fields in `data`, correctly so — a DNS query has no command and a
+screenshot has no HTTP exchange.
+
+- Formats: JSON and Markdown.
+- **Includes full agent input and output.** Markdown collapses it behind
+ ``; JSON carries it whole. Both go through the existing export
+ redaction boundary. The skeleton alone cannot answer "what did the agent
+ actually see", which is the motive in §1.
+
+**Blocker:** the one-line summary of an event exists only in
+`Timeline.tsx:111` (`eventTitle`). The main process and the export path have
+no equivalent, so a purple teamer would receive raw per-lane JSON. Move
+`eventTitle` into `src/core/` so the timeline and the export read one
+implementation — the same fix already applied twice this cycle (time
+formatting, the shortcut table), both of which had been "identical output, so
+nothing looked wrong" right up until they were not.
+
+## 4b. Reviewing a session
+
+A session — an agent's, or a terminal's — is often hundreds of entries, and
+scrolling it is not the same as knowing what it did. That is §1's motive
+restated as a UI problem, and it is not agent-specific: an eight-hour terminal
+session needs the same digest.
+
+**Decision: a structured summary card per session, folding open to the detail.**
+Which files were touched, which commands ran, which hosts were reached, how
+many failed, how long it lasted.
+
+Every line is an aggregation over fields that already exist. There is no
+judgement about *what the session was doing* — an LLM-written précis was
+rejected: a wrong summary inside an evidence log is unfalsifiable by the person
+reading it, and generating it would send client data to an external API,
+against the local-first pillar.
+
+This is a third consumer of the same `GROUP BY` aggregation that host search
+and the Targets page need — which is what makes that query worth building
+properly.
+
+## 4c. Getting back to a moment in time
+
+An engagement runs for days. The minimap supports drag-to-zoom and
+click-to-jump, but there is no way to say "8/19 14:32" — you aim at a density
+histogram.
+
+**Decision: a time input, wired into ⌘K, plus day boundaries drawn on the
+minimap.** No new view. ⌘K keeps being the jumper; the restored search page is
+the explorer.
+
+## 4d. Two lanes with no producer
+
+`credential_use` and `c2_checkin` have no built-in producer — they exist only
+for external agents posting to `/api/events`, so by default they are always
+empty. "Which credentials did I use where" is one of the questions an
+after-action review asks most.
+
+**Decision: give `credential_use` a producer; leave `c2_checkin` external.**
+Credential use is derivable from capture that already runs — a key copied to
+the clipboard, `-p` / `--password` on a command line, an `Authorization`
+header seen by mitmproxy. C2 check-in needs integration with a specific C2
+framework, and parsing a C2's logs is RedEye's job by the positioning's own
+table.
+
+## 4e. What the chain sampler now means
+
+The background sampler stays, and its badge copy changes. It reads "background
+chain sampler detected tampering"; under §1 that is the wrong claim, and it
+was usually the wrong claim before — the one real `chain_sample_broken` in the
+field (v0.7.5, closed in v0.11.3) turned out to be field *order* in a
+reconstructed hash, not an attacker.
+
+What a broken link means now is **"the record does not join up here, so
+something may be missing"** — which matters more under knowability than it did
+under defensibility, because the operator is relying on that record to find
+out what happened. It still raises `Needs attention`.
+
+## 5. Consequences for the readiness model
+
+`computeCaptureReadiness` has three states: `active`, `wired`, `todo`. A
+source that needs elevated privileges introduces a fourth: **installed but not
+permitted**. It is not `todo` — the operator has done the setup — and it is
+not `wired`, because it will never produce an event. Classified as `todo`
+today, first-run guidance would keep telling them to install something that is
+already installed.
+
+Enabling any source that writes client traffic to disk owes a §5.5-style
+disclosure at the moment it is switched on, naming what lands on disk. The
+threat model's at-rest disclosure currently assumes text events plus terminal
+recordings.
+
+## 6. Still open
+
+- Windows script-block logging (§2.3, rejected). PowerShell Start-Transcript
+ following (§2.3, chosen) is **done** — `transcript-tailer.ts` follows the
+ transcript the `start-transcript-hook.ps1` writes and parses it back into
+ shell command events (`start-transcript.ts`). Queryable tool output (§2.4) is
+ **done** — full-text search over `.cast` recordings (`cast-index.ts`).
+ Non-HTTP traffic (§2.1) is **done** — connection-level capture
+ (`connection-monitor.ts` / `connection-table.ts`).
+- Settings: **11 pages / 27 field groups**, against positioning risk #2 which
+ named "8 tabs / 34 groups" as the symptom of breadth outrunning the persona.
+ The left list took the page count *up* to 13 before removals brought it to 11
+ (export and cloud-share pages gone) and the groups from 34 to 26, then back
+ to 27 when connection capture added one. Three groups still need a scope
+ ruling rather than a merge: the deconfliction webhook, operator tokens, and
+ the MCP server.
+- Timeline view modes — the audit-mode shape does not survive §1, but the
+ eight flat toggles are still a real problem.
+- 18 lanes.
+- ~~The plugin marketplace, publisher trust and revocation lists.~~
+ **Removed 2026-08-22.** Browsing a registry, pinning publishers by Ed25519
+ fingerprint, and reading revocation lists are the machinery of *distributing*
+ capture code. §1 is about not missing anything and finding it afterwards;
+ distribution serves extensibility, which is a different product. Managing
+ what is installed stays, because "is anything capturing that I did not put
+ there" is a question §1 does have to answer. The `redlog-sign` CLI went with
+ it — it existed only to produce registry entries.
+- ~~Size-pressure eviction for the body store.~~ **Built 2026-08-22**
+ (`body-eviction.ts` + `sweepBodyStore` in `retention.ts`). Time-based
+ retention did not bound the store by size; this evicts the coldest UNPINNED
+ bodies when it exceeds `httpBodies.maxBytes` (config-only, 0 = unbounded,
+ the default). Scope is the pin: an in-scope body is never evicted. The safety
+ property is that eviction deletes the `.body` file, never the event — the
+ sha256 attestation and the chain survive, and an evicted body reads back as
+ "content no longer on disk", not as an erased fact. A `system.body_evicted`
+ audit event records each sweep, and if the pinned set alone exceeds the
+ budget the shortfall is reported rather than resolved by touching evidence.
+- Rewriting `PRODUCT-POSITIONING.md` to match §1.
+
+### The test being applied to Settings
+
+For each field group: **does the operator in §1 have to understand this to
+answer "what happened, and can I show it"?** If not, it is either a default
+this product should pick, or something that belongs to a different product.
+
+Two rounds so far, worth 4 groups and 1 page between them.
+
+The first removed three groups that were not settings at all — export is an
+action, and it now lives in the shell's one export control (§10). That also
+emptied the "Retention & export" page down to a screenshot interval, which had
+been filed under export while being a capture setting; it moved, and the page
+went.
+
+The second removed the marketplace. Only one group, but the group was a
+registry browser, a publisher trust editor, and a revocation viewer stacked
+inside it — the group count understated it, which is worth noting because the
+count is the metric positioning risk #2 uses.
+
+The third went group by group, and two of the five candidates turned out to
+be wrong on inspection. Both failures were the same shape — **the name did
+not describe the contents**:
+
+*"Polling" is not a tuning knob.* Every field in it decides what RedLog
+itself sends to the network and to whom: which resolver or third-party echo
+service learns the operator's address, how often, from where. During an
+engagement that is OPSEC surface, and it belongs in front of the operator
+rather than in a packet capture. It stays, renamed to "What RedLog itself
+sends out". The bad name is what nearly deleted it.
+
+*"Team Profile Sync" was one useful button and one duplicate.* Import
+duplicated the project picker's, which is where you want it — you seed a
+config when creating a project, not after. Export could not go with it:
+deleting it would leave an import that consumes files nothing produces, and
+it carries `views.json` as well as `config.yaml`, so it is not a button for
+`cp`. Kept as one button, renamed "Hand-off profile".
+
+Actually removed or merged: screenshot quality folded into screenshots (one
+subject, two groups); VPN adapter detection folded into safe/exposed IPs (it
+exists only to answer that group's question); "Check for updates" moved to the
+title bar beside the version string it sat next to a copy of.
diff --git a/docs/DESIGN-two-tier-chain.md b/docs/DESIGN-two-tier-chain.md
index 840ede55..ee7ce728 100644
--- a/docs/DESIGN-two-tier-chain.md
+++ b/docs/DESIGN-two-tier-chain.md
@@ -51,7 +51,7 @@ Prior-art guidance: this is not a performance PR. v0.12.1 cached Ed25519 keys an
- Every logged-tier row lands in every export bundle whether the recipient asked for full evidence footprint or not.
- Every logged-tier row that touches the chain widens the window during which `chain_sample_broken` can misfire under real-world clock skew.
-Fixing this at v0.13 also unblocks the plugin-marketplace roadmap ([PLUGIN_MARKETPLACE.md](PLUGIN_MARKETPLACE.md)), because a 🟢 trust-tier plugin that wants to log its own high-frequency observations can be pointed at the logged tier by default, without every plugin author having to think about anchor cadence.
+Fixing this at v0.13 also helps plugins, because a 🟢 trust-tier plugin that wants to log its own high-frequency observations can be pointed at the logged tier by default, without every plugin author having to think about anchor cadence.
---
@@ -316,7 +316,7 @@ function insertLoggedEvent(
): RedLogEvent | null {
if (!opts?.operatorId) {
throw new Error(`insertEvent (logged): operatorId is required (agent_type=${agentType}). ` +
- `Every event must resolve to a known operator — see docs/operators.md.`)
+ `Every event must resolve to a known operator.`)
}
const db = getDB()
const now = Date.now()
@@ -777,7 +777,7 @@ Release note draft (for CHANGELOG.md):
### 13.1 Plugin-contributed event types default to which tier?
-**Recommendation: chained.** A 🟢-trust plugin (see [PLUGIN_MARKETPLACE.md](PLUGIN_MARKETPLACE.md) and [MEMORY.md#redlog-plugin-system](file:///Users/guantou/.claude/projects/-Users-guantou-Desktop-redlog/memory/redlog-plugin-system.md)) that emits `insertEvent('my-plugin', {...})` should land chained by default. Plugin authors can opt into logged tier by extending `LOGGED_TIER` in a plugin manifest field:
+**Recommendation: chained.** A 🟢-trust plugin that emits `insertEvent('my-plugin', {...})` should land chained by default. Plugin authors can opt into logged tier by extending `LOGGED_TIER` in a plugin manifest field:
```yaml
# plugin.yaml
diff --git a/docs/DETECTOR-ROLES.md b/docs/DETECTOR-ROLES.md
new file mode 100644
index 00000000..493667da
--- /dev/null
+++ b/docs/DETECTOR-ROLES.md
@@ -0,0 +1,142 @@
+# Detector Roles — the inference layer, decomposed
+
+Written 2026-08-13. Applies `DECOMPOSITION-METHOD.md` to RedLog's detection /
+inference layer (the ~10 bespoke `*-detector.ts` / `*-monitor.ts` files). Its job:
+**every detector is one of four roles emitting one uniform §3 suggestion — so
+"add a detector" is filling a template, and DESIGN-PRINCIPLES §3 is carried by the
+role contract instead of each author's memory.**
+
+## The principle everything derives from
+
+By §3, RedLog records **facts** — operator markers and primary capture (a command
+*was* run). **Everything a detector produces is interpretation on top of a fact,
+so it is an inferred *suggestion*, never authoritative** — labelled, confidence-
+scored, attributable to the detector, and operator-promotable. The detector layer
+is therefore uniform on the *authority* axis (all inferred); roles are cut on the
+**mechanism axis: input → output shape.**
+
+## The four roles (mechanism axis)
+
+```
+Pull a structured fact out of ONE event (a command)? → EXTRACTOR
+Attach a LABEL + confidence to one event/output? → CLASSIFIER
+Watch WORLD/SYSTEM state and emit on a transition? → MONITOR
+Relate TWO+ events into a causal/relational edge? → CORRELATOR
+```
+
+## Master table (every detector file maps to one role)
+
+| Role | Input → Output | Built-in files | Emits | Confidence source | Plugin counterpart |
+|---|---|---|---|---|---|
+| **Extractor** | 1 command → a structured fact | `pivot-detector`, `target-extractor`, `technique-tagger` (`detectCleanup`/`detectFileTransfer`) | `pivot` / target / `cleanup` / `file_transfer` companion events | deterministic rule (implicit-high) | `targetExtractors` (Recognizer) |
+| **Classifier** | 1 event/output → label + confidence | `loot-detector`, `scope-monitor`, `command-tagger` | `loot`, `scope_violation`, stamped tags | pattern/entropy → `high\|medium\|low` | `lootPatterns` / `commandTags` (Recognizer) · `detectionPatterns`/`monitors` (Labeller) |
+| **Monitor** | world state over time → transition event | `ip-monitor`, opsec-state monitor | `system.ip_transition`, `system.opsec_state_changed` | observed state delta | — (built-in; a plugin path would be a gated Monitor) |
+| **Correlator** | N events → causal edge | `causes-resolver` | `_causes` links (start→end via flow_id) | structural match (flow_id/session) | — |
+
+**Completeness:** all ten inference-layer files map to exactly one of four roles.
+The axis (input→output shape) is exhaustive because a detector can only consume
+one event, one blob, ambient state, or a set of events — there is no fifth input
+class.
+
+## The §3 suggestion contract (what EVERY role must emit)
+
+This is the uniform output the role framework enforces — the payoff of the
+decomposition:
+
+- **Inferred, never authoritative:** does not mutate the source event's data; lives
+ in the derived/review layer or as a typed detection event.
+- **Confidence-scored:** an explicit `confidence` (loot already does this,
+ `loot-detector` L8/L17). Deterministic Extractors carry an implicit-high but
+ should still *state* it, not omit it.
+- **Detector-attributed:** names which detector fired (`target-extractor` already
+ stamps `extractor_name`; every role should carry the analog).
+- **Operator-promotable:** one-click promotion to an authoritative marker; rendered
+ visually distinct from operator assertions (dashed vs solid, §3).
+
+## Per-role reference (template)
+
+### 1. Extractor — pull a structured fact from one command
+
+- **Input → output:** a command string → a structured record (tool, subtype, via,
+ route, target). Pure function, no side effects (`technique-tagger` L13,
+ `pivot-detector` L32 are the model).
+- **Emits:** a first-class companion event (`pivot`/`cleanup`/`file_transfer`) or a
+ resolved target with provenance.
+- **Confidence:** deterministic rule → implicit-high; **state it explicitly** (gap:
+ `pivot-detector`/`technique-tagger` currently omit a confidence field though §3
+ says the *interpretation* "this ssh -D is a SOCKS pivot" is inferred).
+- **Cost:** one regex pass per command. Negligible.
+- **Test seam:** `detectX(command) → record | null` over sample-command fixtures —
+ already the shape; keep it pure.
+- **Instances:** pivot, cleanup, file-transfer, target extraction. Plugin face:
+ `targetExtractors`.
+
+### 2. Classifier — label an event/output with confidence
+
+- **Input → output:** an event or output blob → a label + `confidence`.
+- **Emits:** `loot` (type + confidence), `scope_violation` (verdict), stamped tags.
+- **Confidence:** pattern/entropy/heuristic → `high|medium|low` (`loot-detector`
+ L8). The **scope verdict is a Classifier output** — inferred, so it must be
+ promotable, not silently authoritative.
+- **Cost:** regex/entropy per event; a model-backed classifier (injection) belongs
+ in a 🔴 plugin Monitor, not the built-in hot path.
+- **Test seam:** `classify(input) → {label, confidence} | null` over labelled
+ fixtures (real / not).
+- **Instances:** loot, scope verdict, technique/command tags. Plugin face:
+ `lootPatterns`, `commandTags` (deterministic), and the Labeller role
+ (`detectionPatterns` 🟢 / `monitors` 🔴) for AI-era injection labels.
+
+### 3. Monitor — watch state, emit on transition
+
+- **Input → output:** ambient world/system state sampled over time → a transition
+ event only when it changes.
+- **Emits:** `system.ip_transition`, `system.opsec_state_changed` (VPN/DNS/MAC/
+ hostname). `IPMonitor extends EventEmitter` (`ip-monitor` L122) is the model.
+- **Confidence:** the delta is observed (a fact *that state changed*); the
+ *interpretation* (safe vs risky transition) is the inferred part.
+- **Cost:** polling (30 s opsec, hysteresis on IP) — standing, so tune intervals;
+ matches §2's ambient-is-opt-in stance for anything heavier.
+- **Test seam:** `next(prevState, sample) → transition | null` — pure over state
+ pairs; the poller is a thin wrapper.
+- **Instances:** IP monitor, OPSEC monitor.
+
+### 4. Correlator — relate events into a causal edge
+
+- **Input → output:** two or more events → a causal/relational link between them.
+- **Emits:** `_causes` edges (a `command_end` refers to its `command_start`; an
+ `http_request_end` to its `http_request_start` via `flow_id` —
+ `causes-resolver` L58/L75).
+- **Confidence:** usually a **structural** match (flow_id/session/pid), so
+ high-certainty; a heuristic correlation (timing-only) would be inferred and must
+ say so.
+- **Cost:** a small in-memory cache keyed by the linker field; O(1) per event.
+- **Test seam:** `resolveIncomingCauses(agentType, data) → sourceIds` over an
+ event sequence (`_resetCausesResolver` exists for test isolation, L104).
+- **Instances:** start→end causal linking. Candidate future: pivot→command,
+ loot→exfil chains.
+
+## The cross-link that matters
+
+**The detector layer and the plugin Recognizer/Labeller roles are one taxonomy
+seen from two sides.** Built-in `loot-detector`/`target-extractor` are the core's
+Classifier/Extractor; plugin `lootPatterns`/`targetExtractors`/`detectionPatterns`
+are the *contributed* Classifier/Extractor. So a new detection need has one
+decision — **core or plugin** — and the same role either way. This is why the two
+docs share vocabulary.
+
+## Gaps this framework surfaces
+
+| # | Gap | Role | Fix |
+|---|---|---|---|
+| 1 | Extractors omit an explicit confidence field | Extractor | add `confidence` (implicit-high) so §3's "this is inferred" is visible, not assumed |
+| 2 | scope verdict is emitted as a plain violation, not a promotable suggestion | Classifier | render the verdict as a §3 suggestion with one-click operator promotion |
+| 3 | no built-in inferred-`detection` event type | Classifier/Labeller | define the shared §3 suggestion shape (confidence, attribution, promote) — same gap as `PLUGIN-ROLES.md` gap #3 |
+| 4 | Monitor has no plugin path | Monitor | if third-party state monitors are wanted, a gated plugin Monitor (mirrors 🔴 `monitors`) |
+| 5 | Correlator is single-purpose (start→end) | Correlator | generalize to declare linker-field + relation, enabling pivot→command / loot→exfil chains |
+
+## Cross-references
+
+- The method: `DECOMPOSITION-METHOD.md`
+- Plugin-side twin roles: `PLUGIN-ROLES.md` (Recognizer, Labeller)
+- The law this layer carries: `DESIGN-PRINCIPLES.md` §3
+- Event shapes: `event-schema.md`
diff --git a/docs/EVENT-TYPE-VOCABULARY.md b/docs/EVENT-TYPE-VOCABULARY.md
new file mode 100644
index 00000000..08939b74
--- /dev/null
+++ b/docs/EVENT-TYPE-VOCABULARY.md
@@ -0,0 +1,101 @@
+# Event-Type Vocabulary — origin, authority, and timeline identity
+
+Written 2026-08-13. Applies `DECOMPOSITION-METHOD.md` to the `agent_type`
+vocabulary — the set of event kinds and **how each declares its timeline
+presentation and its §3 authority**. This is the seam that ties three frameworks
+together: a **capture plugin** (`PLUGIN-ROLES.md`) emits events; each event's
+`agent_type` must claim a **timeline identity** (`TIMELINE-ELEMENTS.md` channels);
+and that identity must state whether the event is a **fact or a suggestion**
+(§3). `event-schema.md` owns the field definitions; this doc is the classification
++ the pairing rule.
+
+## The load-bearing rule (why capture plugins need this)
+
+**A capture plugin that emits a new event kind MUST pair its `capture` with an
+`eventTypes` contribution.** Without it, the event's `agent_type` is unknown to the
+registry (`event-registry.ts`) and the timeline drops it into the generic "other"
+bucket — no lane, no colour, no glyph, no authority. So "capture" and "how it looks
+on the timeline" are **one decision**, not two: emitting without declaring is the
+most common way a plugin's evidence becomes illegible on the reconstruction
+surface.
+
+```
+Parser/Tailer/Tee (PLUGIN-ROLES) → emits events with agent_type X
+ │
+ MUST pair with ─────────────► eventTypes: { agentType: X, label, lane, color, icon }
+ │ (registerEventTypes → event-registry)
+ ▼
+ timeline channels (TIMELINE-ELEMENTS): lane, colour, glyph
+ + §3 authority (fact vs inferred)
+```
+
+## Classification — origin axis (mechanism)
+
+Every `agent_type` maps to exactly one **origin** — where the event comes from —
+which also fixes its default authority (§3):
+
+| Origin class | agent_types | Authority (§3) | Default timeline identity |
+|---|---|---|---|
+| **Operator-authored** | `marker` | **authoritative** (operator assertion) | own lane, solid |
+| **Primary-capture** | `shell`/`terminal`, `http`, `http_navigation`, `browser`, `scanner`, `dns`, `screenshot`, `clipboard`, `process` | **authoritative** (a thing actually happened) | source lane / target lane, solid |
+| **Agent** | `agent` (tool-calls) | authoritative (the agent did call the tool) | agent lane, solid |
+| **Detector-derived** | `loot`, `pivot`, `cleanup`, `file_transfer` | **inferred** (interpretation, §3) | own lane, **dashed** ✅ |
+| **System / meta** | `system` (`recording_paused`, `config_changed`, `sanitized`, `*_pruned`, `ip_transition`, `opsec_state_changed`) | authoritative (system fact / drift signal) | system lane / ribbon, solid |
+| **Split-authority** | `system`/`scope_violation` | **per event** — `fact` for an excluded target, `inferred` for a proximity match | diamond, stroke set by `data.authority` |
+
+**Correction (2026-08-14, found while implementing K1).** An earlier revision of
+this table filed `scope_violation` under detector-derived. Two things were wrong:
+it is a **`system` subtype**, not an agent_type, and its authority is **not fixed
+by type** — hitting an explicitly excluded target is an observed rule match
+(`fact`), while hitting a neighbouring host is a proximity judgement
+(`inferred`). See `ALERT-ROLES.md` B.5.
+
+That single case is why authority resolution is **per-event first**: no
+type-level default can be right for a type that legitimately emits both. The
+type-level default (`EventTypeDef.authority`, and the built-in table in
+`core/authority.ts`) is the fallback, never the override.
+| **Plugin-contributed** | any of the above, via a plugin | inherits the class it emits into | **must be declared** via `eventTypes` |
+
+**Completeness:** an event can only be *authored* by the operator, *captured* from
+a medium, produced by the *agent*, *derived* by a detector, emitted by the
+*system*, or *contributed* by a plugin — six origins, no seventh. Every existing
+`agent_type` lands in exactly one.
+
+## The two axes
+
+- **Origin** (mechanism) → the class above; also sets **default authority**.
+- **Authority** (§3) → authoritative vs inferred, which **must** show as solid vs
+ dashed on the timeline. This is the same authority axis as `DETECTOR-ROLES.md`
+ and `TIMELINE-ELEMENTS.md` — one law, three surfaces.
+
+## Timeline identity contract (per `agent_type`)
+
+Declared by `EventTypeDef { agentType, label, lane?, color?, icon? }`
+(`event-registry.ts`), built-in or plugin-contributed. Maps onto the timeline
+channels:
+
+- `lane` → the **Lane** channel (should resolve under the target/phase axis, §8 —
+ see gap #3).
+- `color` → the **Colour** channel (source-type encoding — see gap #4).
+- `icon` → the **Glyph** channel.
+- ~~**missing** `authority`~~ → ✅ resolved per event, defaulting by type (gap #2)
+ (gap #2).
+
+## Gaps this framework surfaces
+
+| # | Gap | Fix |
+|---|---|---|
+| 1 | `eventTypes` is **optional** — a plugin can emit a new `agent_type` with no identity → "other" lane | make a new-emitting `capture`/`tailer` **require** a paired `eventTypes` (load-time warn/lint); surface undeclared kinds in Settings ▸ Plugins |
+| 2 | ~~`EventTypeDef` has no `authority` field~~ | ✅ **done (K1 slice).** `EventTypeDef.authority` + `EventTypeContribution.authority`; resolved by `core/authority.ts`, stamped into the hashed row by `insertEvent`, read by `lib/dotShape.ts` for the solid/dashed split. Promote-to-marker beyond phase is still open. |
+| 3 | `lane` is an **open string** — a plugin can invent a lane that doesn't map to the target/phase axis | reconcile the lane vocabulary with the `lanesForAxis` model (§8): a plugin lane is a *source* lane (a filter), never a new organizing axis |
+| 4 | `color` is per-`agent_type`, but the timeline binds **Colour ← source-type**; a plugin picking arbitrary colours collides with the palette | derive plugin colours from the source-type palette, or document the collision rule |
+| 5 | vocabulary is documented in `event-schema.md` but the **origin/authority classification** isn't formalised there | keep this doc as the classification; `event-schema.md` stays the field reference |
+
+## Cross-references
+
+- The capture roles that emit events: `PLUGIN-ROLES.md` (Parser, Tailer, Tee)
+- The channels an identity maps to: `TIMELINE-ELEMENTS.md`
+- The inferred-origin detectors: `DETECTOR-ROLES.md`
+- Built-in vs plugin capture line: `CAPTURE-SOURCE-TAXONOMY.md`
+- Field reference (source of truth): `event-schema.md`
+- Fact vs suggestion: `DESIGN-PRINCIPLES.md` §3
diff --git a/docs/OFF-CHAIN-CONTENT-STORES.md b/docs/OFF-CHAIN-CONTENT-STORES.md
new file mode 100644
index 00000000..9dd44c06
--- /dev/null
+++ b/docs/OFF-CHAIN-CONTENT-STORES.md
@@ -0,0 +1,105 @@
+# Off-Chain Content Stores — one pattern, two shapes
+
+Written 2026-08-13. Applies `DECOMPOSITION-METHOD.md` to the heavy content RedLog
+keeps *off* the hash chain. Today this is **four stores** across separate
+implementations — `.cast` recordings, agent transcripts, io_ref bodies,
+screenshots — with separate directories, **three live retention paths** (io_ref is
+spec-only, never implemented), and parallel reference conventions. This doc shows
+they are **one off-chain-content pattern in two shapes (Blob / Stream)**, unified
+by a single invariant and a single lifecycle engine (per the cross-session
+consensus + code audit with the transcript/log sessions, 2026-08-13).
+
+## The core invariant (from `SPEC-IO-SIDECAR.md`)
+
+**Bytes never enter the chain — only their `sha256`.** The event carries a
+reference + digest; the bytes live in an off-chain store; a pruned/absent object
+reads as **pruned, not tampered** (`redlog-verify` semantics). This is the same
+invariant already stated for the io sidecar — the decomposition just recognises it
+governs *all* heavy content, not only HTTP bodies.
+
+## The two roles (mechanism axis = content shape)
+
+```
+Is the content one opaque object (read whole or by byte-range)? → BLOB
+Is it a time-indexed sequence (read by time-slice)? → STREAM
+```
+
+## Master table
+
+| Store | Role | Addressing | Dir / layout | Reference on event | Read | Retention today |
+|---|---|---|---|---|---|---|
+| **io_ref bodies** | Blob | **content-addressed** | `io/.bin`, dedup by digest | `io.*` ref + `sha256` | whole or ranged (`io:read`, path-validated) | **spec-only — never implemented** (no runtime code; only `SPEC-IO-SIDECAR.md` et al.) |
+| **Screenshots** | Blob | **path-addressed** | `screenshots/${ts}_${trigger}.jpg`; `sha256` = integrity only, not location/dedup (`screenshot-agent.ts`) | `filename` (basename) + `sha256` | whole (image) | `screenshots.keepDays` → `screenshot_pruned` |
+| **Cast recordings** | Stream | path-addressed | `casts/` per session; integrity `castSha256` | `castPath` + `castSha256` | **time-slice** `readCastSlice(path, startMs, endMs)` (asciicast v2 JSON-lines) | `terminal.castKeepDays` → `cast_pruned` |
+| **Agent transcripts** | Stream | path-addressed | `agent-transcripts/${agentKind}-${sessionId}.jsonl`, append-only; integrity `cumulative_sha256` | `snapshot_path` + `cumulative_sha256` (`transcript_snapshot` event) | whole / tail | `agentTranscripts.keepDays` → `agent_transcript_pruned` |
+
+## The two classifying axes
+
+- **Shape** (mechanism) → **Blob** (opaque object) vs **Stream** (time-indexed
+ sequence). This is the role.
+- **Addressing** → **content-addressed** (dedup by digest, location = the hash) vs
+ **path-addressed** (per-session/per-capture name, digest is integrity only).
+ Independent of shape.
+
+**These two axes give three valid combinations, not two** (per the code audit,
+2026-08-13):
+
+| | content-addressed | path-addressed |
+|---|---|---|
+| **Blob** | io_ref bodies | screenshots |
+| **Stream** | — (a time sequence *is* its position; content-addressing is nonsensical) | `.cast`, agent transcripts |
+
+So the honest classification is **Blob-content / Blob-path / Stream-path** — see
+gap #3.
+
+## The uniform contract every off-chain store must satisfy
+
+The payoff — three ad-hoc stores collapse to one interface + two shape adapters:
+
+1. **Digest on chain, bytes off chain.** The event holds `ref` + `sha256`; the
+ store holds bytes. Verify checks bytes-match-digest; a missing object is
+ *pruned*, never *tampered*.
+2. **Referenced, not embedded.** The store resolves `ref → bytes` behind a
+ **path-traversal guard** (stay inside the store dir — `io:read` already does
+ this; the guard should be shared, not re-implemented per store).
+3. **One lifecycle engine.** hot → warm (compress) → pruned, **refcount-gated**,
+ **scope/marker as pin** — all from `SPEC-SCOPE-AWARE-LIFECYCLE.md`. Every store
+ plugs into *one* retention/rotation engine, not three keep-day knobs.
+4. **Shape-appropriate read.** Blob → whole or byte-range; Stream → time-slice.
+ Both **lazy** (never loaded to scroll the timeline; a preview/glyph stands in,
+ per `TIMELINE-ELEMENTS.md`).
+
+## Completeness
+
+Heavy content is either **one opaque object** (Blob) or a **time-indexed
+sequence** (Stream) — there is no third shape. Every current store maps to one
+role. Future heavy captures slot in by shape without a new subsystem:
+
+- **Blob:** pcap files, memory dumps, downloaded artefacts, binary loot.
+- **Stream:** screen-recording video, keystroke timelines, packet streams.
+
+## Gaps this framework surfaces
+
+| # | Gap | Fix |
+|---|---|---|
+| 1 | **io_ref is spec-only** — no runtime code exists (grep hits docs only); it is *never implemented*, not "wired but retention-less" | implement the Blob store, then wire its keep-window as the first store to prove the unified interface |
+| 2 | **Three parallel retention branches** — `config.terminal.castKeepDays` + `config.screenshots.keepDays` + `config.agentTranscripts.keepDays`, each a separate branch in `retention.ts` `sweepRetention()` | a `contentStore..keepDays` registry turns 3 branches into **one loop over registered stores** — a real code convergence, not just a doc one |
+| 3 | **Addressing is three-way, not two** — the audit found screenshots are path-addressed (filename = `${ts}_${trigger}`, `sha256` integrity only), not content-addressed | classify deliberately as **Blob-content / Blob-path / Stream-path** (Stream-content is nonsensical); document why each store sits where it does |
+| 4 | **`isInsideDir()` traversal guard duplicated in 3 places** (tailer sidecar, screenshot delete, terminal replay) | extract one shared traversal guard + one shared "bytes-match-digest / pruned≠tampered" verify, used by every adapter |
+| 5 | **Compression/pin not applied uniformly** | the warm-compress + scope-as-pin from the lifecycle spec should cover `.cast`, agent transcripts (both compress well) and screenshots, not just io bodies |
+| 6 | **Four stores, one missing from the taxonomy until now** — agent transcripts (`transcript_snapshot` + `cumulative_sha256`) is a Stream store parallel to `.cast` | route it through the same Stream adapter; it already has the `snapshot_path` + `cumulative_sha256` shape |
+
+## Note on formats
+
+The `.cast` on-disk format (asciicast v2 today; a v3 greenfield move is under
+discussion in the transcript session) is the **Stream adapter's concern**, not this
+layer's — the decomposition is about the store contract (digest, ref, lifecycle,
+read), not the byte format. See that session for the format decision.
+
+## Cross-references
+
+- The Blob store's detailed spec: `SPEC-IO-SIDECAR.md`
+- The lifecycle engine all stores share: `SPEC-SCOPE-AWARE-LIFECYCLE.md`
+- Which capture items produce which content: `CAPTURE-SOURCE-TAXONOMY.md`
+- How content surfaces on the timeline (glyph/preview, lazy): `TIMELINE-ELEMENTS.md`
+- The method: `DECOMPOSITION-METHOD.md`
diff --git a/docs/PLUGIN-ROLES.md b/docs/PLUGIN-ROLES.md
new file mode 100644
index 00000000..43a4e8f5
--- /dev/null
+++ b/docs/PLUGIN-ROLES.md
@@ -0,0 +1,266 @@
+# Plugin Roles — a categorization for unambiguous development
+
+Written 2026-08-13. The classification layer above `docs/plugin-development.md`
+(mechanics/trust) and `SPEC-AI-ERA-PLUGINS.md` (the four AI-era gaps). Its job:
+**a developer with a capture/detection need looks up one row, and knows exactly
+which contribution type, which trust tier, what event to emit, and what it costs
+locally — before writing a line.**
+
+## The one principle everything derives from
+
+**Built-in captures the *medium* (shell / HTTP / DNS / agent tool-call / screen /
+clipboard / process). A plugin performs a *role* over that medium.** So the first
+question is never "what tool is this" but "**what am I doing, to which layer**"
+— that answer is the role, and the role fixes the contribution and the tier.
+
+## Two classifying axes
+
+- **Axis 1 — what layer does it act on?** → picks the *role*.
+- **Axis 2 — does it run code *inside* RedLog?** → picks the *trust tier*
+ (🟢 data / out-of-process = trust-free; 🔴 in-process code = isolated,
+ capability-scoped, human-approved).
+
+## Decision tree (pick your role)
+
+```
+Are you teaching RedLog to *understand* a tool with only data/regex,
+ over events it already captures? → RECOGNIZER 🟢
+Turning a tool's *structured output file* into typed events? → PARSER 🟢
+Following an *external log/stream* continuously? → TAILER 🟢*
+Sitting *between two external parties*, forwarding+recording? → TEE 🟢
+*Annotating* already-captured events with a judgement? → LABELLER 🟢/🔴
+Giving an *AI agent* tools to operate RedLog? → TOOL PROVIDER 🔴
+Producing a *deliverable* from the evidence store? → EXPORTER 🔴†
+```
+`*` tailers: **bundled-only today** (user-plugin tailers need isolation work,
+`types.ts` L144). `†` exporters: contribution **reserved, not yet implemented**.
+
+## Master table
+
+| Role | Acts on | Contribution(s) | Tier | Emits | Local cost | AI-era instance |
+|---|---|---|---|---|---|---|
+| **Recognizer** | substrate's reading of captured events | `targetExtractors`, `commandTags`, `lootPatterns`, `redaction`, `eventTypes` | 🟢 data | tags/targets on existing events; no new event | per-event regex, negligible | recognize a new AI-tool's targets/fields |
+| **Parser** | a tool's completed output file | `capture` (+`eventTypes` +`targetExtractors`) | 🟢 | typed `scanner`/`scan_result` events | one-shot parse | **Gap 1** structured scan (nmap XML, nuclei JSON) |
+| **Tailer** | an external long-running log/stream | `tailers` | 🟢* | events per line/record | continuous file-follow | **Gap 2** C2 logs (Sliver, CS) |
+| **Tee** | a channel between two external parties | `capture` (out-of-process proxy) | 🟢 | `agent`/`tool_call` events | one hop per call | **Gap 3** third-party MCP tee |
+| **Labeller** | already-captured event stream | `detectionPatterns` (🟢, proposed) / `monitors` (🔴, reserved) | 🟢/🔴 | inferred `detection` suggestions (§3) | regex per-event / LLM call | **Gap 4** injection / hijack labels |
+| **Tool provider** | RedLog itself (as MCP server) | `mcpTools` | 🔴 | tools the agent calls; events attributed to operator | RPC | RedLog-as-tool for the operator's AI |
+| **Exporter** | the evidence store | `exporters` | 🔴† | a deliverable artifact | store scan | scope-sanitized bundle (see lifecycle spec) |
+
+## Completeness — every contribution maps to exactly one role
+
+The roles are cut along the **contribution mechanism**, not along tools, so they
+are provably exhaustive over the plugin API. Every slot in `PluginContributes`
+(`types.ts` L130–146) maps to a role:
+
+| Contribution (`PluginContributes`) | Role | Tier |
+|---|---|---|
+| `targetExtractors` | Recognizer | 🟢 |
+| `commandTags` | Recognizer | 🟢 |
+| `lootPatterns` | Recognizer | 🟢 |
+| `redaction` | Recognizer | 🟢 |
+| `eventTypes` | Recognizer (also a Parser helper) | 🟢 |
+| `capture` — parses a completed output | Parser | 🟢 |
+| `capture` — forwards in-band | Tee | 🟢 |
+| `tailers` | Tailer | 🟢* |
+| `monitors` *(reserved)* | Labeller (semantic) | 🔴 |
+| `detectionPatterns` *(proposed)* | Labeller (regex) | 🟢 |
+| `mcpTools` | Tool provider | 🔴 |
+| `exporters` *(reserved)* | Exporter | 🔴† |
+
+So **7 roles cover all 10 contribution slots** (the 5 Recognizer data-contributions
+collapse into one role; `capture` splits into Parser vs Tee by whether it parses a
+finished artifact or forwards a live channel). Anything a plugin can express today
+is one of these roles.
+
+**The boundary (stated honestly):** the roles are complete *over the contribution
+surface*, not over every imaginable need. A need that fits no contribution type is
+not a plugin yet — it is an **API gap**, and the known ones are listed at the
+bottom. When the surface grows, the new contribution gets a new role (or extends
+one) — the framework is complete-and-extensible, not frozen. Roles are also
+**for plugins only**: built-in substrate capture (the 15 medium items in
+`CAPTURE-SOURCE-TAXONOMY.md`) is deliberately *not* a role.
+
+## Cross-cutting rule — emitting roles must claim a timeline identity
+
+The event-producing roles (**Parser, Tailer, Tee**, and a Labeller's detections)
+emit events with an `agent_type`. **Each must pair with an `eventTypes`
+contribution** so the event claims a timeline identity (lane / colour / glyph) and
+its §3 authority — otherwise the timeline drops it into the generic "other" lane
+and the plugin's evidence is illegible on the reconstruction surface. "Capture"
+and "how it looks on the timeline" are one decision. See `EVENT-TYPE-VOCABULARY.md`.
+
+---
+
+## Per-role reference (development template)
+
+Each role below follows the same template — **Layer · Contribution · Tier & why ·
+Event contract · Local cost · Install · Test seam · Instance** — so building a new
+plugin is filling in a known shape.
+
+### 1. Recognizer 🟢 — teach the substrate, with data only
+
+- **Layer:** the interpretation of events the substrate already captured (a shell
+ command, an HTTP flow). Adds no new capture.
+- **Contribution:** `targetExtractors` (pull a host from a bespoke tool's cmd),
+ `commandTags` (stamp fields — MITRE, tool-category), `lootPatterns` (new secret
+ format), `redaction` (allow/deny), `eventTypes` (give an `agent_type` a lane,
+ colour, icon). Exact shapes: `types.ts` L45–98.
+- **Tier & why:** 🟢 — pure data the app reads; loads automatically; cannot touch
+ the chain. Zero trust cost, so **the long tail lives here** — most "support tool
+ X" needs are a `targetExtractor` + `eventType`, nothing more.
+- **Event contract:** does not emit; annotates. `targetExtractors` stamp
+ `extractor_name` for audit traceability (`types.ts` L77).
+- **Local cost:** regex per matching event. Negligible.
+- **Install:** none — declarative, active on enable.
+- **Test seam:** the pattern set is data; test the extractor/tagger as a pure
+ match over sample commands.
+- **AI-era instance:** recognizing a new AI-agent CLI's target/host argument, or
+ tagging its subcommands with ATT&CK techniques.
+
+### 2. Parser 🟢 — a tool's structured output → typed events
+
+- **Layer:** a tool's *completed* structured artifact (nmap `-oX`, nuclei
+ `-json`, httpx). The substrate already logged the *command*; the Parser turns
+ the *result file* into typed findings.
+- **Contribution:** `capture` (`CaptureContribution`, `types.ts` L100) that parses
+ and POSTs, **+** `eventTypes` for the timeline identity, **+**
+ `targetExtractors` for hosts/ports.
+- **Tier & why:** 🟢 — an out-of-process script that POSTs to the authenticated
+ local API like any client; unprivileged, can't touch the chain.
+- **Event contract:** one event per host/finding —
+ `agent_type: 'scanner'`, `subtype: 'scan_result'`, parsed fields (port,
+ service, template-id, severity). **Severity/loot are inferred suggestions
+ (§3)**, never authoritative.
+- **Local cost:** parse runs once at scan completion. Output files can be large,
+ but only parsed events enter the chain (raw file optionally io-sidecar'd).
+- **Install:** `installMethod: 'manual'` (wrap the tool invocation) or a Tailer
+ follow of the output directory. `manualSteps` carry the wrap command.
+- **Test seam:** the parser is a pure `parse(rawOutput) → Event[]`; test against
+ captured sample XML/JSON fixtures before it ever POSTs.
+- **AI-era instance:** **Gap 1** — a bundled `scan-parsers` pack.
+
+### 3. Tailer 🟢* — follow an external long-running channel
+
+- **Layer:** an external channel RedLog isn't sitting on — a C2 server's log/DB,
+ a scan-output directory being appended to.
+- **Contribution:** `tailers` — a module exporting a `TailerAdapter`
+ (`{ agentKind, transcriptGlob, … }`, `tailer-registry.ts`). The host wires it
+ into `tailer-host`.
+- **Tier & why:** 🟢, but **bundled-only today** — user-plugin tailers need the
+ isolation work slated for v0.8.3+ (`types.ts` L144). A tailer runs continuously
+ and reads an external file, so the eventual user-plugin path will be gated.
+- **Event contract:** one event per line/record — e.g. `agent_type: 'scanner'`
+ `subtype: 'c2_checkin'`, or `pivot` for a new route.
+- **Local cost:** continuous file-follow (inotify/poll) — low but standing; the
+ exposure window is the live tail, matching §2's "ambient = opt-in."
+- **Install:** config the log path per tool; `manualSteps` document where each
+ framework writes its log.
+- **Test seam:** the adapter's line→event mapping is pure; test over recorded log
+ fixtures.
+- **AI-era instance:** **Gap 2** — a bundled `c2-tailers` pack.
+
+### 4. Tee 🟢 — record a channel by sitting in-band
+
+- **Layer:** an in-band channel between two *external* parties — the operator's AI
+ client and a third-party MCP server (HexStrike, PentestMCP).
+- **Contribution:** `capture` — an out-of-process proxy the client points at,
+ which forwards to the real server **and** POSTs each call to RedLog.
+- **Tier & why:** 🟢 — the proxy is a separate process; it can't stall RedLog and
+ can't touch the chain (it POSTs like any client).
+- **Event contract:** `agent_type: 'agent'`, `subtype: 'tool_call'` /
+ `'tool_result'`, with tool name, input, and an output digest.
+- **Distinguish from Tool provider (🔴):** a Tee **captures calls the agent makes
+ to someone else's server**; a Tool provider **exposes RedLog's own tools to the
+ agent**. Opposite directions — keep them named apart or they conflate.
+- **Local cost:** one extra hop per tool-call — negligible interactively.
+- **Install:** `manual` — repoint the AI client's MCP server URL/command at the
+ tee. Note: if the client has a native tool-call hook (Claude Code
+ `PostToolUse`), those calls are **already captured** — a Tee is only for
+ hookless clients.
+- **Test seam:** the forward-and-tee logic is testable against a mock MCP server;
+ assert the emitted event equals the forwarded call.
+- **AI-era instance:** **Gap 3** — needs a `examples/plugins/mcp-tee` shim (no new
+ API, but no pattern exists yet).
+
+### 5. Labeller 🟢/🔴 — annotate captured events with a judgement
+
+- **Layer:** the *already-captured* `http`/`agent` event stream. Adds
+ interpretation, not capture.
+- **Contribution (two, by detectability):**
+ - **Regex/heuristic** (known payload markers, canary strings): 🟢 — a
+ `detectionPatterns` contribution (a **proposed** sibling of `lootPatterns`, or
+ a `kind` field on it: `lootPatterns` classifies secrets, this classifies
+ *behaviours*). Emits an inferred detection, not a secret.
+ - **Semantic** (paraphrased injection, multi-turn goal manipulation): 🔴 — a
+ `monitors` module that watches the event stream and emits inferred detections.
+ **`monitors` is reserved, not yet implemented** (`types.ts` L141) — this is
+ its first real use case.
+- **Tier & why:** regex is data (🟢); a semantic monitor runs code and reads the
+ evidence stream, which is exactly what the 🔴 trust gate exists for.
+- **Event contract:** an **inferred `detection` suggestion** (§3) —
+ confidence-scored, attributable to the detector, **visually distinct and
+ operator-promotable** (dashed vs solid, one-click promote). Never mutates the
+ source event; never authoritative.
+- **Local cost:** regex per-event (cheap); a semantic monitor may call an LLM
+ (latency/cost the operator opts into) — runs in the isolated utilityProcess so
+ it can't block capture.
+- **Install:** 🟢 pattern pack auto-loads; 🔴 monitor requires the trust gate.
+- **Test seam:** regex over labelled event fixtures (real / not); for the monitor,
+ a pure `classify(event) → detection|null` behind the LLM call.
+- **AI-era instance:** **Gap 4** — prompt-injection / tool-call-hijack labels.
+
+### 6. Tool provider 🔴 — expose RedLog to an AI agent
+
+- **Layer:** RedLog itself — the plugin makes RedLog an MCP server the operator's
+ agent can call.
+- **Contribution:** `mcpTools` (module path). Runs in the isolated utilityProcess,
+ reaches RedLog only through the capability-scoped `ctx` RPC
+ (`read:events`/`write:events`/`read:findings`/`read:config`/`net:outbound`,
+ `types.ts` L119).
+- **Tier & why:** 🔴 — it ships and runs code; gated by content-hash-pinned
+ consent (`trust.json`); changing code or requested capabilities revokes trust.
+- **Event contract:** tools the agent invokes; any events appended are attributed
+ to the plugin's operator (`write:events`).
+- **Local cost:** RPC per call; isolated so it can't stall the host.
+- **Install:** trust gate + point the agent at RedLog's MCP endpoint.
+- **Test seam:** each tool is a pure function of its args + a mocked `ctx`.
+- **AI-era instance:** RedLog-as-tool inside an operator's autonomous pentest agent.
+
+### 7. Exporter 🔴† — transform the store into a deliverable
+
+- **Layer:** the whole evidence store.
+- **Contribution:** `exporters` — **reserved, not yet implemented** (`types.ts`
+ L140).
+- **Tier & why:** 🔴 — reads the full store (including sensitive bodies) and
+ produces an external artifact; gated.
+- **Event contract:** appends a chained audit event for the export action.
+- **Instance:** a scope-sanitized client-deliverable exporter (see
+ `SPEC-SCOPE-AWARE-LIFECYCLE.md` Part B) — its scope planner is the natural first
+ Exporter.
+
+---
+
+## API gaps this framework surfaces (so future work is explicit)
+
+| # | Gap | Role blocked | Fix |
+|---|---|---|---|
+| 1 | `monitors` reserved, unimplemented | Labeller (semantic) | implement 🔴 `monitors`: isolated read of event stream + append of inferred detections |
+| 2 | no `detectionPatterns` contribution | Labeller (regex) | add sibling to `lootPatterns`, or a `kind: 'secret'\|'detection'` field |
+| 3 | inferred `detection` event type absent | Labeller output | define the §3 suggestion shape (confidence, detector attribution, promote) + its dashed-vs-solid rendering |
+| 4 | user-plugin `tailers` bundled-only | Tailer | land the v0.8.3+ isolation so user tailers can be trust-gated, not blocked |
+| 5 | no MCP-tee pattern/example | Tee | ship `examples/plugins/mcp-tee` + a stdio/HTTP-MCP shim |
+| 6 | `exporters` reserved, unimplemented | Exporter | implement 🔴 `exporters`, first instance = scope-sanitized bundle |
+
+**Build order (from `SPEC-AI-ERA-PLUGINS.md`):** Parser + Tailer packs first (zero
+core change, roles fully supported today), then Tee (shim + example), then
+Labeller (grows the core API — sequence with the §3 promotion UI). Exporter tracks
+the lifecycle spec.
+
+## Cross-references
+
+- Mechanics + trust gate: `docs/plugin-development.md`
+- The four AI-era gaps in detail: `SPEC-AI-ERA-PLUGINS.md`
+- Capture taxonomy + build/plugin line: `CAPTURE-SOURCE-TAXONOMY.md`
+- Facts vs inferred suggestions: `DESIGN-PRINCIPLES.md` §3
+- Trust-tier rationale: `redlog-plugin-system` memory
diff --git a/docs/PLUGIN_MARKETPLACE.md b/docs/PLUGIN_MARKETPLACE.md
deleted file mode 100644
index 16688b29..00000000
--- a/docs/PLUGIN_MARKETPLACE.md
+++ /dev/null
@@ -1,153 +0,0 @@
-# RedLog Plugin Marketplace — Design Spec (draft)
-
-Status: **shipped** since v0.6.68 (core) / v0.6.69 (UI). The threat model in §9 was corrected in v0.11.0 to describe what the code actually does — two of its claims described controls that were never built.
-
-## 1. Goal
-
-Today plugins are installed by dropping a directory into `~/.redlog/plugins//`. That works for the operator who wrote the plugin, but nothing else — no discoverability, no version updates, no way to distinguish "the RedLog team wrote this" from "a random archive I pulled off the internet." The marketplace is the answer to three concrete problems: (a) an operator can browse and install a curated set of loot patterns, capture integrations, and MCP tools without a shell, (b) declarative-tier plugins can auto-update so pattern lists stay current, and (c) privileged-tier plugins can be signed by a publisher key the operator has explicitly trusted, so a compromised or malicious archive can't silently obtain `write:events` or `net:outbound`. The existing manifest schema, trust store (`~/.redlog/plugins/trust.json`), and content-hash pinning already do the heavy lifting — the marketplace is a distribution and consent layer on top.
-
-## 2. Non-goals (v1)
-
-- No paid plugins, no billing, no license enforcement.
-- No user reviews, ratings, or comments — trivially poisoned, and irrelevant for an audit tool.
-- No cross-vendor identity or SSO — publishers are keypairs, nothing more.
-- No in-app plugin authoring or hot-reload UI (that stays in the dev guide).
-- No mobile / web client — desktop Electron only.
-- No telemetry on install counts back to the RedLog project.
-
-## 3. Distribution model — recommendation: **A. Git-repo-as-registry**
-
-Use a public GitHub repo (`redlog-plugins/registry`) as the index. It contains a single `index.json` listing plugin id, latest version, publisher key id, tarball URL, sha256, and category. Tarballs live wherever the publisher wants — GitHub Releases, S3, a personal server — as long as the sha256 matches. RedLog fetches `index.json` over HTTPS, verifies the tarball hash, and (for privileged plugins) the publisher signature.
-
-Why not the others: **NPM (B)** drags in a package manager and a namespace we don't control, and NPM's own supply-chain history is exactly what we're trying to avoid. **Self-hosted service (C)** means we run infrastructure, handle abuse reports, and become a single point of failure for a security tool. **Hybrid (D)** is where we end up eventually if the git index grows too large, but v1 doesn't need it.
-
-Git-as-registry gives us: free hosting, PR-based moderation with a full audit trail, easy forks for air-gapped shops, and signed commits from the RedLog maintainers as the root of trust for the index itself.
-
-## 4. Plugin package format
-
-A gzipped tarball (`.tar.gz`), because tar preserves file modes and macOS/Linux operators already have it. Structure:
-
-```
--.tar.gz
-└── /
- ├── plugin.json # manifest, required
- ├── README.md # rendered in the marketplace UI
- ├── LICENSE # optional but shown
- └── ... (contributed files)
-```
-
-Rules enforced during unpack:
-
-- Reject symlinks, hardlinks, absolute paths, and any entry containing `..`.
-- Reject entries outside the single top-level `/` directory.
-- Reject files with executable bits set except inside a `bin/` subdir (we don't spawn them anyway; this is belt-and-braces).
-- Size cap: 5 MiB uncompressed in v1. Bump later if a real use case appears.
-
-The tarball's sha256 is what the index pins. RedLog re-computes it before extraction and rejects a mismatch.
-
-## 5. Signing & trust flow
-
-Two tiers, two flows.
-
-**🟢 Declarative plugins** (loot patterns, redaction, tags, extractors, event types, capture) — signature optional. Install prompt shows a "Publisher: unverified" badge if unsigned, or "Publisher: `redlog-official` (trusted)" if signed by a key in the trust store. No capability prompt because these can't call anything.
-
-**🔴 Privileged plugins** (`mcpTools`, `exporters`, `monitors`, `tailers`) — signature required. The publisher signs the tarball's sha256 with an Ed25519 key; the public key + a human name lives in `~/.redlog/trusted-publishers.json`:
-
-> **`tailers` (v0.8.2)** — transcript adapters for AI coding agents. A tailer module exports `{ adapter }` where `adapter` implements the `TailerAdapter` interface from `src/main/services/tailer-host.ts`. **v0.8.2 restriction:** only bundled tailer plugins (shipped inside the RedLog resources dir) run; user-installed plugins that declare `tailers` produce an advisory in `~/.redlog/log` and are otherwise skipped. Third-party `tailers` require isolation work landing in v0.8.3+ — the current tailer-host would run plugin code in-process at line-rate against the transcript stream, which needs either a faster IPC path than `utilityProcess` or a per-plugin JS sandbox before we can safely open it up.
-
-```json
-{
- "redlog-official": { "pubkey": "ed25519:...", "addedAt": 1706... },
- "acme-redteam": { "pubkey": "ed25519:...", "addedAt": 1706... }
-}
-```
-
-First install of a plugin from a new publisher shows a two-step consent dialog:
-
-> **Trust publisher `acme-redteam`?**
-> Fingerprint: `ed25519:a1b2…c3d4`. Once trusted, you'll be prompted separately for each plugin's capabilities. You can revoke this publisher in Settings ▸ Plugins ▸ Publishers.
-> **[Trust publisher] [Cancel]**
-
-then
-
-> **Install `acme-exfil-monitor` v1.2.0?**
-> This plugin asks for: `read:events`, `net:outbound` (⚠ outbound network).
-> Content hash: `sha256:9f8c…`
-> **[Install] [Cancel]**
-
-The existing `trust.json` grant is written on approval, pinning both the content hash and the capability set — the enforcement path in `src/core/plugins/trust.ts` already handles this.
-
-**Update behaviour** — after a version bump:
-
-- Hash changed, capability set unchanged → silent upgrade, toast "acme-exfil-monitor updated 1.2.0 → 1.3.0".
-- Hash changed, capability set expanded → status flips to `needs-consent`, plugin stops loading its code, operator is re-prompted with the delta highlighted.
-- Signature invalid or publisher no longer trusted → status flips to `error`, no code runs.
-
-## 6. Update flow
-
-- Poll `index.json` on app launch (skipped if last poll < 6 h ago) and on every visit to Settings ▸ Plugins.
-- Declarative plugins: auto-update by default, togglable per-plugin.
-- Privileged plugins: never auto-update the *code* silently. New version → notification badge on the Plugins tab; operator must open it and click "Install update". Applies whether or not capabilities changed — the extra click is cheap and the operator is the audit trail.
-- Rollback: keep the previous version's directory at `~/.redlog/plugins//versions//`, retain the last three versions, and expose a "Rollback" button in the plugin detail panel. The trust grant for a rolled-back hash is re-usable without re-consent because the hash was previously approved.
-
-## 7. In-app UX
-
-Three new views under Settings ▸ Plugins:
-
-- **Marketplace tab** — search bar, category chips (Loot patterns, Capture, MCP tools, Redaction, Event types), plugin cards showing name, publisher badge, one-line description, `[Install]` button. Filters: "Signed only", "Publisher trusted", "Tier: privileged".
-- **Publisher page** — reached by clicking a publisher badge. Lists all plugins from that publisher, trust status (trusted / unverified / revoked), and a Revoke Publisher button.
-- **Plugin detail** — rendered README, requested capabilities with tooltips, install size, current content hash, version history with per-version content hash and "Rollback to this version" affordance, "Report abuse" link that opens a `mailto:` or the registry repo's issue tracker.
-
-The existing Plugins tab (installed list, enable/disable, per-plugin capability view) stays as it is; the marketplace is a sibling tab.
-
-## 8. Revocation
-
-The registry serves a `revocations.json` blob alongside `index.json`, signed by the RedLog project key (baked into the app binary at build time — same key that signs the release itself). Each entry: `{ pluginId, version, contentHash, reason, revokedAt }`.
-
-On every poll RedLog fetches the blob, verifies the signature, and:
-
-- If an installed plugin's contentHash is on the list → disable it immediately, set status to `error` with the reason, toast the operator.
-- Publisher-level revocation is also possible (whole `pubkey` marked revoked) — every plugin signed by that key flips to `needs-consent` or `error` on next load.
-
-Operator override exists (edit `revocations-override.json` by hand) because an air-gapped shop may need to keep running a plugin the upstream registry has yanked. Not exposed in the UI.
-
-## 9. Security threats
-
-1. **Typosquatting** (`acme-redteem` vs `acme-redteam`) — the marketplace UI shows publisher next to every plugin name and dims plugins whose publisher isn't in the trust store; PR review on the git registry is the primary defence for new registrations.
-2. **Publisher key compromise** — the operator untrusts the key in Settings ▸ Plugins ▸ Marketplace ▸ Publishers; every plugin signed by it stops verifying, and an update cannot install. `~/.redlog/plugins/revocations.json` is a **local blocklist the operator maintains** — RedLog never fetches it from the network, because without a root of trust a fetched revocation list is only as honest as whoever served it (and a compromised publisher will not revoke itself). *(Earlier revisions described automatic signed revocations. That mechanism was never built; the file and the UI tab are the manual blocklist they always were.)*
-3. **Capability escalation via update** — content-hash pinning + capability comparison in `trust.ts` already forces re-consent on any capability delta. The UX highlights added capabilities in red.
-4. **Malicious index** — **the index is untrusted and RedLog does not verify it.** There is no root key to check it against, and TLS only proves the bytes came from whoever holds the domain, which is exactly who an attacker would need to be. The index says *where to look*, never *what is safe*. The trust boundary is one step later: `installFromRegistry` verifies each tarball's Ed25519 signature against a key the **operator** pinned, and refuses a privileged plugin that has none. A hostile index can therefore waste your time and serve you a tarball, but cannot get code executed — unless you pin its key, which is why trusting a publisher is a per-publisher action showing the fingerprint you are expected to compare against that publisher's own channel. *(Earlier revisions of this document claimed unsigned index mutations were rejected. They never were; the claim was removed in v0.11.0 along with the one-click "trust all suggested publishers" button, which let whoever controlled the index pin their own key.)*
-5. **Network downgrade / MITM** — HTTPS-only, no `http://` fallback, and the tarball sha256 is pinned in the index so a modified tarball fails even if TLS is broken.
-6. **Malicious tarball contents** — the unpack rules in §4 prevent symlink escapes and path traversal; the plugin runs in the existing utility-process sandbox with capability-gated RPC (see `src/core/plugins/host.ts`), so even a hostile privileged plugin can't reach the DB handle or signing keys.
-
-## 10. Implementation phases
-
-**v1 — minimum shippable**
-
-- Registry repo skeleton + `index.json` schema + `revocations.json` schema.
-- In-app Marketplace tab: browse, search, install, uninstall, update.
-- Publisher trust store + first-install consent flow.
-- Ed25519 signing tooling (a small `redlog-sign` CLI in the repo).
-- Rollback to previous content hash.
-- Documentation update to `plugin-development.md` on how to publish.
-
-**v2 — nice-to-have**
-
-- Bulk "trust curated set" for new operators (one-click trust of `redlog-official`).
-- Publisher key rotation flow (old key signs an attestation for the new key).
-- Delta updates (bsdiff-style) for large plugins — probably unnecessary given the 5 MiB cap.
-- Air-gap export bundle: `redlog plugins export --all` produces a zip an operator can `redlog plugins import` on a disconnected machine.
-
-**v3 — far future**
-
-- Private/internal registries (point RedLog at a company-internal git repo).
-- Plugin dependency resolution (plugin A depends on plugin B's event types).
-- In-app publisher onboarding (currently: file a PR).
-
-## 11. Open questions
-
-1. **Root of trust distribution** — the RedLog project key is baked into the binary. What is the rotation story if that key is compromised? Ship a new binary is the honest answer; is that acceptable?
-2. **Publisher onboarding friction** — is "open a PR against the registry repo" enough gatekeeping, or do we need out-of-band identity verification for publishers who want to ship privileged plugins?
-3. **Capability granularity** — `net:outbound` is a single bit today. Do we need domain-scoped variants (`net:outbound:*.slack.com`) before opening the marketplace, or after evidence that plugins want it?
-4. **Registry scale** — at what plugin count does the single `index.json` become a problem? Rough cap before we split into category shards?
-5. **Event provenance** — a privileged plugin with `write:events` currently attributes its events to itself. Should the marketplace enforce a stricter provenance label (`plugin:@@`) in the event data so audit reviewers can filter plugin-authored events without operator config?
diff --git a/docs/PRODUCT-POSITIONING.md b/docs/PRODUCT-POSITIONING.md
index 5a24771e..517af226 100644
--- a/docs/PRODUCT-POSITIONING.md
+++ b/docs/PRODUCT-POSITIONING.md
@@ -1,6 +1,10 @@
# Product Positioning
-Written 2026-08-10 against v0.11.5. This is the single source of truth for
+Written 2026-08-10 against v0.11.5. **Revised 2026-08-21** — the core moved
+from *defensibility* to *knowability*; see `DESIGN-core-and-capture.md` for the
+review that produced the change and what it does to capture coverage.
+
+This is the single source of truth for
**who RedLog is for, what job it does, and where its edges are**. The one-line
positioning has lived in `ROADMAP.md` and `docs/README.md`; this page keeps
those in sync and adds the layer they never had — users, jobs, and the
@@ -9,32 +13,47 @@ the roadmap is the bug.
## One sentence
-**RedLog is the local, tamper-evident audit-log layer of a red-team
-engagement:** it passively records every operator and agent action into a
-per-project SHA-256 hash chain, anchored to OpenTimestamps and exportable as a
-signed evidence bundle a third party can verify.
+**RedLog is the local record of what actually happened in a red-team
+engagement:** it passively captures every command, connection, file and screen
+— whoever or whatever produced it — so that afterwards you can replay the
+engagement, hand a purple team something they can judge traffic from, and find
+evidence of the things you were not watching when they happened.
+
+If it happened, RedLog captured it. If the record shows a 20-minute gap, the
+record is *right* — the operator was idle, or a `recording_paused` row
+explains it. (The timeline may *draw* that gap compressed; the record still
+contains it. Rendering is a reading convenience, storage is not.)
-If it happened, RedLog captured it. If the timeline shows a 20-minute gap, the
-timeline is *right* — the operator was idle, or a `recording_paused` row
-explains it.
+The hash chain, OpenTimestamps anchoring and the signed bundle still run, and
+still let a third party verify the record end to end. They are a **property of
+how the record is kept**, not the reason to keep one.
## The job to be done
-> When I run an engagement, I need a complete, defensible record of what I did
-> and when — so I can prove I stayed in scope, reconstruct the attack timeline,
-> and hand a client or my own legal team something they can trust — **without
-> spending the engagement taking notes.**
+> When I run an engagement, I need a complete record of what was done and when
+> — so I can **write it up afterwards with the screenshots and the exact
+> commands**, reconstruct the attack timeline, show a purple team what my
+> traffic was, prove I stayed in scope, and find out what happened during the
+> stretches I wasn't watching — **without spending the engagement taking
+> notes.**
Two halves, and both are load-bearing:
-1. **Complete + defensible** — the record has to survive scrutiny. That is the
- hash chain, the OpenTimestamps anchor, the operator attribution, the signed
- bundle. This is what separates RedLog from a scratchpad.
+1. **Complete** — because you are relying on it to tell you things you do not
+ already know. An hour into a scan, three hundred agent turns in, or a
+ teammate's session on the shared VPS: the record is the only account of
+ what happened, so a hole in it is not an inconvenience, it is the failure.
+ Tamper-evidence falls out of how completeness is kept (an append-only hash
+ chain), and is worth having, but it is not the job.
2. **Without taking notes** — capture has to be passive. The moment logging
depends on the operator remembering to log, the record has holes exactly
where the interesting things happened. This is why hooks are the backbone
and MCP is only the control plane (see `agent-integration.md`).
+An AI agent is the sharpest case of the first half — you delegate, it does
+three hundred things, you know none of them — but it is **one producer among
+several**, not half the product. The same gap opens with no agent involved.
+
Everything RedLog does is in service of one of these two. A feature that serves
neither is scope creep.
@@ -66,12 +85,42 @@ the transcript tailer + shell hooks (passive, can't be forgotten) with MCP as an
explicit control plane. This persona is why "hooks log, MCP operates" is a hard
rule, not a preference.
-### S1 — The evidence consumer *(stakeholder, not a user)*
-
-The client, the client's counsel, or the operator's own QA. Never opens the app.
-Receives the signed bundle and runs `ots verify` / `redlog-verify.py`. Every
-positioning claim ultimately answers to this person: **can they check it
-themselves?** That is the v1.0 gate.
+## Who consumes the output
+
+The everyday consumer of RedLog's output is **the operator themselves**, and
+that is not a fourth persona — it is P1, P2 or P3 a day later, writing the
+engagement up.
+
+This is the phase the whole record exists to serve. Writing a report means
+going back for **the screenshot of that finding** and **the exact command,
+with its output, its timestamp and its target** — in a form that can be
+pasted into a document. RedLog does not write the report (see the non-goals
+below; that stays downstream), but everything it captures is judged by
+whether it survives that trip.
+
+Two consequences worth stating, because they are easy to under-weight:
+
+- **Screenshots are report material, not incidental artefacts.** They need to
+ be findable by what they show and when they were taken, and they need to
+ come out at usable fidelity.
+- **A command record is only as useful as its output.** "I ran `nmap -sV`" is
+ not evidence of anything; the command plus what came back is. This is what
+ makes the output-capture gaps (§2.3 Windows, §2.4 tool output) matter more
+ than their size suggests.
+
+### Secondary consumers *(stakeholders, not users)*
+
+- **The purple / blue team** — the client's defenders, correlating what they
+ saw against what the red team actually did. They receive an activity record
+ (time, source, target, command, HTTP exchange, across every capture source)
+ and match it against their own telemetry — the exported record, after the
+ action. (RedLog no longer ships a live deconfliction feed; live "was that
+ us?" is out of scope after the 2026-08-22 core revision.)
+- **The evidence consumer** — the client's counsel, or the operator's own QA,
+ when a record is challenged. Receives the signed bundle and runs
+ `ots verify` / `redlog-verify.py`. Fully supported, and what the hash chain
+ is *for* — but the exception, not the design centre, and it should not shape
+ the everyday UI.
## What RedLog is NOT
@@ -89,8 +138,10 @@ RedLog integrates with that job rather than competing for it.
## Where it sits in the landscape
-RedLog occupies a corner none of the established tools do: **local-first +
-passive capture + tamper-evidence + AI-agent-native**. The comparison isn't
+RedLog occupies a corner none of the established tools do: **capture at the
+point of work, locally, readable afterwards**. Tamper-evidence and
+agent-native capture are both real and both differentiating — they are just
+properties of that corner rather than the definition of it. The comparison isn't
"which is better" — most of these are complementary and sit downstream of
RedLog.
diff --git a/docs/README.md b/docs/README.md
index 0822bac7..626b9140 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -19,6 +19,7 @@ Wiki-style index for **v0.11.7**. Every page is self-contained; follow the links
- **[Roadmap](ROADMAP.md)** — what ships next and the v1.0 gate. Also states what is deliberately not planned.
- **[Audit 2026-08-08](AUDIT-2026-08-08.md)** — standing defect list from a full-tree review: correctness, trust-model gaps, presentation, test coverage, doc drift. Each item tagged verified/reported.
- **[UX & complexity audit 2026-08-10](UX-AUDIT-2026-08.md)** — persona-driven review of the renderer: where breadth has outrun the solo operator (Timeline 3,875 lines / Settings 2,681 lines), the first-run friction gap, and a prioritized backlog. Companion to the correctness audit above.
+- **[Core purpose and capture coverage](DESIGN-core-and-capture.md)** — the 2026-08-21 review that moved the core from *defensibility* to *knowability*, and what that does to capture: four gaps (non-HTTP traffic, remote commands, Windows output, queryable tool output), the point/span timeline model, and the purple-team activity record. Read alongside `PRODUCT-POSITIONING.md`, which it revised.
- **[UI/UX 標準](UIUX-STANDARD.md)** — the rule book UI code lands against, extracted from `src/renderer` itself: colour tokens, type scale, density, the component contract, UX principles, Timeline/HUD rules, and the three-phase checklist the PR templates in `.github/pull_request_template/` check against. Written and maintained in the Claude Design project — see [design-project-sync.md](design-project-sync.md) for the screen-to-source map and the last sync.
- **[Timeline UX deep-dive 2026-08-10](UX-TIMELINE-2026-08.md)** — the F2 deep-dive: why the timeline feels unintuitive (invisible, overloaded, context-dependent gestures), what it is *not* (lanes/data are fine), and the T1–T6 simplification plan.
- **[UX backlog — ticket specs](UX-BACKLOG-TICKETS.md)** — every finding (F1–F7, T1–T6) broken out into an independently implementable ticket: problem, proposed solution, acceptance criteria, pure test seam, effort, priority order.
@@ -26,17 +27,32 @@ Wiki-style index for **v0.11.7**. Every page is self-contained; follow the links
- **[Dev requirements — capture onboarding](DEV-REQUIREMENTS-capture-onboarding.md)** — spec + acceptance criteria for the Capture Readiness onboarding, and the red→green→integrate→cover TDD process every UX change should copy.
- **[Timeline I/O visibility](timeline-io-visibility.md)** — design note (proposed). Which sources capture input/output today, where the gaps are, and the `io_ref` sidecar + transcript-view proposal that closes them.
+## Subsystem decomposition (salvaged from PR #8)
+
+A framework and eleven applications of it, written 2026-08-11 → 08-15 and never
+merged — while code that cites them shipped anyway (`src/core/alert/` names
+`ALERT-ROLES.md` three times). Recovered as documentation only. Each page turns
+an open-ended subsystem into a small, provably-complete set of roles with a
+per-item template, so "add another one" is filling in a row rather than a fresh
+design.
+
+- **[Method](DECOMPOSITION-METHOD.md)** — how a subsystem gets decomposed, and the test for "provably complete".
+- **[Backlog](DECOMPOSITION-BACKLOG.md)** — the gaps the eleven decompositions named, still open.
+- **[Design principles](DESIGN-PRINCIPLES.md)** — the durable laws. **§1 is superseded** by `DESIGN-core-and-capture.md`; the header says how.
+- **[Alert roles](ALERT-ROLES.md)** — self alarm vs target alarm, and the authority tiers that stop an inference reading as a fact. Cited by `src/core/alert/`.
+- **[Capture source taxonomy](CAPTURE-SOURCE-TAXONOMY.md)** · **[Detector roles](DETECTOR-ROLES.md)** · **[Plugin roles](PLUGIN-ROLES.md)** · **[Event type vocabulary](EVENT-TYPE-VOCABULARY.md)** — the four member catalogues.
+- **[Control plane faces](CONTROL-PLANE-FACES.md)** · **[Delivery targets](DELIVERY-TARGETS.md)** · **[Off-chain content stores](OFF-CHAIN-CONTENT-STORES.md)** · **[Timeline elements](TIMELINE-ELEMENTS.md)** — the four surface catalogues.
+- **[I/O sidecar](SPEC-IO-SIDECAR.md)** · **[Scope-aware lifecycle](SPEC-SCOPE-AWARE-LIFECYCLE.md)** · **[Timeline axis](SPEC-TIMELINE-AXIS.md)** · **[AI-era plugins](SPEC-AI-ERA-PLUGINS.md)** — four specs. Unbuilt; read as proposals, not as descriptions of the code.
+- **[Testing](TESTING.md)** — the test strategy the suite assumes: what belongs in a unit test, what needs the app in the loop, and why.
+
## Integrations
-- **[Agent integration](agent-integration.md)** — the full surface: the two-plane model (hooks *log*, MCP *operates*), terminal hooks, agent transcript tailer (primary Claude Code capture), watch paths (whitelist), the app-hosted MCP server (18 tools, HTTP + stdio), REST API, shell helpers, Codex function schema, the proxied browser, config profile sharing.
+- **[Agent integration](agent-integration.md)** — the full surface: the capture model (hooks *log*), terminal hooks, agent transcript tailer (primary Claude Code capture), watch paths (whitelist), REST API, shell helpers, Codex function schema, the proxied browser, config profile sharing.
- **[REST API reference](api-reference.md)** — complete per-endpoint docs for all 37 HTTP endpoints: auth, request/response schemas, auto-processing behavior.
-- **[MCP server](agent-integration.md#2-mcp-server-operate-the-app)** — RedLog hosts its own MCP endpoint over HTTP, live the moment the app opens (`http://127.0.0.1:/mcp`); stdio bridge as a fallback. Set up in Settings ▸ Team & Integrations.
- **[Proxied browser](agent-integration.md#proxied-browser)** — one-click Chromium through your mitmproxy, CDP enabled, project-local profile.
-- **[Deconfliction webhook](deconfliction.md)** — real-time signed feed to the blue team. When to enable, when not to, payload shape, threat model.
## Operator identity
-- **[Operators & tokens](operators.md)** — how per-operator attribution works, secondary operators for teammates or agent contexts (the MCP setup mints a dedicated non-rotating `mcp-agent` token), rotate/revoke lifecycle, threat model.
## Capture surface
@@ -86,9 +102,7 @@ Drift-signals that make the log honest: `recording_paused` / `recording_resumed`
## Related source
-- REST + HTTP MCP server: [`src/core/api-server.ts`](../src/core/api-server.ts)
-- MCP tool registry + JSON-RPC handler: [`src/core/mcp-tools.ts`](../src/core/mcp-tools.ts)
-- stdio MCP bridge: [`mcp/redlog-mcp-server.js`](../mcp/redlog-mcp-server.js)
+- REST API server: [`src/core/api-server.ts`](../src/core/api-server.ts)
- Hooks: [`hooks/`](../hooks/) and [`shell/`](../shell/)
- Anchoring: [`src/core/chain-anchor.ts`](../src/core/chain-anchor.ts)
- Operator model: [`src/core/db/operators.ts`](../src/core/db/operators.ts)
diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md
index e4259bea..0377871a 100644
--- a/docs/RELEASE_CHECKLIST.md
+++ b/docs/RELEASE_CHECKLIST.md
@@ -166,11 +166,6 @@ Everything below still needs hands on the app.
- [ ] Switch language via Settings → both languages render without missing key `[foo.bar]`
- [ ] No CJK mojibake in either lang
-## 16. Deconfliction
-
-- [ ] Webhook config accepts URL
-- [ ] `redlog-cli deconfliction test` posts a probe
-
## 17. Cross-cutting
- [~] Recording pause: no new events added while paused, resume works —
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index fa09dbca..45c0d7e9 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -96,7 +96,6 @@ privileged path that skips the gate:
| Item | Ref |
|---|---|
| `tailers`: isolate like `mcpTools`, or make first-party-only explicit and move it out of `PRIVILEGED_KEYS` | P1-3 |
-| Registry index signing — implement it or delete the claim; replace "Trust all suggested" with per-publisher confirmation; drop the unregistered default registry URL | P1-4 |
| Fetch + verify signed revocations, or remove the tab and the doc section | P1-5 |
| `vps-deploy.sh` refuses the primary token; prints the `operators add` command | P1-6 |
| Content hash covers `capture[].hookFile` | P2-1 |
@@ -155,6 +154,14 @@ work as **V4** below and, once done, deletes the wheel-mode branch behind T2.
## v1.0 gate
+> **Needs review after the 2026-08-21 core revision.** The gate below is
+> written entirely around a third party verifying a bundle, which was the old
+> design centre. Under the revised core (`DESIGN-core-and-capture.md`) the
+> everyday job is the operator finding out what happened, so the gate is
+> arguably missing its own criteria — capture coverage and findability. The
+> five items below are still correct and still blockers; the question is
+> whether they are *sufficient*. Not rewritten unilaterally.
+
1.0 means an operator can hand a bundle to a third party and every claim in
the docs is one they can check. Concretely:
diff --git a/docs/SPEC-AI-ERA-PLUGINS.md b/docs/SPEC-AI-ERA-PLUGINS.md
new file mode 100644
index 00000000..3a33b83f
--- /dev/null
+++ b/docs/SPEC-AI-ERA-PLUGINS.md
@@ -0,0 +1,139 @@
+# Spec — AI-Era Capture Gaps as Plugins (blueprint)
+
+Written 2026-08-13. Companion to `CAPTURE-SOURCE-TAXONOMY.md`, which concluded:
+the 15 substrate capture items ship built-in; everything tool-specific is the
+long tail → plugin. This blueprint takes the four AI-era gaps that survey named
+and maps each onto RedLog's existing plugin contribution surface
+(`src/core/plugins/types.ts`), stating for each: **which contribution type,
+which trust tier, whether it is expressible today or needs an API extension,
+the local-execution cost, and the install/config path.**
+
+## Contribution surface (what exists to build on)
+
+🟢 declarative (trust-free, data or out-of-process, can't touch the chain):
+`lootPatterns`, `redaction`, `commandTags`, `targetExtractors`, `eventTypes`,
+`capture` (hook that POSTs to the local API), `tailers` (long-running file/stream
+follow; **user-plugin tailers are trust-gated**, bundled are free — `types.ts`
+L144).
+
+🔴 privileged (isolated utilityProcess, capability-scoped `ctx`, hash-pinned
+consent): `mcpTools`; **`monitors` and `exporters` are declared but reserved —
+not yet implemented.**
+
+## Gap 1 — Structured scan output (nmap XML, nuclei JSON, httpx, …)
+
+- **Today the shell substrate captures the *command* and raw stdout; the
+ structured artifact (XML/JSON) is not parsed into typed findings.**
+- **Contribution:** `capture` (parse-on-complete) **+** `eventTypes` (timeline
+ identity for `scan_result`) **+** `targetExtractors` (hosts/ports → targets)
+ **+** optional `lootPatterns` (secrets in banners). All 🟢.
+- **Interface:** a `capture` script wraps or post-processes the tool
+ (`nmap -oX -`, `nuclei -json`), reads the structured output, and POSTs one
+ event per host/finding with `agent_type: 'scanner'`, `subtype: 'scan_result'`,
+ and the parsed fields (port, service, template-id, severity). Severity/loot are
+ **inferred suggestions** (§3), never authoritative.
+- **Expressible today:** ✅ no API change.
+- **Local cost:** parse runs once at scan completion — cheap; output files can be
+ large but only the parsed events enter the chain (raw file optionally io-sidecar'd).
+- **Install:** `installMethod: manual` (wrap the tool invocation) or a `tailers`
+ follow of the scan-output directory. Ships as a bundled `scan-parsers` pack.
+
+## Gap 2 — C2 framework logs (Sliver, Cobalt Strike, Mythic)
+
+- **C2 beacons are an out-of-band channel RedLog isn't sitting on** — the
+ substrate items don't see them. Beacon check-ins, task results, and operator
+ commands live in the C2 server's own log/DB.
+- **Contribution:** `tailers` — follow the C2 framework's log file (Sliver's
+ JSON log, CS Aggressor/beacon logs), emit `agent_type: 'scanner'` /
+ `subtype: 'c2_checkin'` / `pivot` events per line. 🟢, but **bundled-only
+ today** — user-plugin tailers need the isolation work slated for v0.8.3+
+ (`types.ts` L144); a bundled `c2-tailers` pack ships now, user-authored C2
+ tailers wait on that.
+- **Interface:** the `TailerLike` adapter (`tailer-registry.ts`) — the plugin
+ supplies a module that tails a path and yields parsed events; the host wires it
+ to `tailer-host`. Config the log path per C2 tool.
+- **Expressible today:** ✅ via `tailers`; the only friction is the user-plugin
+ trust gate (by design).
+- **Local cost:** file-follow (inotify/poll) — low, continuous; the exposure
+ window is the standing tail, matching the §2 "ambient = opt-in" rule.
+- **Install:** point the tailer at the C2 log path; `manualSteps` document where
+ each framework writes its log. Ships as a bundled `c2-tailers` pack.
+
+## Gap 3 — Third-party MCP tool-calls (HexStrike, PentestMCP, …)
+
+- **Nuance:** when the operator's AI client has a native hook (Claude Code's
+ `PostToolUse`), MCP tool-calls are **already captured** as `agent` events —
+ *no gap*. The gap is only clients **without** a hook.
+- **Contribution:** a **MCP tee/proxy** the client points at, which forwards to
+ the real MCP server *and* POSTs each `tool_call`/`tool_result` to RedLog — a 🟢
+ `capture` (out-of-process, unprivileged). Emits `agent_type: 'agent'` /
+ `subtype: 'tool_call'` with the tool name, input, and output digest.
+- **Interface gap:** no new contribution *type*, but there is **no documented
+ MCP-proxy pattern or helper today** — this needs a first-class example
+ (`examples/plugins/mcp-tee`) and a thin stdio/HTTP-MCP shim the operator drops
+ between client and server. The shim is the deliverable, not a new API.
+- **Distinguish from `mcpTools` (🔴):** `mcpTools` = RedLog *exposes tools to*
+ an agent (RedLog is the server). This gap is the inverse — capturing calls the
+ agent makes to *someone else's* MCP server (RedLog is a passive tee). Keep them
+ named apart in docs or they conflate.
+- **Expressible today:** ⚠️ mostly — needs the proxy shim + example, no core change.
+- **Local cost:** one extra hop per tool-call through the tee — negligible for
+ interactive use; the tee is out-of-process so it can't stall RedLog.
+- **Install:** `manual` — reconfigure the AI client's MCP server URL/command to
+ point at the tee. `manualSteps` per client.
+
+## Gap 4 — Injection / tool-call-hijack semantic labels
+
+- **When the operator tests an AI *target*** (OWASP LLM/Agentic Top 10, MITRE
+ ATLAS), the exchange is already captured as `http` (API calls) + `agent`
+ (attacker transcript). What's missing is the **labelling** — "this request was
+ a prompt-injection attempt," "this response shows tool-call hijack."
+- **Two-part answer, by detectability:**
+ - **Regex/heuristic subset** (known payload markers, canary strings): 🟢 a
+ pattern contribution in the `lootPatterns` mould, but emitting a
+ **`detection` suggestion** (inferred, confidence-scored, operator-promotable
+ per §3), not a loot/secret. Today `lootPatterns` only classifies secrets —
+ this needs a sibling `detectionPatterns` contribution or a `kind` field on
+ the existing one.
+ - **Semantic detection** (paraphrased injection, multi-turn goal manipulation):
+ a 🔴 **`monitor`** — watches the `http`/`agent` event stream and emits
+ inferred `detection` events. **`monitors` is reserved but not implemented**
+ (`types.ts` L141) — this gap is its first real use case.
+- **Interface gap:** ⛔ needs (a) `monitors` implemented as a 🔴 contribution
+ (isolated, capability-scoped read of the event stream + append of inferred
+ detections), and (b) an inferred-`detection` event shape that is visually
+ distinct and operator-promotable (§3 — dashed vs solid, one-click promote).
+- **Local cost:** regex subset is per-event and cheap; a semantic monitor may
+ call an LLM (cost/latency the operator opts into) — runs in the isolated
+ utilityProcess so it can't block capture.
+- **Install:** 🟢 pattern pack loads automatically; 🔴 monitor requires the trust
+ gate (it runs code and reads the evidence stream — exactly what the gate is for).
+
+## Summary — build order
+
+| Gap | Contribution | Tier | Ready today? | Blocker |
+|---|---|---|---|---|
+| 1 Structured scan | `capture`+`eventTypes`+`targetExtractors` | 🟢 | ✅ | none — ship `scan-parsers` pack |
+| 2 C2 logs | `tailers` | 🟢 (gated) | ✅ | none — ship `c2-tailers` pack |
+| 3 3rd-party MCP | `capture` (MCP tee) | 🟢 | ⚠️ | needs proxy shim + `examples/plugins/mcp-tee` |
+| 4 Injection labels | `detectionPatterns` (🟢) + `monitors` (🔴) | both | ⛔ | implement `monitors`; add inferred-`detection` event shape |
+
+**Sequence:** Gaps 1 & 2 first (pure packs, zero core change, immediate value).
+Gap 3 next (a shim + example). Gap 4 last — it is the only one that grows the
+core plugin API (`monitors` + the inferred-detection event type), and it should
+land alongside the §3 promotion UI so a detection suggestion has somewhere to go.
+
+## Non-goals
+
+- No gap turns into a built-in capture source — the substrate already captures
+ the medium (shell/HTTP/agent); these are *parsers, tailers, tees, and
+ labellers* over captured evidence, correctly plugin-scoped.
+- `monitors` must not become a back door to unlabelled authoritative assertions —
+ everything a monitor emits is an inferred suggestion (§3), never fact.
+
+## Cross-references
+
+- Capture taxonomy + build/plugin line: `CAPTURE-SOURCE-TAXONOMY.md`
+- Trust tiers + contribution mechanics: `docs/plugin-development.md`,
+ `redlog-plugin-system` memory
+- Two-tier facts vs suggestions: `DESIGN-PRINCIPLES.md` §3
diff --git a/docs/SPEC-IO-SIDECAR.md b/docs/SPEC-IO-SIDECAR.md
new file mode 100644
index 00000000..289f9344
--- /dev/null
+++ b/docs/SPEC-IO-SIDECAR.md
@@ -0,0 +1,163 @@
+# Spec — io_ref Sidecar for Full Request/Response Bodies
+
+> **✅ Implemented 2026-08-13.** All five build-order steps landed:
+> `src/core/io-store.ts` (`putBody`/`readBody`/`stampIoRefs`/`verifyBody`, 19
+> unit tests); sidecaring at `POST /api/events` (option B) + the mitmproxy addon
+> posting `*_body_full` when the preview truncates; the `io:read` IPC
+> (`ref`-only, so no arbitrary-path read); `ScannerDetail`'s "load full body"
+> affordance; and bundle-export copy + retention `system.io_pruned` +
+> `redlog-verify.py` re-hash (pruned ≠ tampered). Read cap `MAX_IO_READ_BYTES`
+> = 8 MB; a body over the read cap or pruned is reported, never dumped as raw
+> bytes. Retention knob: `config.io.keepDays` (default 0 = keep forever).
+
+Written 2026-08-12. The last unshipped piece of the v0.10.0 I/O-visibility work
+(ROADMAP: "shipped as v0.11.2 except the sidecar"): store **full** captured HTTP
+request/response bodies (and any other large capture payload) in a prunable
+on-disk sidecar, keeping only their `sha256` in the hash chain. Today mitmproxy
+bodies are inline, truncated previews (`request_body_preview` / `response_preview`,
+capped at `REDLOG_MAX_BODY`, raised to 16 KB in b986441). This closes the "the
+body was bigger than the cap, so you can't see what actually went over the wire"
+gap without bloating the events table or the chain.
+
+## Why (the problem)
+
+1. **Truncation loses evidence.** A 40 KB JSON response or a multipart upload is
+ the thing a reviewer most wants, and it's exactly what the inline cap drops.
+2. **Inline bodies bloat the chain.** Every byte of an inline preview is hashed
+ into the append-only chain and re-walked on every full verify. Bodies are
+ large, frequent (every request/response), and — unlike a marker — not the
+ operator's assertion; they don't belong in the chain, only their digest does.
+3. **The pattern already exists.** Terminal stdout is NOT stored in the chain —
+ the `.cast` lives on disk and the event carries a reference
+ (`{stream:'cast', ref, off, len, truncated}`), sliced on demand by
+ `cast-slice.ts` (`src/core/api-server.ts` ~L409). The io_ref sidecar is the
+ same idea, generalized to HTTP (and future) bodies.
+
+## Core invariant (unchanged)
+
+**Bytes never enter the chain — only their `sha256`.** The v0.6.47 revert stands
+(`ROADMAP.md`). A body's digest is chained; the bytes live in the sidecar. This
+keeps `redlog-verify` and OpenTimestamps meaningful (they attest the digest, and
+the sidecar bytes are checkable against it) while the chain stays small.
+
+## The sidecar
+
+- **Location:** `/io/` (peer of `screenshots/`, `terminal/`).
+- **Append-only, content-addressed:** a body is written once to
+ `io/.bin` (dedup by digest — identical bodies stored once). No edits,
+ no deletes except retention pruning.
+- **Ref shape on the event** (mirrors the io.stdout shape from v0.9.6):
+ ```jsonc
+ "io": {
+ "request": { "ref": "", "len": 40213, "sha256": "", "ct": "application/json", "truncated": false },
+ "response": { "ref": "", "len": 128932, "sha256": "", "ct": "text/html", "truncated": false }
+ }
+ ```
+ `ref` is the sidecar filename stem; `len` the full byte length; `truncated`
+ true only if capture itself hit a hard ceiling (see limits). The short inline
+ `*_preview` fields stay for at-a-glance display and offline bundles; the sidecar
+ holds the full body.
+
+## Capture path (who writes the sidecar)
+
+Two options; **(B) is recommended** — it keeps the digest authoritative and the
+addon dumb.
+
+- **(A) Addon writes the file.** The mitmproxy addon writes `io/.bin`
+ directly and posts the ref. Rejected: the addon runs under a HOME that may not
+ be the project dir; it would need the project path, and a capture source
+ writing into the evidence store is a wider trust surface.
+- **(B) Addon posts the full body; the server sidecars it.** *(recommended)* The
+ addon posts the full body (up to a generous `REDLOG_MAX_IO` ceiling, e.g.
+ 2 MB) on a new field; `POST /api/events` computes the `sha256`, writes
+ `io/.bin` if absent, replaces the body with the `io.*` ref before the
+ event is chained, and keeps the short `*_preview` inline. The server is already
+ the single DB-write chokepoint (pause gate, `noteDbError`), so this is where
+ the sidecar write belongs. Bodies above the post ceiling are marked
+ `truncated:true` and only the preview is kept.
+
+## Read path
+
+- **`io:read` IPC** (already named in ROADMAP): `io:read(ref, off?, len?) →
+ bytes`, validated to stay within `/io/` (same path-validation
+ discipline as screenshot reads). Range args mirror the cast slice API.
+- **ScannerDetail** lazy-loads: shows the inline preview immediately, and when a
+ body is sidecar-backed and larger than the preview, a "load full body
+ (`len`)" affordance calls `io:read`. Binary/oversize bodies say so rather than
+ dumping raw bytes.
+- Generalizes: the same viewer serves any event carrying `io.*` refs (browser
+ console payloads, future large captures).
+
+## Export / retention / verify
+
+- **Bundle export** (`redlog-cli export bundle`): copy the referenced
+ `io/.bin` files into the bundle's `io/`; the manifest already lists
+ screenshots + casts, add io refs. Sanitized exports (`redlog-cli sanitize`)
+ apply to sidecar bodies too — a sanitized replacement body is written and the
+ `system.sanitized` event records the digest swap (per `redaction-design.md`
+ Layer 4), so the chain still verifies.
+- **Retention** (`src/core/retention.ts`): prune `io/` bodies older than the
+ keep window, emitting `system.io_pruned` (already reserved in ROADMAP) so the
+ gap is explainable — same arc as `.cast` pruning. The chain (digests only) is
+ untouched; a pruned body reads as "pruned by retention" not "missing".
+- **`redlog-verify.py`**: when an event carries `io.*` refs, verify the sidecar
+ file's `sha256` matches the chained digest (bytes-on-disk match the attested
+ hash). A pruned/absent body is reported as pruned, not tampered.
+
+## Limits & config
+
+| Knob | Default | Meaning |
+|---|---|---|
+| `REDLOG_MAX_BODY` | 16 KB | inline preview cap (unchanged, for at-a-glance) |
+| `REDLOG_MAX_IO` | 2 MB | max body posted to the sidecar; above → `truncated`, preview only |
+| retention keep-days | project retention | when `io/` bodies are pruned |
+
+## Acceptance criteria
+
+- **A1** A response body larger than `REDLOG_MAX_BODY` but under `REDLOG_MAX_IO`
+ is fully retrievable via `io:read`; ScannerDetail shows the full body on
+ demand, and `len`/`truncated:false` are correct.
+- **A2** The event row + chain contain only the `sha256` (+ short preview), never
+ the full bytes; DB size is ~unchanged vs. today for large bodies.
+- **A3** Identical bodies across events are stored once (dedup by digest).
+- **A4** `redlog-verify.py` confirms the sidecar file matches the chained digest;
+ a retention-pruned body verifies as *pruned*, not *tampered*.
+- **A5** Bundle export includes referenced io bodies; `ots verify` on the bundle
+ still passes (chain unchanged).
+- **A6** Pause semantics hold: while recording is paused, no body is sidecarred
+ (the gate is at `insertEvent`/`POST /api/events`, which already covers this).
+- **A7** Path traversal is impossible — `io:read` refuses any ref outside
+ `/io/`.
+
+## Migration & compatibility
+
+- Purely additive: events without `io.*` refs (all historical, and any capture
+ under the post ceiling) behave exactly as today via the inline preview.
+- No chain migration — old events keep their inline previews; new large bodies
+ gain refs. `redlog-verify` handles both.
+
+## Non-goals
+
+- Not a general blob store or attachment system — scoped to capture payloads
+ (HTTP bodies first).
+- No at-rest encryption of the sidecar (tracked separately as a 1.x item,
+ `ROADMAP.md`); the threat model discloses on-disk exposure honestly today.
+- No streaming of unbounded bodies — `REDLOG_MAX_IO` is a real ceiling; above it
+ the preview + `truncated` flag is the honest record.
+
+## Build order (each testable)
+
+1. Sidecar store module (`src/core/io-store.ts`): `putBody(projectDir, bytes) →
+ {ref, len, sha256}` (dedup), `readBody(projectDir, ref, off?, len?)`. Pure-ish
+ + fs; unit-testable with a temp dir.
+2. `POST /api/events` (option B): sidecar the full-body field, stamp `io.*`,
+ drop bytes before chaining. Unit + the existing mitm e2e extended to assert a
+ >16 KB body round-trips.
+3. `io:read` IPC + path validation.
+4. ScannerDetail lazy full-body load.
+5. Bundle export + retention prune (`system.io_pruned`) + `redlog-verify.py`.
+
+Cross-references: `redaction-design.md` (Layer 4 sanitize), `event-schema.md`
+(io.stdout precedent), `src/core/cast-slice.ts` (the reference-not-bytes model),
+`hooks/mitmproxy-addon.py` (`REDLOG_MAX_BODY`), commit b986441 (request-body
+display + raised cap).
diff --git a/docs/SPEC-SCOPE-AWARE-LIFECYCLE.md b/docs/SPEC-SCOPE-AWARE-LIFECYCLE.md
new file mode 100644
index 00000000..f95b8055
--- /dev/null
+++ b/docs/SPEC-SCOPE-AWARE-LIFECYCLE.md
@@ -0,0 +1,221 @@
+# Spec — Scope-Aware Sanitize + Retention + Artifact Rotation
+
+> **Implementation status (2026-08-13).**
+> - ✅ **Part C — artifact lifecycle (G1, G2, G4):** shipped. `config.io` gains
+> `warmDays` + `maxBytes` (G1); `artifact-pin.ts` pin score (G4);
+> `artifact-gc.ts` `planArtifactRotation` — age-or-size, refcount-gated,
+> pin-ordered (G2); `io-store` warm compress (gzip, original-digest-preserving)
+> + transparent read/verify; `retention.ts` `sweepIoLifecycle` wires it, with
+> marker/loot `_causes` pinning; `redlog-verify.py` + bundle-export handle warm
+> bodies. 42 unit tests (A4/A5/A6/A7).
+> - ✅ **Part B — decision core + scope wiring (G3 partial):** `classifyTarget`
+> (pure, side-effect-free scope verdict) and `planScopeSanitize` (Part B
+> planner incl. io-sidecar coverage + `unknown` flagging, A1/A2) shipped and
+> unit-tested; the classifier is wired into retention so scope-priority eviction
+> is **live** (A5 fully). 8 more tests.
+> - ✅ **Part B execution (G3 complete):** shipped. `scope-sanitize.ts`
+> `runScopeSanitize` applies the plan — whole-body placeholder for out-of-scope
+> inline fields (`sanitized_events`) **and** io sidecar bodies (new
+> `sanitized_io` table), refcount-safe (a body cited by any kept in-scope event
+> is never sanitized), appending one chained `system.sanitized` with
+> `io_replacements`. `bundle-export` serves the redacted bodies under their
+> original names; `redlog-verify.py` reads the swap and reports them
+> *sanitized*, never *tampered* (A1/A6). `internal` vs `client-deliverable`
+> export profiles wired through `exportBundle` + the `data:exportBundle` IPC
+> (§9). `unknown` targets are never auto-sanitized unless the operator opts in
+> (A2). Pure placeholder + DB round-trip tests added.
+>
+> **The full spec (Parts A/B/C) is now implemented.** The one deliberate scope
+> boundary: the CLI/HTTP export path stays internal-profile; the client-
+> deliverable profile is exposed through the app (IPC) export.
+
+Written 2026-08-13. Unifies three previously-separate storage concerns into one
+mechanism: **the hash chain is a small, immutable WORM spine; every heavy capture
+artifact is a refcounted, scope-prioritized, lifecycle-managed store.** It extends
+`SPEC-IO-SIDECAR.md` (the prunable io sidecar) and `redaction-design.md` (layer-4
+export sanitize) with (a) a scope-aware verdict on both sanitize and retention,
+(b) a three-stage artifact lifecycle (hot → warm/compressed → pruned) with
+size *and* age triggers, and (c) a per-capture-type coverage matrix that states
+honestly where scope does and does not apply.
+
+## Why (the problem)
+
+1. **Capture broad, sanitize/prune later** (DESIGN-PRINCIPLES §2) makes broad
+ capture safe *only if* the detect/sanitize/prune machinery is strong. Today
+ sanitize is manual per-event and retention is time-only per artifact type.
+2. **Client deliverables must not leak out-of-scope / adjacent-client content**,
+ but the *fact* a target was touched (`scope_violation`) must survive. Sanitize
+ must be able to key on scope, not just on hand-picked fields.
+3. **Disk pressure is real** for long engagements — but the heavy artifacts
+ (`.cast`, screenshots) are exactly the ones a host-based scope predicate
+ cannot classify. Retention needs a size ceiling and a compression tier, not
+ just an age cutoff, and rotation order needs a priority signal.
+
+## Core invariant (unchanged)
+
+**The chain is sacred and small.** Only `sha256` digests, previews, and event
+metadata (who/when/which host/verdict) enter the hash chain; they are **never**
+rotated, compressed away, or pruned. Everything below acts on *artifact bytes*
+(`io/*.bin`, `*.cast`, screenshot `*.jpg`) — the prunable payload — and every
+lifecycle action **appends** a chained `system.*` audit event, so a rotated or
+sanitized store is itself tamper-evident (a missing artifact reads as *pruned*,
+never *tampered*; a stripped bundle is detectable). This is the Schneier–Kelsey /
+WORM split the prior art converges on (see Prior art).
+
+## Part A — Per-capture-type coverage matrix
+
+Scope is a **host/target predicate**. Not every `agent_type` carries a host, so
+scope-aware handling is **not** uniform. Three tiers:
+
+| Tier | agent_types | Has target? | io body? | Scope-aware sanitize | Scope-aware retention |
+|---|---|---|---|---|---|
+| **1 — target-clean** | `http`, `http_navigation`, `scanner`, `dns`, `browser`, `pivot` | yes (`dest_host`/`url`/`route`) | yes (Tier-1 only) | ✅ verdict via `ScopeMonitor.checkTarget` | ✅ pin=in-scope, GC=out-of-scope first |
+| **2 — target-partial** | `shell`/`terminal`, `file_transfer`, `cleanup`, `agent`, `loot`, `marker` | sometimes | no | ⚠️ only when target present; else `unknown` bucket | ⚠️ `unknown` → in-scope window |
+| **3 — no host** | `screenshot`, `clipboard`, `process`, `.cast` recordings | no | no | ❌ scope N/A — time/size retention only | ❌ scope N/A — time/size retention only |
+
+**Consequence stated plainly:** scope-aware retention reclaims Tier-1 io bodies
+only, which are the *small* artifacts after dedup + the 2 MB ceiling. The disk
+hogs (`.cast`, screenshots) are Tier 3 and must be handled by Part C's
+size/compression rotation, **not** by scope. Do not let the UI imply "scope-aware
+retention on = disk handled."
+
+### The `unknown` fallback (Tier 2) — asymmetric by design
+
+Scope verdict is an *inference* (DESIGN-PRINCIPLES §3), so an absent/ambiguous
+target must not silently drive a destructive default. The safe default differs
+between the two actions because their failure modes differ:
+
+- **Sanitize** (removes content from a *deliverable copy* — recoverable): an
+ `unknown` event is **neither auto-stripped nor auto-passed**; it surfaces in the
+ dry-run preview flagged for operator decision (default unchecked). Failure =
+ over/under-redacting a reviewable copy.
+- **Retention** (deletes *local bytes* — irreversible): an `unknown` event gets
+ the **in-scope (longest) window**. Never silently expire unclassified evidence.
+ Failure = permanent loss, so bias to keep.
+
+## Part B — Scope-aware sanitize
+
+Extends layer-4 export sanitize (`src/core/sanitize.ts`). Reuses the existing
+`sanitize()` machinery (per-event, `dryRun`, chained `system.sanitized`,
+tamper-detectable) — adds a **scope planner** in front of it.
+
+1. **Planner** walks candidate events; for each, resolves its target
+ (`target_id`/`dest_host`/`url`) and calls `ScopeMonitor.checkTarget`.
+ Verdict ∈ {`in_scope`, `out_of_scope`, `excluded`, `unknown`}.
+2. **Plan** = every `out_of_scope`/`excluded` event's body + sensitive fields
+ proposed for sanitization; `unknown` flagged; `in_scope` untouched. This is a
+ `dryRun` result — no writes yet.
+3. **Sidecar coverage (critical):** the plan must include the event's io_ref
+ sidecar body, not just inline event fields. A Tier-1 out-of-scope response
+ body lives in `io/.bin`; sanitize writes a sanitized replacement body and
+ the bundle serves that (per `SPEC-IO-SIDECAR.md` "Export/retention"). Missing
+ this leaks out-of-scope bytes through the side door.
+4. **Operator confirm gate**, then `sanitize --confirm` runs, appending one
+ `system.sanitized` per event with `reason: "scope:"`.
+5. **Export profiles** (§9 progressive disclosure): `internal` (full) vs
+ `client-deliverable` (scope-sanitized). Default deliverable profile runs the
+ scope planner; full content is the advanced opt-out.
+
+## Part C — Artifact rotation (the lifecycle)
+
+RedLog "log rotate" ≠ syslog overwrite. It is a three-stage lifecycle over the
+artifact stores, with **age *and* size triggers** (whichever hits first —
+logrotate's `daily` vs `size`).
+
+```
+HOT ──(age > warmDays OR store > sizeCap)──▶ WARM ──(age > pruneDays OR still > sizeCap)──▶ PRUNED
+uncompressed, zstd in place, bytes deleted,
+instant read read = decompress, sha256 + row kept,
+ verify = re-hash original system.*_pruned appended
+```
+
+- **Warm (compress) is pure win and must exist before prune.** io bodies and
+ `.cast` are text-shaped (JSON/HTML/timing) → ~5–10×. Compression is reversible:
+ keep the **original** `sha256` (not a compressed-hash) so `redlog-verify`
+ decompresses and re-hashes to confirm bytes-on-disk match the attested digest.
+- **Prune** reuses `retention.ts`; extend it from age-only to age-or-size, and
+ add an `io/` keep-window (today's config has cast/screenshot/agentTranscript
+ windows but **no io window** — see gap G1).
+
+### Refcount-gated deletion (restic/borg lesson)
+
+`io/.bin` is content-addressed + deduped, so a body may be referenced by
+many events. **A body is prunable only when *every* referencing event is past its
+window.** Deletion is a mark-and-sweep GC over event→sha refs, not a per-event
+`rm`. Getting this wrong either leaks disk (never delete) or deletes a body still
+cited by live in-scope evidence. (borg's `prune`-doesn't-`compact` trap: dropping
+the reference must actually reclaim the bytes, in one attributable step.)
+
+### Scope as pin (IPFS lesson) — where A, B, C merge
+
+Rotation **order** under size pressure is driven by a pin score:
+
+- **Pinned (evicted last):** `in_scope` bodies; anything referenced by a `loot` or
+ `marker` event; anything an operator explicitly pinned.
+- **Unpinned (evicted first):** `out_of_scope` / `excluded` / `unknown`, unmarked.
+
+So when `io/` exceeds its cap, GC compresses then prunes out-of-scope/unmarked
+bodies first and keeps in-scope evidence longest — the single mechanism that
+answers both "how does broad capture not fill the disk" and "where does scope
+attach": scope attaches to **rotation priority**, never to capture.
+
+## Prior art
+
+- **Schneier–Kelsey hash-chain audit logs / WORM** — retention acts on content,
+ never on the tamper-evident chain. ([finqub], [emergentmind])
+- **Teleport session recording** — audit events and heavy recordings split across
+ backends; recordings run an explicit S3 lifecycle, are **not** auto-deleted,
+ default 1-year retention; rotated keys are decrypt-only. ([teleport-recording],
+ [teleport-backends])
+- **restic vs borg** — generational retention (keep N daily/weekly/monthly), and
+ the reclaim gotcha: borg `prune` drops refs but needs `compact`; restic
+ `forget --prune` repacks in one step. Deletion must actually free space.
+ ([restic-borg])
+- **IPFS pin + GC** — content-addressed store GCs unpinned blocks; pinning
+ protects. Maps to scope/marker = pin. ([ipfs-gc])
+- **SIEM hot/warm/cold + WORM** — tier down (compress) before delete; ~85–90%
+ cost cut; PCI-DSS 10.7: 12-month retention, recent 3 months immediately
+ available. ([logsentinel], [scality-tiers])
+
+## Acceptance criteria
+
+- **A1** Export `client-deliverable` profile: a Tier-1 out-of-scope response body
+ (event field *and* io sidecar) is sanitized; the `scope_violation` event and
+ the touched host remain in the bundle. `in_scope` bodies are untouched.
+- **A2** `unknown`-target events are never auto-sanitized; they appear in the
+ dry-run preview flagged, default unchecked.
+- **A3** Retention never expires an `unknown`-scope artifact before the in-scope
+ window; out-of-scope artifacts expire on the short window.
+- **A4** Warm stage: a compressed io body is still retrievable via `io:read`, and
+ `redlog-verify.py` confirms the decompressed bytes match the chained (original)
+ `sha256`.
+- **A5** Size trigger: with `io/` over its cap, GC compresses then prunes
+ unpinned (out-of-scope/unmarked) bodies first; a body referenced by any event
+ still inside its window is never deleted (refcount-gated).
+- **A6** Every rotation/sanitize action appends a chained `system.*` event
+ (`io_pruned`/`cast_pruned`/`screenshot_pruned`/`sanitized`); a pruned artifact
+ verifies as *pruned*, a sanitized one as *sanitized*, never as *tampered*.
+- **A7** Tier-3 artifacts (`.cast`, screenshot, `clipboard`, `process`) are
+ governed by time/size retention only; no scope verdict is computed or implied
+ for them in the UI.
+
+## Gaps to close (implementation order)
+
+- **G1** Wire an `io` keep-window + size cap into `config.ts` retention block
+ (today: cast/screenshot/agentTranscript only). Prerequisite for Part C.
+- **G2** Extend `retention.ts` from age-only to age-or-size, add the warm
+ (compress) stage, and make deletion refcount-gated over event→sha refs.
+- **G3** Add the scope planner + `unknown` bucket in front of `sanitize.ts`, with
+ sidecar-body coverage and export profiles.
+- **G4** Compute the pin score (scope verdict + marker/loot reference) as the
+ rotation eviction key.
+
+## Non-goals
+
+- **Not** scope-gated *capture*. Capture stays broad (§2); scope only drives
+ sanitize, retention priority, and export — never a capture blind spot, which
+ would also disable `scope_violation` recording.
+- **Not** rotating, compressing, or pruning the hash chain, event rows, previews,
+ or `sha256` digests. Only artifact bytes move through the lifecycle.
+- **Not** scope-slicing a single `.cast` or screenshot by host (Tier 3 is
+ time/size only). Visual/stream artifacts are out of scope's reach by construction.
diff --git a/docs/SPEC-TIMELINE-AXIS.md b/docs/SPEC-TIMELINE-AXIS.md
new file mode 100644
index 00000000..9689fbe2
--- /dev/null
+++ b/docs/SPEC-TIMELINE-AXIS.md
@@ -0,0 +1,163 @@
+# Spec — Timeline Reconstruction Axis (Phase C)
+
+Written 2026-08-11. Executable spec for the flagship timeline refactor from
+`DESIGN-PRINCIPLES.md` §8/§9: re-organise the timeline from a live source-type
+firehose into a **reconstruction surface organised by target and phase**. This
+is the highest-value and highest-risk item in the UX plan — it touches the
+4,100-line `Timeline.tsx` lane model — so it is specced before a line is wired.
+
+Foundation already shipped (Phase B, commit `d8494c2`): `phaseSegments`,
+`phaseInference`, `targetGrouping`. This spec is how they reach the screen.
+
+## Goal
+
+A reviewer reconstructing an engagement asks *"what happened to this target, in
+what order?"* and *"reconstruct this phase (recon → exploit → pivot → exfil)."*
+The current 18 source-type lanes answer *"which streams are active"* — a live
+question that now belongs to the HUD (§8). Phase C makes the timeline answer the
+reconstruction questions.
+
+## Non-goals (unchanged from the principles)
+
+- No opinionated ATT&CK/correlation output. Phase inference stays a **suggestion**
+ (§3) — dashed, promotable, never authoritative.
+- Timeline stays the **event map**; it does not grow inline I/O content (that is
+ the transcript/exchange reader, §8 / Q10).
+- No new capture. This is a presentation refactor over existing events + the
+ Phase B seams.
+
+## The axis model (recommended design)
+
+Two orthogonal controls, not one three-way switch:
+
+1. **Lane axis** — the swim-lane rows. Toggles between:
+ - `source` *(current, default at ship)* — the 18 source-type lanes, unchanged.
+ - `target` *(new)* — one lane per target from `groupByTarget(events)`, plus the
+ explicit **untargeted** lane always last (§12). Each event's dot keeps its
+ source-type colour/shape, so source is still legible as an encoding, just
+ not as the row axis.
+2. **Phase ribbon** — a thin horizontal band across the top of the time axis,
+ independent of the lane axis, rendered from `phaseSegments(...)`. Operator
+ phase-markers draw **solid** bands; `inferPhaseSuggestions(...)` draws
+ **dashed/ghost** bands with a "suggest" affordance that promotes to an operator
+ marker on click (§11). Toggleable; off by default until the operator has
+ phases.
+
+> **Why a ribbon, not a third lane mode:** phase is inherently *temporal*
+> (segments over time), so it maps naturally to time-bands, not rows. Making it a
+> ribbon lets target-lanes and phase coexist ("show me, per target, which phase
+> each action fell in") instead of forcing a choice. **Open question O1** records
+> the alternative.
+
+### Data flow
+
+```
+events ──┬─ groupByTarget() → target lanes (axis='target')
+ ├─ toLane() [existing] → source lanes (axis='source')
+ ├─ phaseMarkersFromEvents → phaseSegments() → solid phase bands
+ └─ inferPhaseSuggestions() → dashed phase bands
+event dot colour/shape ← source-type (unchanged encoding, both axes)
+```
+
+## Safe refactor strategy — incremental, behind a toggle
+
+The rule: **never a big-bang replacement of the lane model.** Order:
+
+1. **Extract T5 seams first** (make the current behaviour testable before
+ changing it):
+ - `lib/timelineCluster.ts` — the 14px same-lane cluster bucketing (currently
+ inline). Pure `clusterEvents(events, pxPerMs, laneOf)`.
+ - `lib/laneVisibility.ts` — populated / visible / hidden / solo resolution
+ (currently inline; pairs with the T3 modes seam already shipped).
+ Each ships with unit tests and is imported back into Timeline.tsx with **zero
+ behaviour change** (renderer-smoke + timeline-keys stay green). This buys the
+ first real interaction-test coverage on the file before we touch its axis.
+2. **Introduce `laneAxis` state** (`'source' | 'target'`), default `'source'`.
+ Add a `lib/timelineAxis.ts` seam: `lanesForAxis(axis, events, ...)` returning
+ the ordered lane descriptors — for `'source'` it returns today's lanes
+ (delegating to the existing logic), for `'target'` it wraps `groupByTarget`.
+ The renderer maps events to rows through this one function regardless of axis.
+ Ship with `'source'` as default so nothing changes until the operator opts in.
+3. **Wire the target axis** behind a header control (part of the T4 "View
+ options" menu). Verify empty/untargeted handling, cluster + visibility still
+ work per-lane, and the T3 modes row reflects target-lane solo/hide.
+4. **Add the phase ribbon** (solid from markers, dashed from inference, promote
+ action). Independent toggle.
+5. **Fold TargetView in** — once the target axis is solid, TargetView becomes the
+ timeline with `laneAxis='target'`; deprecate the separate view or make it a
+ deep-link into that axis. (Sequenced last; not required for the axis to ship.)
+
+Each step is independently shippable and independently revertible. The default
+stays `source` until `target` is proven, so no operator loses the current view on
+upgrade.
+
+## Seams to build (all pure, TDD, renderer lib)
+
+| Seam | Purpose | Ships in step |
+|---|---|---|
+| `timelineCluster.ts` | cluster bucketing, testable | 1 |
+| `laneVisibility.ts` | populated/visible/hidden/solo | 1 |
+| `timelineAxis.ts` | `lanesForAxis(axis, events, …)` → ordered lanes | 2 |
+| `phaseRibbon.ts` | merge `phaseSegments` (solid) + `inferPhaseSuggestions` (dashed) into render-ready bands, with promote targets | 4 |
+
+Phase B seams (`phaseSegments`, `phaseInference`, `targetGrouping`) are consumed,
+not rebuilt.
+
+## Acceptance criteria
+
+- **A1 — no-regression default:** with `laneAxis='source'` and the phase ribbon
+ off, the timeline is byte-for-byte the current behaviour. `renderer-smoke` and
+ `timeline-keys` stay green; the T5 extractions change nothing observable.
+- **A2 — target axis:** `laneAxis='target'` renders one lane per target ordered by
+ first activity, untargeted last; every event appears in exactly one lane; dots
+ keep source-type encoding.
+- **A3 — untargeted honesty:** events with no target land in the explicit
+ untargeted lane, never dropped, never guessed into a target (§12).
+- **A4 — phase ribbon:** operator phase-markers render as solid bands spanning
+ their segment; inferred suggestions render dashed and are visually distinct;
+ clicking a suggestion's promote affordance creates an operator marker (an
+ authoritative, attributable, on-chain fact — §3/§11).
+- **A5 — cross-feature integrity:** cluster popovers, lane solo/hide (T3 row),
+ the `/` filter, zoom/pan, and the T2 wheel behaviour all work under both axes.
+- **A6 — I/O boundary held:** the timeline still surfaces I/O only as a glyph;
+ drill-down still opens the transcript (§8 / Q10). No inline I/O content added.
+
+## Regression guards (hard gates each step)
+
+- `npx vitest run` on: the new seam tests, `renderer-smoke`, `timeline-keys`,
+ `timeline-modes`, `timeline-wheel`, plus (steps 1) the extracted-seam tests.
+- `npm run build` green.
+- i18n en/zh parity for any new axis/ribbon copy.
+- Manual smoke on a transcript-heavy project before folding TargetView in (step 5).
+
+## Risks & mitigations
+
+| Risk | Mitigation |
+|---|---|
+| Big-bang breaks the fragile 4,100-line file | Toggle-behind + `source` default; each step revertible; T5 seams give test coverage *before* the axis change |
+| Target-lane count explodes on a wide engagement | Lane virtualization (T6) — sequence it alongside/after; until then cap + "N more targets" like the cluster cap |
+| Phase inference reads as authoritative | Enforced by design: dashed rendering + promote-to-marker; inference never writes to the chain (§3) |
+| Concurrent edits to Timeline.tsx (has happened) | Single-owner sequential passes; check `git status`/mtime before each |
+
+## Open questions — RESOLVED 2026-08-11
+
+- **O1 — phase representation → RIBBON overlay.** Phase renders as a horizontal
+ time-band at the top of the time axis, independent of the lane axis, so target
+ lanes and phase coexist. (Not a third `laneAxis='phase'` mode.)
+- **O2 — default axis at ship → keep `source`.** `target` is opt-in; nothing
+ changes on upgrade until the operator switches. Revisit the default after
+ dogfooding.
+- **O3 — TargetView → REMOVE.** The `target` lane axis fully subsumes TargetView;
+ step 5 deletes the separate view rather than keeping it as a deep-link. (More
+ decisive than the original recommendation — the axis is the one place targets
+ are reviewed.)
+
+## Sequencing summary
+
+Step 1 (T5 seams, no behaviour change) → Step 2 (`timelineAxis` seam, source
+default) → Step 3 (wire target axis behind View options) → Step 4 (phase ribbon +
+promote) → Step 5 (**remove** TargetView, per O3). Steps 1–2 shipped (commits
+7c98579, dfceb47); O1–O3 resolved, so 3–5 are unblocked.
+
+Cross-references: `DESIGN-PRINCIPLES.md` §3/§8/§9/§11/§12 · `UX-TIMELINE-2026-08.md`
+(T-series) · `UX-BACKLOG-TICKETS.md` (T4/T5/T6) · Phase B commit `d8494c2`.
diff --git a/docs/TESTING.md b/docs/TESTING.md
new file mode 100644
index 00000000..995ba8a4
--- /dev/null
+++ b/docs/TESTING.md
@@ -0,0 +1,687 @@
+# RedLog Test Matrix
+
+Every configurable option, every value that behaves differently, and where each
+one is proven. If you change a default, add a field, or touch the alert path,
+this is the file that says what the change is allowed to break.
+
+```
+npm test # the whole vitest suite (unit + renderer)
+npx vitest run test/ # one file
+npx vitest # watch mode
+npm run e2e # Playwright, needs a built app
+npm run typecheck # renderer types
+```
+
+**After an e2e run, `npm test` will fail to start.** `pree2e` points
+`node_modules/.bin/node-gyp` at `@electron/node-gyp`, so `pretest`'s
+`npm rebuild better-sqlite3` then builds the native module against Electron
+headers and vitest cannot load it (`Could not locate the bindings file`, even
+though the file is right there — it is the ABI that is wrong). Recover with:
+
+```
+rm -rf node_modules/better-sqlite3/build
+(cd node_modules/better-sqlite3 && ../../node_modules/node-gyp/bin/node-gyp.js rebuild --release)
+```
+
+## How to read a row
+
+| Column | Meaning |
+|---|---|
+| **Value** | the literal a config file can hold, including the junk values |
+| **Behaviour** | what RedLog does with it — stated as an observable outcome |
+| **Proof** | vitest file that asserts it, `e2e` for Playwright, `manual` for §5, `—` for an open gap |
+
+Proof names drop the `test/` prefix and the `.test.ts(x)` suffix.
+
+---
+
+# Part 1 — The alert path
+
+The one sequence that has to be right: an address or a target is observed, a
+verdict is computed, and the verdict reaches the operator's eye. RedLog never
+blocks (`ALERT-ROLES.md` Part D), so a verdict that is wrong or invisible is the
+whole defence failing — there is nothing downstream to catch it.
+
+```
+egress IP ──► classifyIP ──► settling/stale ──► ipBadge ──► HUD + card + status bar
+command ──► classifyDistance ──► alertFloor ──► violation event ──► ScopeStatus
+```
+
+## 1.1 IP verdict matrix — which list wins
+
+Two lists, each independently set or unset, and an address that may hit either.
+Nine reachable cells.
+
+Five verdicts, because the two lists answer different kinds of question. Three
+are **observations** and one is an **inference**, and §3 says an inference may
+never wear a fact's colour:
+
+| Verdict | Authority | Means |
+|---|---|---|
+| `safe` | fact | on an address you declared safe |
+| `exposed` | fact | on an address you declared to be *you* |
+| `off_profile` | fact | you declared an expectation and this is **outside** it |
+| `presumed_safe` | **inference** | "not obviously you" — a blacklist is set and this missed it |
+| `unknown` | — | nothing declared; there is no question to answer |
+
+| # | whitelist | blacklist | address hits | Verdict | Why |
+|---|---|---|---|---|---|
+| A-1 | — | — | — | `unknown` | nothing declared, nothing to judge against |
+| A-2 | — | set | blacklist | `exposed` | your own IP is showing |
+| A-3 | — | set | neither | `presumed_safe` | an inference, and rendered as one |
+| A-4 | set | — | whitelist | `safe` | on a declared exit |
+| A-5 | set | — | neither | `off_profile` | an observed deviation from a declared expectation |
+| A-6 | set | set | both | `exposed` | config conflict resolves to the alarming side |
+| A-7 | set | set | blacklist | `exposed` | identity leak is never masked |
+| A-8 | set | set | whitelist | `safe` | |
+| A-9 | set | set | neither | `off_profile` | **the VPN-dropped-onto-café-NAT case** |
+
+Proof: `ip-monitor` (table-driven, one `it` per row).
+
+Two fixes are pinned here against re-introduction:
+
+- **G-A1** — A-9 answered `safe`, because the blacklist-only shortcut ran even
+ after a configured whitelist had already missed. `ip-monitor` keeps both the
+ truth-table row and the scenario-shaped assertion.
+- **G-A2** — A-3 answered `safe` (an inference in a fact's solid green) and A-5
+ answered `unknown`, which understated an observed deviation into the same
+ amber bucket as "nothing is configured at all". Both now have their own
+ verdict.
+
+## 1.2 Range matching — `network.whitelist` / `network.blacklist`
+
+| Value | Behaviour | Proof |
+|---|---|---|
+| `10.8.0.0/24` | matches `10.8.0.0` through `10.8.0.255`, including network + broadcast | `ip-monitor-options` |
+| `10.8.0.0/24` vs `10.8.1.0` / `10.7.255.255` | one address either side is outside | `ip-monitor-options` |
+| `172.16.0.0/12` | matches up to `172.31.255.255`, not `172.32.0.1` | `ip-monitor-options` |
+| `203.0.113.42/32` | single host | `ip-monitor-options` |
+| `0.0.0.0/0` | matches everything | `ip-monitor-options` |
+| `203.0.113.42` (no mask) | exact equality, never a prefix — `203.0.113.4` does not match | `ip-monitor-options` |
+| `2001:db8::1/64` | IPv6 compares the literal prefix address only; `::2` does not match | `ip-monitor-options` |
+| IPv4 CIDR vs IPv6 address | family mismatch never matches | `ip-monitor-options` |
+| multiple entries | a hit on any one entry is a hit | `ip-monitor-options` |
+| `[]` (default) | list is not configured → see A-1/A-3/A-5 | `ip-monitor`, `config-options` |
+
+The blacklist is evaluated first and wins outright; a whitelist that is set but
+missed answers `off_profile` — never `safe`, and never the same `unknown` that
+"nothing configured" gets.
+
+## 1.2.0 `network.lanProfile` — which segment you expect to be ON
+
+The same classifier as `whitelist`, pointed at the INTERNAL address, with no
+blacklist — so only three of the nine cells are reachable.
+
+| Value | Internal address | Verdict | Proof |
+|---|---|---|---|
+| unset | anything | `unknown` — nothing was declared, so nothing is claimed | `ip-monitor` |
+| set | inside a listed CIDR | `safe` | `ip-monitor` |
+| set | outside every listed CIDR | `off_profile` | `ip-monitor` |
+
+`internalIP` was collected and displayed but never judged, so a laptop that
+silently reassociated to a guest SSID read exactly like one still on the client
+VLAN — the external IP can be perfectly fine while you are on the wrong network.
+The LAN verdict never goes `stale`: it is a local read of the network
+interfaces, so a failed external lookup does not expire it (and the failed poll
+now re-reads it rather than discarding it with the rejection).
+
+## 1.2.1 `network.staleAfter` — a verdict expires
+
+| Value | Behaviour | Proof |
+|---|---|---|
+| `2` (default) | after 2 consecutive failed reads the verdict decays to `unknown` and `stale` is set | `ip-monitor` |
+| `N` | same, at N | `ip-monitor` |
+| `0`, negative | rejected, default kept | `ip-monitor` |
+| a later success | verdict re-computes, `stale` clears | `ip-monitor` |
+
+A dropped VPN and a dead IP provider look identical from outside the process, so
+neither may keep rendering the last good answer at full confidence. `stale` beats
+`settling` on the badge: no reading at all is a stronger statement than an
+unconfirmed one (`ip-badge`).
+
+## 1.3 `network.confirmations` — flap protection
+
+A new address is held until it has been read this many times in a row. The
+displayed address, and therefore the verdict, does not change during the hold.
+
+| Value | Behaviour | Proof |
+|---|---|---|
+| `1` | promote on sight, no flap protection | `ip-monitor-options` |
+| `3` (default) | two reads hold the old address, the third promotes | `ip-monitor`, `ip-monitor-options` |
+| `5` | four reads hold, the fifth promotes | `ip-monitor-options` |
+| `0`, negative | rejected — falls back to 3, **not** to "promote instantly" | `ip-monitor-options` |
+| first ever reading | taken as-is; there is nothing to flap against | `ip-monitor` |
+| candidate changes each poll (CGNAT) | nothing is ever promoted; `settling` stays true | `ip-monitor` |
+| old address returns mid-hold | the half-confirmed candidate is dropped | `ip-monitor` |
+
+`settling: true` is the "displayed value is the last stable read" flag. It is set
+for the whole hold and cleared on promotion (`ip-monitor-options`).
+
+## 1.4 `network.ipMode` and `network.providers`
+
+| Option | Value | Behaviour | Proof |
+|---|---|---|---|
+| `ipMode` | `auto` (default) | DNS first, HTTP only if DNS fails/blocked | `ip-monitor-dns` |
+| `ipMode` | `dns` | direct query to OpenDNS/Google resolvers, no HTTP at all | `ip-monitor-dns` |
+| `ipMode` | `http` | HTTP echo only, DNS never queried | `ip-monitor-options` |
+| `providers` | `[]` (default) | the three built-in echo services are used | `ip-monitor-options` |
+| `providers` | custom list | tried in order; first success wins | `ip-monitor`, `ip-monitor-options` |
+| `providers` | first entry throws | falls through to the next | `ip-monitor-options` |
+| `providers` | first entry answers non-200 | treated as a failure, moves on | `ip-monitor-options` |
+| `providers` | response `{ip}` or `{origin}` | both shapes are accepted | `ip-monitor-options` |
+| `providers` | every entry fails | `error: 'All IP providers failed'`, **last known address is kept** | `ip-monitor`, `ip-monitor-options` |
+| `providers` | a later poll succeeds | error clears | `ip-monitor-options` |
+
+## 1.5 `network.checkInterval`
+
+| Value | Behaviour | Proof |
+|---|---|---|
+| unset (monitor default) | polls every 10 s | `ip-monitor-options` |
+| `60` (config default) | polls every 60 s | `ip-monitor-options`, `config-options` |
+| `0` | ignored — a zero-second poll would be a busy loop | `ip-monitor-options` |
+| `stop()` | clears the timer, idempotent | `ip-monitor-options` |
+| slow poll still in flight | the next tick is skipped, not queued | `ip-monitor-options` |
+
+## 1.6 Scope distance ladder — `classifyDistance`
+
+How far a target sits from declared intent (`ALERT-ROLES.md` Part B). The tier
+decides whether an alert fires at all, so every rung needs its own row.
+
+| Tier | Distance | Meaning | Alerts? |
+|---|---|---|---|
+| D0 | `in_scope` | hits a scope entry | no |
+| D1 | `excluded` | hits an exclude entry — a **fact** | **always**, even with warnings off |
+| D2 | `adjacent_subnet` | same container as a *single-IP* scope entry — **inferred** | only when `warnOnViolation` |
+| D2 | `adjacent_domain` | same registrable domain as a scope entry — **inferred** | only when `warnOnViolation` |
+| D3 | `unrelated` | no relationship to anything declared | never; **counted** via `getUnrelatedCount()` |
+
+D1 outranks D0: an explicit exclusion inside a broad CIDR still fires.
+
+| Case | Scope | Target | Distance | Proof |
+|---|---|---|---|---|
+| no scope set | `[]` | anything | `in_scope` | `scope-monitor-behaviour` |
+| single-IP entry expands into its container | `192.168.1.10` | `192.168.1.55` | `adjacent_subnet` | `scope-monitor-behaviour` |
+| a written CIDR is **not** widened | `10.0.0.0/24` | `192.168.50.1` | `unrelated` | `scope-monitor-behaviour` |
+| wildcard expands to the registrable domain | `*.staging.example.com` | `prod.example.com` | `adjacent_domain` | `scope-monitor-behaviour` |
+| unrelated domain | `*.staging.example.com` | `google.com` | `unrelated` | `scope-monitor-behaviour` |
+| `proximityBits: 16` | `192.168.1.10` | `192.168.2.55` | `adjacent_subnet` | `scope-monitor-behaviour` |
+| `proximityBits: 24` (default) | `192.168.1.10` | `192.168.2.55` | `unrelated` | `scope-monitor-behaviour` |
+| nonsensical `proximityBits` | any | any | falls back to 24 rather than opening up | `scope-monitor-behaviour` |
+
+The IP/domain asymmetry is deliberate: a bare IP carries no boundary, so
+deriving one fills a gap; a CIDR *states* a boundary, and widening it would
+invent authorisation the operator never gave. A domain always expands to its
+registrable domain because that is the ownership boundary the authorisation is
+about — hitting `prod` while scoped to `staging` is exactly what D2 exists for.
+
+## 1.6.1 Scope verdicts — `classifyTarget`
+
+The coarser four-value verdict shared by sanitize and retention (D2 and D3 both
+collapse to `out_of_scope` here).
+
+| Target | Scope | Verdict | Proof |
+|---|---|---|---|
+| `null` / `''` / `undefined` | any | `unknown` (caller picks the safe default) | `scope-monitor-behaviour` |
+| anything | `targets: []` | `in_scope` — no scope set means everything is in scope | `scope-monitor-behaviour` |
+| `10.0.0.9` | `10.0.0.0/24` | `in_scope` | `scope-monitor-behaviour` |
+| `10.0.1.9` | `10.0.0.0/24` | `out_of_scope` | `scope-monitor-behaviour` |
+| `api.app.example.com` | `*.app.example.com` | `in_scope` | `scope-monitor-behaviour` |
+| `app.example.com` | `*.app.example.com` | `in_scope` — the wildcard covers its anchor host | `scope-monitor`, `scope-monitor-behaviour` |
+| `vpn.example.com` | `*.app.example.com` | `out_of_scope` (same root, outside the wildcard) | `scope-monitor-behaviour` |
+| `google.com` | `*.app.example.com` | `out_of_scope` | `scope-monitor-behaviour` |
+| `10.0.0.1` | in `targets`, also in `excludeTargets` | `excluded` — exclusion beats the enclosing CIDR | `scope-monitor-behaviour` |
+| `dc01.app.example.com` | in the wildcard, also excluded | `excluded` | `scope-monitor-behaviour` |
+
+CIDR and wildcard mechanics themselves (`/8` `/16` `/24` `/32`, `*.` prefix, root
+extraction) are table-tested in `scope-monitor`.
+
+## 1.7 `scope.alertFloor` — how much the operator is told
+
+The lowest rung that still raises an alert. It replaced the `warnOnViolation`
+boolean (G-C3), which could only silence D2 and gave an operator who wanted to
+see *everything* no setting at all.
+
+| Distance | `excluded_only` | `adjacent` (default) | `all` | Proof |
+|---|---|---|---|---|
+| D0 in scope | no violation | no violation | no violation | `scope-monitor-behaviour` |
+| D1 excluded | **violation** | **violation** | **violation** | `scope-monitor-behaviour` |
+| D2 adjacent subnet | silent, `inScope: false` | **violation** | **violation** | `scope-monitor-behaviour` |
+| D2 adjacent domain | silent, `inScope: false` | **violation** | **violation** | `scope-monitor-behaviour` |
+| D3 unrelated | counted only | counted only | **violation** | `scope-monitor-behaviour` |
+
+D1 fires at every floor: "keep off X" is not a preference call. D3 is counted at
+every floor, because silent is not the same as not looking — the count is what
+proves the difference (`getUnrelatedCount()`).
+
+Bookkeeping: violations accumulate rather than dedupe, each carries
+target/command/timestamp, `getViolations()` returns a copy, and the full command
+is kept in memory while the chained event slices it to 200 chars
+(`scope-monitor-behaviour`). A violation event is `system` /
+`subtype: scope_violation` carrying `reason` (`excluded_target` \|
+`adjacent_subnet` \| `adjacent_domain` \| `unrelated`) and `authority`
+(`fact` \| `inferred`), so downstream can tell an observation from an inference
+rather than seeing one undifferentiated red (`scope-violation-event`).
+`scope_violation` is also the default deconfliction subtype (`deconfliction`).
+
+Whatever the tier, the command itself still lands in the timeline — only the
+*alert* is suppressed, never the record.
+
+`configure()` semantics: omitting `alertFloor` on a later call keeps the current
+value; replacing `targets` re-derives the containers used for D2; a re-configured
+`proximityBits` takes effect on the next check (`scope-monitor-behaviour`).
+
+## 1.7.1 One severity scale for both alarms
+
+The self alarm (IP) and the target alarm (scope) grew separate colour
+vocabularies: four tones on one side, an on/off light on the other, so a D1 hit
+on a forbidden host and a D2 proximity inference rendered identically. G-C1 puts
+both on one scale — `ok` < `notice` < `unknown` < `warn` < `critical`
+(`alert-severity`).
+
+| Input | Severity |
+|---|---|
+| `exposed` / D1 `excluded_target` | `critical` — the thing you must not do, observed, non-silenceable |
+| `off_profile` / D2 adjacent | `warn` |
+| D3 `unrelated` (only at `alertFloor: all`) | `notice` — a recorded departure, not an alarm |
+| `unknown` | `unknown` |
+| `safe` / `presumed_safe` | `ok` |
+
+`worstSeverity()` folds several inputs into the single dot the status bar shows.
+Authority is carried **orthogonally** to severity: `dotShape()` renders an
+inferred event as a hollow/dashed mark and an observed one as solid, so a
+judgement never looks like a measurement at any severity (`dot-shape`,
+`authority`, `authority-stamp`).
+
+## 1.8 Alert display — the verdict has to be seen
+
+The same verdict has to be legible on three surfaces, and the two qualified
+verdicts must not borrow a plain one's confidence. Proof: `alert-display`
+(HUD + dashboard) and `alert-surfaces` (status bar); the decision itself is
+`ip-badge`.
+
+| Verdict | Severity | HUD label | Dashboard card | Status bar |
+|---|---|---|---|---|
+| `safe` | `ok` | `SAFE` | green dot, `Safe IP` | green dot, `SAFE` + address |
+| `presumed_safe` | `ok` | `PRESUMED` | `Presumed safe` + "nothing to confirm it against" hint | `SAFE`, qualified |
+| `off_profile` | `warn` | `OFF-PROFILE` | `Off-profile IP` + "not where you declared" hint | `OFF-PROFILE` |
+| `exposed` | `critical` | `EXPOSED` | red pulsing dot + "check your VPN" hint | red dot, `EXPOSED` |
+| `unknown` | `unknown` | `IP?` | yellow dot + "configure the lists" hint | amber dot, `IP?` |
+| any + `stale` | forced `unknown` | verdict expired | `No current reading` + "a dropped VPN looks exactly like this" | `NO READ` |
+
+The HUD frame turns red on `exposed` (`#d75f63`) and stays cyan (`#3fc7d6`)
+otherwise; `flashOnExposed` controls only the flash, never the colour.
+
+Also asserted: the expanded pane spells the verdict out in words
+(`Exposed IP — Not Protected`, `Presumed safe — inferred, not verified`) rather
+than relying on colour alone; every qualified verdict carries the instruction
+that would upgrade it to a verified one; a provider error is shown instead of
+pretending the reading is fresh; a missing reading renders `—` rather than a
+stale address, and the status bar omits the address entirely.
+
+**Scope alerts** (`alert-surfaces`): the status bar shows `SCOPE OK` or
+`SCOPE `; the Scope & Evidence view distinguishes `NOT SET` (with the hint
+that fixes it) from `ACTIVE` + "all commands within scope", lists each violation
+with its target and command, caps the recent list at 10 rows without losing the
+count, and only offers the Export button once there is something to export.
+
+**Recording vs capture health** (`alert-surfaces`): `REC` pulsing red only means
+recording *and* a healthy capture. A `partial` verdict turns it amber and still
+pulsing; `dark` turns it amber and stops the pulse, because "REC" on its own
+would be a lie when nothing is feeding events.
+
+**Live updates** (`alert-surfaces`): the open HUD subscribes to all five overlay
+settings (`showMark`, `flashExposed`, `scale`, `emphasizeIp`, `passThrough`), and
+each one takes effect without a restart — including the `0`-opacity rejection,
+which is enforced on the live message as well as on the config read.
+
+---
+
+# Part 2 — Config option matrix
+
+Defaults below are asserted one-per-`it` in `config-options`, so a changed
+default fails a named test rather than a distant integration.
+
+## 2.1 `engagement` / `operator`
+
+| Option | Default | Behaviour | Proof |
+|---|---|---|---|
+| `engagement.id` | `default` | stamped on every event | `config-options` |
+| `engagement.name` | `Default Engagement` | display only | `config-options` |
+| `operator.id` | `operator-1` | attribution; no operator = no capture (see 2.5, 2.9) | `config-options` |
+| `operator.name` | `Operator` | display only | `config-options` |
+
+## 2.2 `network`
+
+| Option | Default | Values | Behaviour | Proof |
+|---|---|---|---|---|
+| `whitelist` | `[]` | IPs / CIDRs | §1.1–1.2 | `ip-monitor*` |
+| `blacklist` | `[]` | IPs / CIDRs | §1.1–1.2, wins over whitelist | `ip-monitor*` |
+| `checkInterval` | `60` | seconds | §1.5; UI coerces junk to 60 | `ip-monitor-options` |
+| `providers` | `[]` | URLs | §1.4 | `ip-monitor-options` |
+| `confirmations` | `3` | ≥1 | §1.3; UI clamps to ≥1 | `ip-monitor-options` |
+| `staleAfter` | `2` | ≥1 | §1.2.1 — consecutive failed reads before the verdict expires to `unknown` + `stale` | `ip-monitor` |
+| `ipMode` | `auto` | `dns` \| `http` \| `auto` | §1.4 | `ip-monitor-dns` |
+| `showWifiName` | `false` | bool | off drops the SSID from the link before it reaches any surface, keeping the link **type** (the UI renders a generic "Wi-Fi"); on shows it. The macOS toggle also asks for Location Services, since the OS redacts the SSID without it. Turning it off applies immediately rather than at the next 20 s poll | `wifi-name-policy` |
+| `vpnAdapters` | 12 built-ins, all enabled | `{name, pattern, enabled}` | patterns are user regexes matched case-insensitively against interface names | `vpn-adapters` |
+
+`vpnAdapters` detail (`vpn-adapters`): a disabled adapter matches nothing; an
+empty list means no interface is ever VPN; several adapters can match and the
+result is sorted; an interface with no external address is skipped; **a malformed
+regex is dropped instead of throwing**, so one bad pattern cannot take the OPSEC
+poller down; re-configuring replaces the pattern set. The shipped list is
+verified to recognise `wg0`, `tun0`, `tap0`, `tailscale0`, `nordlynx`, `proton0`,
+`utun4`, `ipsec0`, `ppp0`, and to leave `en0` / `eth0` / `wlan0` alone.
+
+## 2.3 `scope`
+
+| Option | Default | Behaviour | Proof |
+|---|---|---|---|
+| `alertFloor` | `adjacent` | §1.7 — `excluded_only` \| `adjacent` \| `all`; the lowest rung that still alerts | `scope-monitor-behaviour` |
+| `targets` | `[]` | §1.6; empty = everything in scope | `scope-monitor-behaviour` |
+| `excludeTargets` | `[]` | D1: always warns when hit | `scope-monitor-behaviour` |
+| `proximityBits` | `24` | container width derived for a **single-IP** scope entry; entries already written as CIDRs are never widened; values outside 1–32 or non-integers fall back to 24. Settings ▸ Scope renders it under the alert-floor control and hides it at `excluded_only` (with D2 silenced there is nothing to widen); the UI clamps to 1–32 and coerces junk to 24 | `scope-monitor-behaviour`, `config-options`, `settings-interaction` |
+| `publicSuffixes` | `[]` | extra multi-label suffixes on top of the built-in table, so an engagement on a suffix RedLog has not heard of is not blocked on a release. **Additive only** — the built-ins cannot be removed | `public-suffix` |
+| `scopeFile` | `null` | external scope document, loaded on open | `config-options` |
+
+`scopeFile` parsing (`config-options`): plain text is one target per line with
+`#` comments and blank lines dropped; a file with no extension is read as text;
+a JSON array keeps only its string entries; malformed JSON, an unrecognised JSON
+shape, and a missing file all yield `[]` rather than throwing — an unreadable
+scope must never take the project open down with it.
+
+Burp/ZAP hold a scope host as a **regex**, so `burpHostToTarget()` decodes the
+shapes those tools write into RedLog target syntax:
+
+| Burp host | Decoded | Meaning |
+|---|---|---|
+| `^example\.com$` | `example.com` | exact host |
+| `\Qexample.com\E` | `example.com` | literal-quoted |
+| `.*\.example\.com$` | `*.example.com` | "and subdomains" |
+| `.*\Qcorp.example.com\E` | `*.corp.example.com` | same, literal-quoted |
+
+`\Q…\E` runs are expanded wherever they appear and however many times; a
+hand-written pattern that is still regex-shaped afterwards (`(dev|prod)\.…`) is
+handed back **untouched** rather than dropped or half-converted. It matches
+nothing, but it stays visible in the scope list — a scope target that vanishes
+is the failure with no symptom, since the operator sees a scope load
+successfully and never learns which hosts are missing from it.
+
+## 2.4 `screenshot`
+
+| Option | Default | Values | Behaviour | Proof |
+|---|---|---|---|---|
+| `quality` | `85` | 1–100 | passed to the JPEG encoder verbatim; `0` is ignored and 85 is used | `screenshot-options` |
+| `intervalSec` | `0` | seconds, `0` = off | `0` and negatives schedule nothing; `30` schedules a 30 s loop; `12.9` floors to 12 s; setting it back to `0` cancels the running loop; re-configuring replaces the timer instead of stacking one | `screenshot-options` |
+
+Capture gating (`screenshot-options`): a periodic capture of an unchanged screen
+is skipped (exact-bytes then perceptual dHash), a changed screen is captured
+again, and a **manual** capture always lands — including while recording is
+paused, where periodic capture is suppressed. No operator id means nothing is
+scheduled and nothing is captured. Files land in `/screenshots/` and the
+event records trigger, size, dimensions and sha256.
+
+## 2.5 `overlay` — HUD appearance
+
+| Option | Default | Values | Behaviour | Proof |
+|---|---|---|---|---|
+| `showMarkButton` | `true` | bool | `false` hides both mark buttons in the expanded pane, keeping the keep-open toggle | `alert-display` |
+| `showInDock` | `true` | bool | macOS only: keeps a Dock icon once the HUD is shown. Stays manual — `app.dock.hide()` takes effect asynchronously via the activation policy and its result is **not observable** through `app.dock.isVisible()` in an automated Electron run (polled 5 s, still reports visible), which is also why main re-runs `applyDock()` on a timer and on overlay show | manual §5.4 |
+| `flashOnExposed` | `true` | bool | `false` keeps the red frame but stops the flashing; never flashes while `safe` | `alert-display` |
+| `scale` | `1.0` | 0.85 / 1.0 / 1.25 / 1.5 in the UI | scales type and padding; **clamped to 0.75–2.0**; `0` and negatives are ignored (treated as unset) | `alert-display` |
+| `emphasizeExternalIp` | `false` | bool | multiplies **only** the external IP by a further 1.4×, compounding with `scale` | `alert-display` |
+| `passThrough` | `false` | bool | dims HUD chrome and lets mouse events through | `alert-display` |
+| `passThroughOpacity` | `0.4` | 0.1–0.9 in the UI | applied to chrome while `passThrough` is on; `0` is ignored (an invisible HUD is worse than none); **the external IP is never dimmed** | `alert-display` |
+
+Measured values for `scale` (external IP font size, base 12 px):
+`0.85 → 10.2`, `1.0 → 12`, `1.25 → 15`, `1.5 → 18`, `99 → 24` (clamped),
+`0.01 → 9` (clamped); with `emphasizeExternalIp`: `1.0 → 16.8`, `1.5 → 25.2`.
+
+## 2.6 `terminal` / `screenshots` / `io` — retention
+
+| Option | Default | Behaviour | Proof |
+|---|---|---|---|
+| `terminal.maxCastBytes` | `52428800` (50 MB) | a session's `.cast` is truncated at the cap with an inline marker; values ≤ 0 are ignored | `cast-slice` (slicing), manual §5.5 (truncation) |
+| `terminal.castKeepDays` | `0` | `0` = keep forever; `N` deletes `.cast` files older than N days on project open, one `system.cast_pruned` audit event per deletion | `retention` |
+| `screenshots.keepDays` | `0` | same shape, `system.screenshot_pruned` | `retention` |
+| `io.keepDays` | `0` | prunes `io/.bin` sidecars, `system.io_pruned`; a body still referenced by a fresh event is kept (refcount-gated) | `retention`, `artifact-gc` |
+| `io.warmDays` | `0` | `0` = never compress; `N` gzips bodies older than N days in place, keeping the ORIGINAL sha256 so verification still passes | `artifact-gc`, `io-store` |
+| `io.maxBytes` | `0` | `0` = no cap; over the cap, unpinned bodies are evicted first (out_of_scope before unknown), and a pinned body is never evicted even if that leaves the store over cap | `artifact-gc`, `retention` |
+
+Every prune writes an audit event, so a pruned body verifies as *pruned*, not as
+tampered (`retention`, `io-store`).
+
+## 2.7 `clipboard`
+
+| Option | Default | Behaviour | Proof |
+|---|---|---|---|
+| `enabled` | `false` | off captures nothing at all; flipping it off stops the poll; on start the current clipboard is seeded so pre-session content is never captured | `clipboard-options` |
+| `pollMs` | `1500` | honoured as given, **clamped up to 500 ms** | `clipboard-options` |
+| `storePreview` | `false` | off stores hash + length + line count and `preview: null`; on stores a preview capped at 120 chars with high-entropy secrets masked to `•` | `clipboard-options` |
+
+Invariants (`clipboard-options`): raw clipboard text never appears in the event
+whatever the setting; identical consecutive reads dedupe to one event; an empty
+clipboard produces nothing; paused recording suspends capture and resuming
+restores it; detected credential *types* are recorded without copying the
+credential; a throwing loot detector does not lose the event.
+
+## 2.8 `browser`
+
+| Option | Default | Behaviour | Proof |
+|---|---|---|---|
+| `binary` | `''` | auto-detect per platform (incl. per-user Windows installs) | `browser-launcher` |
+| `proxy` | `http://127.0.0.1:8080` | adds `--proxy-server` **and** `--proxy-bypass-list=<-loopback>`; `''` omits both | `browser-launcher` |
+| `cdpPort` | `9222` | `--remote-debugging-port`; `0` omits it | `browser-launcher` |
+| `isolateProfile` | `true` | `--user-data-dir` + `--no-first-run`; `false` leaves the daily profile alone | `browser-launcher` |
+| `ignoreCertErrors` | `true` | `--ignore-certificate-errors` so the intercepting CA is accepted; `false` restores validation | `browser-launcher` |
+| `startUrl` | `''` | appended **last** so it is the target, not a flag value; omitted when empty | `browser-launcher` |
+| `extraArgs` | `[]` | appended before the start URL; empty strings dropped | `browser-launcher` |
+
+A fully stripped-down config produces no flags at all (`browser-launcher`).
+
+## 2.9 `redaction`
+
+| Option | Default | Behaviour | Proof |
+|---|---|---|---|
+| `entropyThreshold` | `4.5` | Shannon entropy above which a token is flagged | `redaction` |
+| `minLength` | `20` | shorter tokens are never entropy-flagged | `redaction` |
+| `denylist` | `[]` | substring match, or `/pattern/` for regex; a malformed regex is ignored | `redaction` |
+| `allowlist` | `[]` | suppresses an entropy match | `redaction` |
+
+Plugin-contributed denylist entries merge in and unregister cleanly
+(`redaction`); masking preserves span length (`redaction`, `secret-redaction`).
+
+## 2.10 `deconfliction`
+
+| Option | Default | Behaviour | Proof |
+|---|---|---|---|
+| `enabled` | `false` | disabled sends nothing | `deconfliction` |
+| `url` | `''` | empty sends nothing even when enabled | `deconfliction` |
+| `secret` | `''` | HMAC over the exact bytes sent | `deconfliction`, `signing` |
+| `events` | `marker, system, credential_use, c2_checkin` | only these agent types are forwarded | `deconfliction` |
+| `subtypes` | `scope_violation` | forwarded even when the agent type is not listed | `deconfliction` |
+| `includeData` | `false` | **the PII gate** — the event body is omitted unless this is on | `deconfliction` |
+| `authorityFloor` | `inferred` | lowest §3 authority tier to forward: `inferred` sends both tiers labelled; `fact` holds D2 proximity inferences back so the blue team only hears about observed rule matches (G-C2) | `deconfliction` |
+
+Events batch into a single POST and buffered events flush on shutdown rather
+than being dropped (`deconfliction`).
+
+## 2.11 `cloudShare` / `marketplace`
+
+| Option | Default | Behaviour | Proof |
+|---|---|---|---|
+| `cloudShare.endpoint` | `''` | empty falls back to the local `file://` stub uploader | `cloud-share`, `cloud-share-uploader` |
+| `cloudShare.authToken` | `''` | sent as `Authorization: Bearer …` on the upload init | `cloud-share-uploader` |
+| `cloudShare.maxBundleBytes` | unset → 100 MB | an oversized bundle is rejected and the zip cleaned up | `cloud-share` |
+| `marketplace.defaultRegistryUrl` | bundled example registry | **dormant**: `MARKETPLACE_ENABLED = false` shelves the only panel that reads it, so the setting currently has no UI consumer at all. The shelved state itself is asserted, so un-shelving trips a test rather than quietly reviving an untested option | `settings-interaction` |
+
+Bundle building is gated on the reviewed-by-operator flag and produces
+zip + `manifest.json` (`cloud-share`); registry installs enforce revocation,
+signatures, and tarball hash/metadata agreement (`marketplace`, `publisher-trust`).
+
+## 2.12 `fileWatcher` / `processMonitor` / `agentTailer`
+
+| Option | Default | Behaviour | Proof |
+|---|---|---|---|
+| `fileWatcher.enabled` | `false` | starts only with enabled + non-empty `watchPaths` + an engagement id; flipping off stops the watcher | `file-watcher` |
+| `fileWatcher.watchPaths` | `[]` | empty is a no-op even when enabled | `file-watcher` |
+| `fileWatcher.ignorePatterns` | `[]` | added on top of the built-in ignores | `file-watcher` |
+| `processMonitor.enabled` | `false` | off by default; Windows emits a one-shot advisory | `process-monitor` |
+| `processMonitor.pollMs` | `500` | poll cadence; floored at 200 ms, and at **2000 ms on Windows** where a cold PowerShell spawn is 800–1500 ms and a 500 ms cadence would stack calls | `process-monitor-cadence` |
+| `processMonitor.ignoreCommands` | `[]` | leading-token match, on top of the built-ins | `process-monitor` |
+| `agentTailer.enabled` | `true` | on by default; a `.redlog-app-root` marker opts a repo out | `agent-transcript-tailer` |
+| `agentTailer.emitThinking` | `false` | thinking blocks are excluded unless turned on | `agent-transcript-tailer` |
+
+---
+
+# Part 3 — Config file handling
+
+| Case | Behaviour | Proof |
+|---|---|---|
+| no `config.yaml` | full defaults | `config`, `config-options` |
+| corrupt / empty YAML | falls back to defaults, never throws | `config-options` |
+| partial file | merges over defaults; siblings keep their defaults | `config`, `config-options` |
+| arrays | **replace**, never concatenate | `config-options` |
+| explicit `[]` | wins over a non-empty default | `config-options` |
+| explicit `false` / `0` | kept, not treated as "unset" | `config-options` |
+| unknown key | carried through, not dropped | `config-options` |
+| save → load | round-trips every block | `config`, `config-options` |
+| `saveConfig` into a missing directory | creates it | `config-options` |
+
+Legacy migration (`config-options`, `config`):
+
+| Old | New | Note |
+|---|---|---|
+| `network.vpnIPs` | `network.whitelist` | legacy key is deleted so it cannot re-migrate |
+| `network.safeIPs` | `network.whitelist` | beats `vpnIPs` when both are present |
+| `network.dailyIPs` | `network.blacklist` | |
+| `network.exposedIPs` | `network.blacklist` | |
+| `scope.enforcement: warn` | `warnOnViolation: true` | |
+| `scope.enforcement: log` | `warnOnViolation: false` | the only value that meant quiet (and it did not even log) |
+| `scope.enforcement: block` | `warnOnViolation: true` | `block` was the strictest value the old field offered; answering it with silence would give less protection than was asked for, on a config nobody revisits |
+| `scope.enforcement: ` | `warnOnViolation: true` | unrecognised values fail loud, not quiet |
+| `scope.warnOnViolation: false` | `alertFloor: 'excluded_only'` | the boolean could only silence D2 (G-C3) |
+| `scope.warnOnViolation: true` | `alertFloor: 'adjacent'` | |
+
+An explicit `warnOnViolation` is never overwritten by a stale `enforcement` key.
+
+---
+
+# Part 4 — Renderer surfaces
+
+| Surface | Covered | Proof |
+|---|---|---|
+| every view mounts with one event of each agent type | crash-on-open regressions | `renderer-smoke` |
+| HUD verdict colour / label / flash / scale / emphasis / pass-through / mark buttons | §1.8, §2.5 | `alert-display` |
+| dashboard IP card: three verdicts, both addresses, hints, provider error | §1.8 | `alert-display` |
+| status bar: IP verdict, scope count, recording × capture-health dot, loot | §1.8 | `alert-surfaces` |
+| scope violations list, empty/not-set states, 10-row cap, chain length, export button | §1.8 | `alert-surfaces` |
+| HUD live-update subscriptions (Settings → open overlay) | §1.8 | `alert-surfaces` |
+| Settings controls: each toggle / number field writes the right config key, with its UI-layer coercion | §2.x | `settings-interaction` |
+| process-monitor poll cadence + platform floors | §2.12 | `process-monitor-cadence` |
+| one severity scale across both alarms; `worstSeverity` folding | §1.7.1 | `alert-severity` |
+| inferred-vs-observed rendering (dot shape), orthogonal to severity | §1.7.1 | `dot-shape` |
+| `authority` resolution + the stamp `insertEvent` writes | §1.7.1 | `authority`, `authority-stamp` |
+| `scope_violation` payload: `reason` + `authority` per rung | §1.7 | `scope-violation-event` |
+| registrable-domain table incl. operator-added suffixes | §2.3 | `public-suffix` |
+| settings search index covers every group | option discoverability | `settings-search` |
+| timeline lanes, axis, clustering, modes, wheel, geometry | timeline behaviour | `timeline-*` |
+| capture onboarding + readiness | empty/partial states | `capture-onboarding-render`, `capture-readiness`, `empty-state` |
+| keyboard shortcuts, split pane, lane visibility/colours | UI plumbing | `shortcuts`, `split-pane`, `lane-*` |
+
+Playwright (`npm run e2e`) covers the HUD overlay window, recording pause,
+project flow, command IO, marketplace flow, cloud-share flow, transcript view
+and timeline presentation against a built app.
+
+`ip-alert.spec.ts` is the alert path through real windows: a verdict pushed on
+`ip:status` turns the HUD frame red and flashes it, reaches the main window's
+status bar at the same time, clears back to cyan on `safe`, stays amber and
+distinct on `unknown`, and stops flashing the moment `flashOnExposed` is saved
+false — no restart. The verdict is pushed rather than provoked by a real egress
+lookup: the classifier has exhaustive unit coverage, and an e2e run must not
+depend on the machine's network position.
+
+**Not covered end to end:** a scope violation cannot be provoked from the
+renderer bridge (no "emit a shell event" IPC), so §1.6–1.7 stop at the unit
+level plus the manual walk-through in §5.3.
+
+---
+
+# Part 5 — Manual QA
+
+What no unit test can reach. Run before a release, or after touching the alert
+path.
+
+### 5.1 Trigger an EXPOSED alert end to end
+
+1. Settings ▸ Network: put your real egress IP (`curl ifconfig.me`) in the
+ **blacklist**, leave the whitelist empty. Set `confirmations: 1` to skip the
+ hold.
+2. Disconnect the VPN.
+3. Within `checkInterval` seconds the HUD frame turns red, flashes, and reads
+ `EXPOSED`; the dashboard card shows a red pulsing dot and the "check your
+ VPN" hint.
+4. Reconnect the VPN → back to `SAFE` (or `unknown` if no whitelist is set).
+5. Set `overlay.flashOnExposed: false` and repeat: still red, no flashing.
+
+### 5.2 Trigger the UNKNOWN (dropped-VPN) case
+
+Whitelist your VPN exit CIDR, blacklist your home IP, then connect through a
+third network (phone hotspot). The verdict must be `IP?` / amber — **never**
+green. This is the A-9 case from §1.1.
+
+### 5.3 Trigger a scope violation
+
+1. Settings ▸ Scope: `targets: *.app.example.com, 192.168.1.10`, warnings on.
+2. Run `curl vpn.example.com` in a hooked shell → D2 violation badge, red row in
+ Scope & Evidence, `system/scope_violation` event with
+ `reason: adjacent_domain`, `authority: inferred`.
+3. Run `nmap 192.168.1.55` → D2 again (`reason: adjacent_subnet`), because the
+ single-IP entry expands to `/24`.
+4. Run `curl google.com` and `nmap 8.8.8.8` → no alert (D3, by design); the
+ commands are still in the timeline.
+5. Add `dc01.app.example.com` to `excludeTargets`, set `alertFloor:
+ excluded_only`, hit it → still alerts (`reason: excluded_target`,
+ `authority: fact`). Hit `vpn.example.com` and `192.168.1.55` again → silent.
+6. Set `alertFloor: all` and run `nmap 8.8.8.8` → now it alerts too, as
+ `notice`-severity `unrelated`, visibly below the D1/D2 rungs.
+
+### 5.4 `overlay.showInDock` (macOS)
+
+Show the HUD with the option on → Dock icon stays. Turn it off → Dock icon
+disappears while the HUD keeps working. **This step cannot be automated**: the
+Dock change goes through the macOS activation policy asynchronously and
+`app.dock.isVisible()` keeps reporting the old value in an automated run, so an
+e2e assertion here would be a false green rather than coverage.
+
+### 5.5 `terminal.maxCastBytes`
+
+Set a small cap (e.g. 4096), run `yes | head -100000` in the built-in terminal,
+and confirm the `.cast` ends with the `[redlog: cast truncated at N bytes]`
+marker and the event records `castTruncated`.
+
+### 5.6 Wi-Fi SSID (`network.showWifiName`, macOS)
+
+Toggle it on, accept the Location Services prompt, and confirm the HUD shows the
+real SSID instead of a generic `Wi-Fi`. Toggle it back off — the HUD must drop
+to `Wi-Fi` straight away, not at the next poll. The SSID names the building you
+are sitting in and the HUD is the surface most likely to be in frame on a
+screenshot, so "off" has to mean off everywhere, immediately.
+
+---
+
+# Part 6 — Known gaps
+
+| ID | Gap | Impact |
+|---|---|---|
+| **G-UI1** | `overlay.showInDock` is the last manual-only option. `app.dock.hide()` changes the macOS activation policy asynchronously and the result does not surface through `app.dock.isVisible()` in an automated run, so the assertion would be a false green. | One setting, verified by §5.4. The other three originally filed here turned out not to need e2e at all: `authToken` was already covered (`cloud-share-uploader`), `pollMs` is decided synchronously (`process-monitor-cadence`), and `defaultRegistryUrl` is dormant behind a shelved panel. |
+
+Adding a config option? Add its default to the table in `config-options`, its
+behaviour to the relevant Part 2 section, and — if it changes what the operator
+sees — an assertion in `alert-display`.
+
+The first of those is **enforced, not requested**: `config-options` walks the
+real default config and fails on any option the table does not name (and on any
+table entry whose option has been removed). It found `network.staleAfter` and
+`scope.publicSuffixes` the first time it ran — both had reached Part 2 of this
+document but not the assertions, which is exactly the drift a sentence in a doc
+cannot prevent.
diff --git a/docs/TIMELINE-ELEMENTS.md b/docs/TIMELINE-ELEMENTS.md
new file mode 100644
index 00000000..718b4053
--- /dev/null
+++ b/docs/TIMELINE-ELEMENTS.md
@@ -0,0 +1,120 @@
+# Timeline Elements — the reconstruction surface, decomposed
+
+Written 2026-08-13. Applies `DECOMPOSITION-METHOD.md` to the timeline as a
+**structural lens**, complementing (not replacing) the design docs that own the
+timeline: `SPEC-TIMELINE-AXIS.md` (the axis refactor), `UX-TIMELINE-2026-08.md`
+(the UX diagnosis + T1–T6), and `DESIGN-PRINCIPLES.md` §8 (timeline = event map,
+transcript = I/O reader) / §9 (necessity). Those are the source of truth for
+*what to build*; this doc adds the closed **element taxonomy** — so the timeline's
+complexity has a structural name (channel/mode overloading) and every element
+declares whether it shows a fact or a suggestion.
+
+## The principle everything derives from
+
+The timeline is a **visual encoding surface**: it maps data dimensions to visual
+variables and exposes interactions over them. Its felt complexity (UX-TIMELINE's
+"invisible, overloaded, context-dependent") is **channel/mode overloading** — one
+visual channel carrying two data dimensions, or one gesture bound to two actions.
+The decomposition's payoff: **one channel = one data dimension; one mode = one
+action; every element declares its §3 authority.** When that holds, the surface is
+legible by construction.
+
+> **Implementation status varies by branch.** This is a *structural* taxonomy, not
+> a shipped-state guarantee. Some elements are live on `feat/ux-design-and-tickets`
+> (lane axis, phase ribbon, wheel/modes/keys resolvers), some only have their
+> underlying data on `main` with the visual still proposed (instance-ordinal
+> badges, shell bracketed-cast preview). Rows below tag **[shipped]** / **[data
+> only]** / **[proposed]** where sessions have confirmed; confirm against git
+> before relying on any as done.
+
+## Two families (mechanism axis)
+
+```
+Does it map a DATA dimension → a visual variable? → ENCODING CHANNEL
+Does it let the operator NAVIGATE or MUTATE? → INTERACTION MODE
+```
+
+## The authority axis (§3, applies to every element)
+
+Every element renders either a **fact** (operator marker, primary capture) or an
+**inferred suggestion** (ambiguous target, inferred phase) — and the two must be
+**visually distinct** (solid vs dashed/ghost) with one-click promotion. This is
+already the phase-ribbon design (solid markers vs dashed inference,
+`SPEC-TIMELINE-AXIS.md` §11); the taxonomy makes it a rule for *all* elements.
+
+## Encoding channels (closed set)
+
+Visual variables are a bounded set (Bertin: position, colour, shape, size/density,
+plus the temporal band, and composites of these — e.g. an instance ordinal =
+colour + text-value) — so this table is provably exhaustive over what the surface
+*can* encode.
+
+| Channel | Data dimension | Authority | Seam / source |
+|---|---|---|---|
+| **Position-X** | time (the one fixed anchor) | fact (wall-clock, but see clock-anomaly) | time scale |
+| **Lane (Y-group)** | organizing axis — `source` **or** `target`/phase (§8) | fact | `lanesForAxis(axis, events, …)` (implemented: header ⊞ toggle) |
+| **Colour** | source-type (kept as encoding even when lane = target) | fact | event `agent_type` |
+| **Glyph / shape** | event kind | fact | event registry |
+| **Instance ordinal** | which concurrent instance — `instanceOf(event)`: shell = `terminal_id` (ext-shell fallback `pid`), agent = `session_id`; HTTP/scanner `flow_id` **excluded** (per-request, not a persistent instance) | fact (recorded id) | **[data only]** `session_id`/`terminal_id` exist in event data on `main`; the **[proposed]** visual = coloured ordinal badge (dot corner) + detail chip (`term #2`) + cluster-popover `#N`, shown only when a lane has >1 concurrent instance |
+| **Density / cluster** | event count in a 14px time bucket | fact | `timelineCluster.ts` |
+| **Phase ribbon (band)** | phase segments over time | **fact (solid marker) + suggestion (dashed inference)** | `phaseRibbon.ts` |
+
+**The §8 fix restated in channel terms:** originally **lane and colour were both
+bound to source-type** — redundant, and the lane channel maxed out at 18 groups.
+§8 re-binds **lane ← target/phase**, freeing colour to keep encoding source without
+the 18-lane overload. One channel, one dimension.
+
+## Interaction modes (closed set)
+
+Interaction reduces to a bounded set — navigate, filter, select, mutate — so this
+too is exhaustive.
+
+Seams below are the **implemented** pure resolvers on the PR #8 branch (per the
+timeline-axis session), not spec placeholders.
+
+| Mode | Action | State visibility | Implemented seam |
+|---|---|---|---|
+| **Navigate** | pan / zoom / scroll the time axis | — (the overloaded one) | `lib/timelineWheel.ts` (`wheelMode` decision matrix) + `lib/timelineKeys.ts` (keyboard resolver) |
+| **Filter** | show/hide/solo lanes; source-type demoted to a filter (§8) | T3 active-modes row | `lib/timelineModes.ts` (`activeModes` — which non-default modes are hiding events) |
+| **Select / inline-preview → drill-down** | three-part (§8 refined): **axis = glyph** presence **[shipped]** (v0.9.2 `eventTitle` role prefixes `❯ ◂ ⚙ ↩ 💭`); **select = inline short I/O preview** — agent tier-2 **[shipped]** (v0.9.2 `AgentTurnDetail`, `CollapsibleStream startOpen`, *text* body), shell bracketed-cast stdout preview **[proposed]** (`CommandEndDetail` shows stdout/stderr today but not the bracketed-cast visual); **full narrative = transcript** **[proposed]** ("Open transcript" button, like today's "Open sidecar") | selection highlight | detail inline preview + transcript view |
+| **Mutate / promote** | inferred → authoritative (dashed phase → solid marker, §3) | the "suggest" affordance | phase-ribbon promote action |
+
+## Completeness
+
+- **Channels:** the surface can encode only via position, colour, shape, density,
+ a temporal band, or a composite of these (instance ordinal = colour+text-value)
+ — a closed set. Every current visual maps to one row; nothing falls outside.
+- **Modes:** an operator can only navigate, filter, select, or mutate — a closed
+ set. Every current gesture maps to one row.
+- **Authority:** every element is fact or suggestion (§3) — no third state.
+
+So the timeline is fully described by **7 channels × 4 modes × the fact/suggestion
+axis**. A new timeline feature is one of these, or it is over-encoding (see gaps).
+
+## Gaps — the UX-TIMELINE problems, restated structurally
+
+| # | UX-TIMELINE problem | Structural name | Fix |
+|---|---|---|---|
+| G1 | "same gesture does different things by invisible state" (wheel) | **one mode bound to two actions** (pan/zoom/scroll on the same wheel, switched by hidden state — the `timelineWheel.ts` `wheelMode` matrix) | T2 — one gesture, one action; make the binding explicit |
+| G2 | "every affordance invisible until `?`" | **modes have no state channel** — interaction state isn't encoded on the surface | T1 persistent legend + T3 active-modes row (give modes a visible channel) |
+| G3 | "18-colour problem" | **lane + colour both bound to source-type** (redundant, lane overloaded) | §8 axis re-bind: lane ← target/phase, colour ← source |
+| G4 | inference vs fact must not blur | **authority axis not uniform across elements** — only phase does solid/dashed today | extend solid-vs-dashed to every inferred element (ambiguous target, inferred tags) per §3 |
+| G5 | timeline trying to be the I/O reader | **Select mode scope** — where does I/O content live | §8 refined (three-part): axis = glyph; select = *inline short* preview in the detail; *full* narrative stays in the transcript/exchange view — the map never becomes the full reader |
+
+## Why this lens pays
+
+- It gives the timeline's complexity a **structural diagnosis** (overloaded
+ channel/mode) instead of a list of symptoms — each T-ticket becomes "un-overload
+ channel X / mode Y."
+- It makes "should this go on the timeline?" answerable: which channel or mode is
+ it? If none, it belongs in a paired surface (transcript/HUD/search, §8), not the
+ map.
+- It carries §3 onto every visual element by contract, not by memory.
+
+## Cross-references
+
+- Owns the axis design: `SPEC-TIMELINE-AXIS.md`
+- Owns the UX diagnosis + tickets: `UX-TIMELINE-2026-08.md`, `UX-BACKLOG-TICKETS.md` (T1–T6)
+- Surface split (map vs reader vs HUD vs search): `DESIGN-PRINCIPLES.md` §8, §9
+- Fact vs inferred suggestion: `DESIGN-PRINCIPLES.md` §3
+- The method + its variants: `DECOMPOSITION-METHOD.md`
diff --git a/docs/UIUX-STANDARD.md b/docs/UIUX-STANDARD.md
index 3ac2ddb6..28fb5c2a 100644
--- a/docs/UIUX-STANDARD.md
+++ b/docs/UIUX-STANDARD.md
@@ -23,11 +23,23 @@
| `text-faint` | — | `#7a7a84` | 分區標題、次要說明 |
| `muted` | `#71717a` | `#6a6a74` | placeholder、停用 |
| `accent`(品牌紅) | `#d75f63` | `#d75f63` | 導航選取、區塊標題、主要按鈕 |
-| `danger`(危險紅) | 與品牌紅同色 | `#ff4d4f` | 實心底 + 白字,僅危險 |
+| `danger`(危險紅) | 與品牌紅同色 | `#ff4d4f` | 實心底 + `on-danger` 深色字,僅危險 |
| `lane` | 18 種色相 | `#6e6e78` | 全部泳道統一 |
| `safe` / `warn` / `info` | `#5ecf9c` / `#d4ac5a` / `#3fc7d6` | 不變 | 狀態 |
-**紅色語意收緊**:品牌紅永遠只做文字或細線;危險紅永遠是實心色塊。全 app 同時只允許一處危險紅。
+**紅色語意收緊**:品牌紅只在**命令按鈕**上填色,其餘一律只做文字或細線;危險紅只填色,且可以填在任何地方。區別不靠「有沒有底色」,而靠「這是可以按的動詞,還是在報告狀態」。硬限制:**同一個動作區(對話框底部、工具列、卡片頁腳)不得同時出現實心品牌紅與實心危險紅**。搭配「一屏最多一顆主要按鈕」與「全 app 同時只允許一處危險紅」,這個情境不會發生。
+
+**填色一律配深色字**:這個色盤的彩色全是中亮度,白字在每一個上面都不過 AA——`#d75f63` 3.68:1、`#ff4d4f` 3.27:1、`#5ecf9c` 1.93:1、`#3fc7d6` 2.03:1。深色字全部過 5:1 以上。所以規則不分顏色、不分元件:**填色就配深色字**,涵蓋按鈕、徽章、chip、進度標示。
+
+| 填色 | 配的字色 | 對比 |
+|---|---|---|
+| `accent` `#d75f63` | `on-accent` `#16090a` | 5.3:1 |
+| `danger` `#ff4d4f` | `on-danger` `#1a0303` | 6.1:1 |
+| `warn` `#d4ac5a` | `on-warn` `#1a1203` | 7.2:1 |
+| `safe` `#5ecf9c` | `on-safe` `#03150d` | 9.7:1 |
+| `info` `#3fc7d6` | `on-info` `#031215` | 9.4:1 |
+
+不調暗 `#ff4d4f`——它的亮度正是與品牌紅拉開距離的手段。也不另開第三個紅。
**狀態唯一真相表**
@@ -35,7 +47,7 @@
|---|---|---|
| 正常/安全 | `#5ecf9c` | 圓點 + 文字,無實心底、無動畫 |
| 未知/閒置 | `#d4ac5a` | 圓點 + 文字 + 一句「怎麼補」 |
-| 危險/違規 | `#ff4d4f` | 實心徽章或實心按鈕,白字 |
+| 危險/違規 | `#ff4d4f` | 實心徽章或實心按鈕,`on-danger` 深色字 |
| 錄製中 | `#d75f63` | 閃爍圓點,僅狀態列與 HUD |
| 資訊/連線 | `#3fc7d6` | HUD 邊框、拓樸、minimap;不用於狀態判定 |
@@ -63,7 +75,10 @@
## 4. 元件
-- **按鈕**四級(主要/次要/安靜/危險),高 34px(緊密 30px),圓角 8px,focus 環 2px `#d75f6366` 外偏移 2px。同一畫面最多一個主要
+- **標誌**:單一字標 `REDL(●)G`,O 為錄製指示環。介面內不再並列「R」方塊與字標。環全部以 em 表示(換字級不必重算):外徑 `0.72em`(= Noto Sans TC 大寫字高)、環寬 `0.115em`(外徑 16%)、內點 `0.216em`(外徑 30%)、上移 `0.02em`;環必須 `box-sizing: border-box`,用 content-box 會讓外徑多出兩倍環寬。字標下限 12px。切角符號只用於 app 圖示、Dock、系統列、favicon,切角 = 邊長 22%(固定左上/右下;HUD 面板用的是 `lib/hud.ts` 的固定 11px/5px,因為面板尺寸不一,標誌是等比縮放所以改用比例),16px 以下環收成實心點。標誌永遠 `#d75f63`,不用危險紅、不動畫;單色場合用 `#ececf0` 或純黑;留白 ≥ 環直徑
+- **按鈕**四級,高 34px(緊密 30px),圓角 8px,focus 環 2px `#d75f6366` 外偏移 2px。同一畫面最多一個主要
+ - 主要 = 實心 `accent` + 深色字(`#16090a`)。這是 §1 允許品牌紅填色的唯一場合
+ - 次要 = `elevated` 底 + 1px 邊框;安靜 = 純文字;危險 = 實心 `danger` + `on-danger` 深色字
- **卡片**:取消頂部 2px 色條,改為左側色塊包住圖示(危險用實心,其餘用 12% 底色,與徽章規則一致)。卡片底色永遠是 `surface`,不因狀態改變
- **徽章**:12% 底色 + 同色文字 + 圓點;只有危險用實心
- **chip**:選取時 `elevated` + 1px 邊框,不用紅色
@@ -129,6 +144,9 @@
- **狀態列〈⏱ 時戳〉刪除** —— 與標記寫入同一張表,只差沒有標題與嚴重度,功能重複。改由標記對話框提供「快速落點」(HUD 已是此模型)
- **側欄搜尋頁刪除** —— 全部收進 ⌘K(導航、事件/標記/operator/host 模糊搜尋、動作、檢視模式與密度切換、專案切換、最近指令)。頁內篩選單獨給 ⌘F。設定項不進 ⌘K,由設定頁自己的搜尋框負責
+ - **operator 搜尋沒有前置條件**:`operators:list` 已是既有 IPC(`TranscriptView` 已在用),直接接上
+ - **host 搜尋不是面板的問題**:host 只存在於事件的 `data.host` JSON 裡,只有時間軸把事件載進記憶體才數得出來。要讓它全域可搜,需要 DB 端的 distinct-host 聚合——那與 §9「目標頁改 SQL 聚合(`GROUP BY detectedTarget`)」是同一件事,host 與 target 是同一組資料。兩者一起做,面板讀同一份聚合結果
+ - 時間軸本地面板懂泳道,是資料庫搜尋做不到的,保留為面板內的動作項
- **〈顯示 N 個機密〉與本機遮蔽全部刪除** —— 資料已在使用者自己的機器上。**例外:進入證據包、任何匯出、雲端分享、藍隊 webhook 的資料仍須經 redaction**,這條線要在程式裡分開實作
- **Token 不再靠使用者複製** —— 寫入 `~/.redlog/tokens/`(與專案分離,不會被證據包或匯出收進去)並在 Finder/檔案總管點亮
- **外掛信任** —— 一張對話框列出每項權限的具體影響 + 內容雜湊指紋 + 勾選框;「一鍵信任 registry 建議的發行者」改為逐一信任,並列出該發行者能簽署的外掛數
@@ -214,5 +232,6 @@
- [ ] 外掛信任流程改逐一信任 + 權限清單 + 指紋
- [ ] 卡片左側色塊改版(影響所有卡片)
- [ ] 移除狀態列〈⏱ 時戳〉
+- [ ] 字標改單一 `REDL(●)G`(標題列 + Project Picker);app 圖示/`.icns`/`.ico` 需向量原稿,不屬本期
**驗收**:同上 + 資訊架構走查(每個設定項 ≤2 層可達)· 虛擬列表在 28k 事件下的捲動效能 · 全畫面截圖比對
diff --git a/docs/agent-integration.md b/docs/agent-integration.md
index c3b7a396..1d1c051e 100644
--- a/docs/agent-integration.md
+++ b/docs/agent-integration.md
@@ -27,7 +27,7 @@ RedLog (Red Team Operation Log) is designed to work as a passive recorder for AI
**Passive hooks** capture everything without the agent knowing. **MCP/API** lets the agent actively query scope, create markers, and search history.
-## Two planes: hooks log, MCP operates
+## How agents capture: hooks log
The cleanest way to think about it:
@@ -253,266 +253,6 @@ SHELL=/path/to/redlog/hooks/codex-wrapper.sh codex run "scan the target"
- If called without arguments: starts an interactive shell with preexec hooks loaded
- Sets `REDLOG_SHELL_WRAPPED=1` env var so tools can detect the wrapper
-## 2. MCP Server (operate the app)
-
-The MCP (Model Context Protocol) server lets compatible agents operate RedLog — check scope before scanning, create markers for findings, anchor the chain, search history. Two transports; **HTTP is recommended**.
-
-### Setup — HTTP (recommended, app-hosted)
-
-RedLog hosts its own MCP server over HTTP from the process already running the API, so **MCP is live the moment the app is open** — no subprocess to spawn, no `node` on PATH, nothing to unpack from the app bundle.
-
-1. In RedLog: **Settings ▸ Team & Integrations ▸ MCP Server ▸ Set up MCP access.** This mints a dedicated, non-rotating operator token (so the registration survives restarts and MCP activity is attributed to its own identity) and shows a ready-to-paste command.
-2. Run it once:
-
-```bash
-claude mcp add --transport http redlog http://127.0.0.1:6660/mcp \
- --header "Authorization: Bearer "
-```
-
-`claude mcp list` should show `redlog … (HTTP) - ✔ Connected`. From then on, every time RedLog is open the endpoint is live.
-
-### Setup — stdio (fallback)
-
-For clients without HTTP-MCP support, the standalone bridge still works. It's a subprocess the client spawns, and in a packaged app the script lives inside the bundle's resources:
-
-```bash
-# Claude Code (dev checkout)
-claude mcp add redlog -- node /path/to/redlog/mcp/redlog-mcp-server.js
-
-# Cursor — .cursor/mcp.json
-{ "mcpServers": { "redlog": { "command": "node", "args": ["/path/to/redlog/mcp/redlog-mcp-server.js"] } } }
-```
-
-The stdio bridge discovers the running app via `~/.redlog/api-port` + `api-token` and proxies to the same tools. HTTP avoids the subprocess and the node-on-PATH requirement entirely.
-
-### Auto-Discovery
-
-The MCP server automatically discovers a running RedLog instance by reading:
-
-- `~/.redlog/api-port` — the HTTP API port (default 6660)
-- `~/.redlog/api-token` — the Bearer auth token
-
-These files are created when RedLog starts. If they don't exist, the MCP server returns an error message suggesting to launch RedLog first.
-
-### Available Tools
-
-#### `redlog_status`
-
-Returns current IP addresses (external/internal), IP safety flag, total event count, and scope violation count.
-
-**Parameters:** none
-
-**Returns:**
-```json
-{
- "ip": {
- "externalIP": "203.0.113.42",
- "internalIP": "10.0.1.5",
- "ipSafety": "safe",
- "lastCheck": 1706400000000,
- "error": null
- },
- "eventCount": 1247,
- "scopeViolations": 3
-}
-```
-
-#### `redlog_whoami`
-
-Return the operator identity that this token resolves to. Every event logged under this token is attributed to this operator. Call once at session start to catch loaded-wrong-token mistakes.
-
-**Parameters:** none
-
-**Returns:**
-```json
-{
- "operator": {
- "id": "codex-agent-a1b2c3",
- "name": "Codex agent",
- "isPrimary": false,
- "createdAt": 1706300000000,
- "revokedAt": null
- },
- "engagementId": "client-pentest-q3"
-}
-```
-
-#### `redlog_operators_list`
-
-List every operator token registered on this RedLog instance.
-
-**Returns:** `{ "operators": [ ... ] }` — each entry mirrors the `operator` object in `redlog_whoami`.
-
-#### `redlog_chain_status`
-
-Length of the SHA-256 hash chain and the latest OpenTimestamps anchor.
-
-**Returns:**
-```json
-{
- "length": 1247,
- "lastAnchor": {
- "id": "…",
- "headHash": "9f2c…",
- "eventCount": 1200,
- "status": "complete",
- "createdAt": 1706398800000,
- "calendarReceipts": [
- { "calendar": "https://a.pool.opentimestamps.org", "ok": true, "receiptB64": "…" }
- ]
- }
-}
-```
-
-#### `redlog_chain_anchor_now`
-
-Submit the current chain head to public OpenTimestamps calendars immediately. Use before wrapping up a session or right after a critical finding.
-
-#### `redlog_chain_verify`
-
-Fast check: does the latest anchor still describe a prefix of the current chain? Compares event counts (not a full re-walk).
-
-**Returns:** `{ "ok": true, "anchor": { … }, "currentHead": "9f2c…" }`
-
-#### `redlog_chain_upgrade`
-
-Fetch upgraded OpenTimestamps proofs for `pending` anchors. Calendars need a few hours to fold your digest into a Bitcoin block; before that, receipts are proto-proofs. RedLog auto-upgrades every 6 hours; call this to force it (e.g. right before exporting a bundle).
-
-**Parameters:** `anchor_id?: string` — omit to upgrade all pending anchors.
-
-**Returns:** `{ "upgraded": 3, "totalPending": 3, "anchors": [ … ] }`
-
-#### `redlog_mark`
-
-Creates a timestamped marker in the timeline — used for findings, phase changes, and notes.
-
-**Parameters:**
-
-| Param | Type | Required | Description |
-|-------|------|----------|-------------|
-| title | string | yes | Marker title |
-| notes | string | no | Detailed notes |
-| severity | string | no | `info` / `low` / `medium` / `high` / `critical` |
-| target_id | string | no | Associated target |
-
-**Example:**
-```json
-{
- "title": "SQL Injection in /api/users",
- "notes": "Parameter 'id' is injectable — time-based blind, PostgreSQL",
- "severity": "high",
- "target_id": "api.example.com"
-}
-```
-
-#### `redlog_log_event`
-
-Logs a raw event with custom agent type and data payload.
-
-**Parameters:**
-
-| Param | Type | Required | Description |
-|-------|------|----------|-------------|
-| agent_type | string | yes | Event category (`agent`, `scanner`, `recon`, `exploit`) |
-| data | object | yes | Event payload (freeform JSON) |
-| target_id | string | no | Associated target |
-
-#### `redlog_search`
-
-Full-text search across all events.
-
-**Parameters:**
-
-| Param | Type | Required | Description |
-|-------|------|----------|-------------|
-| query | string | yes | Search query |
-| limit | number | no | Max results (default 20) |
-
-#### `redlog_events`
-
-Query recent events with filters.
-
-**Parameters:**
-
-| Param | Type | Required | Description |
-|-------|------|----------|-------------|
-| agent_type | string | no | Filter by type |
-| target_id | string | no | Filter by target |
-| limit | number | no | Max results (default 50) |
-
-#### `redlog_scope`
-
-Returns scope configuration, target list, violations, and violation count.
-
-**Returns:**
-```json
-{
- "targets": ["*.example.com", "10.0.0.0/8"],
- "excludeTargets": ["10.0.0.1"],
- "warnOnViolation": true,
- "violations": [...],
- "violationCount": 3
-}
-```
-
-#### `redlog_config`
-
-Returns the full project configuration (engagement, operator, network, scope settings).
-
-#### `redlog_quickmark`
-
-Creates a bookmark for an interesting URL, endpoint, or finding.
-
-**Parameters:**
-
-| Param | Type | Required |
-|-------|------|----------|
-| title | string | yes |
-| url | string | no |
-| note | string | no |
-
-#### `redlog_quickmarks_list`
-
-Lists all bookmarks in the current project.
-
-#### `redlog_loot_scan`
-
-Scans arbitrary text for credentials, secrets, and sensitive data.
-
-**Parameters:**
-
-| Param | Type | Required |
-|-------|------|----------|
-| text | string | yes |
-
-**Detected patterns:** AWS keys, API tokens, JWTs, password hashes, private keys, database connection strings, CTF flags.
-
-#### `redlog_screenshot`
-
-Captures a screenshot and saves it to the project's screenshot directory.
-
-#### `redlog_recording`
-
-Controls recording state.
-
-**Parameters:**
-
-| Param | Type | Required | Description |
-|-------|------|----------|-------------|
-| action | string | yes | `pause` / `resume` / `toggle` |
-
-### Building a Claude Code Skill
-
-Ship-ready skill file at [`docs/skills/redlog-pentest.md`](skills/redlog-pentest.md). Install with:
-
-```bash
-cp docs/skills/redlog-pentest.md ~/.claude/skills/redlog-pentest.md
-# or per-project
-cp docs/skills/redlog-pentest.md .claude/skills/redlog-pentest.md
-```
-
-The skill covers the full loop: session start (`whoami` + `status` + `scope`), real-time findings via `redlog_mark`, loot scanning, and end-of-session `chain_anchor_now`. Read [the skill](skills/redlog-pentest.md) directly for the exact flow.
-
## 3. HTTP API
Direct REST API for scripts, custom agents, and non-MCP tools. Runs on `127.0.0.1:6660` (configurable).
@@ -538,7 +278,6 @@ curl -X POST http://127.0.0.1:$PORT/api/marker \
| Method | Route | Auth | Description |
|--------|-------|------|-------------|
| GET | `/api/health` | no | Health check (`{ ok, version }`) |
-| POST | `/mcp` | yes | MCP over Streamable HTTP (JSON-RPC) — also available at `/api/mcp` |
| GET | `/api/whoami` | yes | Resolve token → operator + engagement ID |
| GET | `/api/status` | yes | System status (IP, events, violations, capture health) |
| GET | `/api/capture` | yes | Detailed capture subsystem health |
@@ -556,11 +295,6 @@ curl -X POST http://127.0.0.1:$PORT/api/marker \
| POST | `/api/loot/scan` | yes | Scan text for secrets/credentials |
| POST | `/api/screenshot` | yes | Trigger manual capture |
| GET | `/api/operators` | yes | List operators (sensitive fields stripped) |
-| POST | `/api/operators` | primary | Create operator, returns token **once** |
-| PATCH | `/api/operators/:id` | primary | Rename |
-| POST | `/api/operators/:id/rotate` | self or primary | Rotate token |
-| POST | `/api/operators/:id/revoke` | primary | Revoke token |
-| DELETE | `/api/operators/:id` | primary | Delete operator |
| POST | `/api/terminal/replay` | yes | Replay terminal command output from `.cast` file |
| GET | `/api/chain` | yes | Chain length + latest anchor |
| GET | `/api/anchors` | yes | List OTS anchors (`?limit=`) |
@@ -572,8 +306,6 @@ curl -X POST http://127.0.0.1:$PORT/api/marker \
| GET | `/api/clock` | yes | NTP offset + last query time + host wall clock |
| POST | `/api/export/bundle` | yes | Produce signed evidence bundle in `exports/` dir |
| POST | `/api/sanitize` | yes | Layer 4 redaction: `{ event_ids, fields?, reason?, dry_run? }` → masked bytes + chained `system.sanitized` event |
-| GET | `/api/deconfliction` | yes | Current deconfliction config (secret redacted) |
-| POST | `/api/deconfliction/test` | yes | Send a test payload to the configured webhook |
### Event Schema
@@ -690,16 +422,6 @@ browser:
extraArgs: []
```
-## Multi-Operator Setup
-
-Each API token maps to one operator identity. The audit log tags every event with the operator resolved from the token — so several people (or several agents) can share one RedLog instance and stay distinguishable.
-
-Full lifecycle in [docs/operators.md](operators.md). TL;DR:
-
-1. **You** (primary): use RedLog's own generated token in `~/.redlog/api-token`. Rotated every app launch. Do nothing.
-2. **Teammate**: Settings ▸ General ▸ Operator Tokens ▸ **Add operator** ▸ copy token once ▸ hand over securely. On their machine: `echo -n '' > ~/.redlog/api-token && chmod 600 ~/.redlog/api-token`.
-3. **Distinct agent context** on the same machine (e.g. Codex vs Claude): create a secondary token and have that agent use `REDLOG_TOKEN=` instead of the file — patch the one hook that agent uses.
-
## Evidence Chain & OpenTimestamps
The `events` table is a SHA-256 hash chain (every row's `hash` covers the previous row's). Once an hour RedLog submits the chain head to three public OpenTimestamps calendars, producing receipts stored in `chain_anchors`. Anyone with a receipt can later prove the chain existed at that time — without touching your machine.
diff --git a/docs/api-reference.md b/docs/api-reference.md
index 33a449bb..a56b9c61 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -30,16 +30,6 @@ No auth required. Liveness check.
---
-### MCP
-
-#### `POST /mcp` (also `/api/mcp`)
-
-MCP over Streamable HTTP (JSON-RPC 2.0). Accepts a single message or a batch array. Returns `202` with empty body when no responses are produced.
-
-See [agent-integration.md § MCP tools](agent-integration.md#available-tools) for the 18 tool definitions.
-
----
-
### Identity
#### `GET /api/whoami`
@@ -182,30 +172,6 @@ Defaults: `title` → `"Untitled"`, `severity` → `"info"`, `category` → `"ex
**Response:** `{ operators: [{ id, name, isPrimary, createdAt, revokedAt }] }`
-#### `POST /api/operators` (primary only)
-
-**Body:** `{ name, id? }`
-
-**Response:** `201 { operator, token }` — token is returned **once**.
-
-#### `PATCH /api/operators/:id` (primary only)
-
-**Body:** `{ name }`
-
-**Response:** `{ renamed: boolean }`
-
-#### `POST /api/operators/:id/rotate` (self or primary)
-
-**Response:** `{ token: "" }`
-
-#### `POST /api/operators/:id/revoke` (primary only)
-
-**Response:** `{ revoked: boolean }`
-
-#### `DELETE /api/operators/:id` (primary only)
-
-**Response:** `{ deleted: boolean }`
-
---
### Terminal Replay
@@ -299,24 +265,11 @@ Writes masked bytes to `sanitized_events` table + appends chained `system.saniti
---
-### Deconfliction
-
-#### `GET /api/deconfliction`
-
-**Response:** Config object with `secret` masked as `"***"`.
-
-#### `POST /api/deconfliction/test`
-
-Send a test payload to the configured webhook.
-
----
-
## Endpoint Summary
| Method | Path | Auth | Category |
|--------|------|------|----------|
| GET | `/api/health` | no | Health |
-| POST | `/mcp` | yes | MCP |
| GET | `/api/whoami` | yes | Identity |
| POST | `/api/events` | yes | Events |
| GET | `/api/events` | yes | Events |
@@ -334,11 +287,6 @@ Send a test payload to the configured webhook.
| GET | `/api/recording` | yes | Recording |
| POST | `/api/recording` | yes | Recording |
| GET | `/api/operators` | yes | Operators |
-| POST | `/api/operators` | primary | Operators |
-| PATCH | `/api/operators/:id` | primary | Operators |
-| POST | `/api/operators/:id/rotate` | self/primary | Operators |
-| POST | `/api/operators/:id/revoke` | primary | Operators |
-| DELETE | `/api/operators/:id` | primary | Operators |
| POST | `/api/terminal/replay` | yes | Terminal |
| GET | `/api/chain` | yes | Chain |
| GET | `/api/anchors` | yes | Chain |
@@ -350,5 +298,3 @@ Send a test payload to the configured webhook.
| GET | `/api/clock` | yes | Clock |
| POST | `/api/export/bundle` | yes | Export |
| POST | `/api/sanitize` | yes | Redaction |
-| GET | `/api/deconfliction` | yes | Deconfliction |
-| POST | `/api/deconfliction/test` | yes | Deconfliction |
diff --git a/docs/audit-trail.md b/docs/audit-trail.md
index 6158b478..03ee8d25 100644
--- a/docs/audit-trail.md
+++ b/docs/audit-trail.md
@@ -179,6 +179,5 @@ redlog-cli chain verify --full
## Related
-- [Operators](operators.md) — who submitted which anchor
-- [Agent integration](agent-integration.md) — REST + MCP surface
+- [Agent integration](agent-integration.md) — REST surface
- [Skill: redlog-pentest](skills/redlog-pentest.md) — when to anchor during an engagement
diff --git a/docs/deconfliction.md b/docs/deconfliction.md
deleted file mode 100644
index 5e47306d..00000000
--- a/docs/deconfliction.md
+++ /dev/null
@@ -1,91 +0,0 @@
-# Deconfliction webhook
-
-When a red team runs against an environment that has an active blue team, the moment a probe fires an alert the SOC has to answer one question: **"is this us, or is this real?"** Without a deconfliction feed, that takes phone calls and Slack pings. With one, it's an instant lookup.
-
-RedLog can POST a signed JSON payload to a webhook you provide, for every event that matches your filter — typically `marker`, `scope_violation`, `credential_use`, and `c2_checkin`.
-
-## When you should turn this on
-
-- **Yes**: purple team, assumed-breach, or any engagement where the client's IR team is in the loop and expecting to be able to distinguish authorised traffic.
-- **No**: covert external red team where the blue team is being tested on their detection response. A deconfliction webhook defeats the test.
-- **N/A**: solo bug bounty, personal CTF, individual research.
-
-The Rules of Engagement should say explicitly which side of this line the engagement is on.
-
-## Configuration
-
-Add to your project's `config.yaml` (or edit through the UI — TODO: settings panel):
-
-```yaml
-deconfliction:
- enabled: true
- url: "https://soc.client.example/hooks/redlog"
- secret: "shared-secret-from-blue-team"
- events: # forward events with these agent_type values
- - marker
- - credential_use
- - c2_checkin
- - system
- subtypes: # also forward events with these subtype values
- - scope_violation
- # Since v0.6, RedLog also emits these system subtypes that are usually
- # worth forwarding to the SOC — uncomment as needed:
- # - ip_transition # external IP or safety state (safe/exposed/unknown) changed
- # - opsec_state_changed # VPN interfaces / DNS resolvers / MAC / hostname changed
- # - config_changed # scope / blacklist / enforcement changed (with a from→to diff)
- # - recording_paused # operator paused recording — explains any timeline gap
- # - recording_resumed
- includeData: false # if true, include full event.data — mind PII
-```
-
-## Payload
-
-```json
-{
- "id": "8f4c4d1e-…",
- "timestamp": 1706400000000,
- "engagement_id": "client-pentest-q3",
- "operator_id": "codex-agent-a1b2c3",
- "agent_type": "credential_use",
- "target_id": "vpn.corp.example.com",
- "hostname": "op-laptop-01.local",
- "hash": "9f2c…",
- "subtype": "successful_login",
- "description": "Reused leaked svc-backup credentials to establish VPN tunnel",
- "severity": "high",
- "mitre_ttp": "T1078"
-}
-```
-
-Header:
-
-```
-X-Redlog-Signature: sha256=
-```
-
-The blue team verifies by recomputing the HMAC with the shared secret. If it matches, the payload is authentic and they can close the ticket in seconds.
-
-## Retry behaviour
-
-If the webhook returns non-2xx or the connection fails, RedLog retries after 5s, 30s, then 120s. After the fourth attempt the notification is silently dropped — this is a best-effort feed, not a delivery-guaranteed queue. If reliability matters, front the webhook with something like a message queue.
-
-## Test button
-
-```bash
-curl -X POST http://127.0.0.1:$PORT/api/deconfliction/test \
- -H "Authorization: Bearer $(redlog-cli token)"
-# → { "ok": true, "status": 200 }
-```
-
-## Threat model
-
-- **The secret is shared** with the blue team. Rotate it after each engagement.
-- **HMAC-SHA-256** only proves that whoever sent the payload knew the secret at that moment. It does not prove non-repudiation — anyone with the secret could forge it.
-- **Payload is not encrypted in transit** unless your webhook URL is HTTPS. Always use HTTPS.
-- **PII / secret exposure** — with `includeData: true` the full event body ships to the blue team. Only enable if the blue team is authorised to see the same output your operators see.
-
-## Related
-
-- [Event schema](event-schema.md) — the standard keys that show up in `description` / `mitre_ttp` etc.
-- [Audit trail](audit-trail.md) — the hash chain your `hash` field references
-- [Operators](operators.md) — what `operator_id` maps to
diff --git a/docs/operators.md b/docs/operators.md
deleted file mode 100644
index 3ef54659..00000000
--- a/docs/operators.md
+++ /dev/null
@@ -1,144 +0,0 @@
-# Operators & API tokens
-
-Every event RedLog records carries an `operator_id`. That id is not a label you type into a form — it is resolved server-side from the **API token** the request was made with. Change token → change identity → different attribution in the timeline.
-
-This is the audit-log foundation that lets you answer "who did what" even when several people (or several agents) share one RedLog instance.
-
-## Model
-
-- Each project has an `operators` table stored inside its SQLite DB.
-- Each operator has one active `token_hash` (SHA-256 of a 32-byte random token). Plaintext is never stored.
-- Exactly one operator is marked `is_primary` — that's the human running the RedLog app on this machine.
-- Every other operator is a **secondary operator**: a teammate on another machine, or a distinct agent context (`codex-agent`, `mitmproxy-runner`, `manual-cli`, etc.).
-- Every REST / MCP / hook call arrives with `Authorization: Bearer `. The server hashes the token, looks it up in `operators`, and stamps the resolved id onto the event. Unknown or revoked → 401.
-
-## The primary operator
-
-Bootstrapped from `config.operator.{id, name}` when a project opens. Its token is **rotated to a fresh random 32-byte value every time the app starts** and written to `~/.redlog/api-token` (mode 0600). All local hooks read from that file, so they follow the rotation automatically — nothing to reconfigure.
-
-Rotation means: if `~/.redlog/api-token` leaks, the leak self-heals at next app launch.
-
-## Secondary operators
-
-Add one per teammate or per distinct agent context. Do this **before** they need to log anything.
-
-### Add via UI
-
-1. Settings ▸ General ▸ **Operator Tokens** ▸ enter a name ▸ **Add operator**.
-2. The token appears in a red banner **once**. Copy it now.
-3. Hand it to the teammate over a secure channel (Signal, 1Password share, in-person).
-
-### Add via CLI
-
-```bash
-redlog-cli operators add "Codex agent"
-# → Created: codex-agent-a1b2c3
-# Token (save now — not shown again):
-# <64-hex>
-```
-
-### Add via REST (from the primary operator)
-
-```bash
-TOKEN=$(cat ~/.redlog/api-token)
-PORT=$(cat ~/.redlog/api-port)
-
-curl -sX POST http://127.0.0.1:$PORT/api/operators \
- -H "Authorization: Bearer $TOKEN" \
- -H "Content-Type: application/json" \
- -d '{"name":"Codex agent"}'
-# → { "operator": { "id": "codex-agent-a1b2c3", ... }, "token": "<64-hex>" }
-```
-
-The `token` in the response is the only time you'll see it in plaintext. Save it.
-
-## Installing a secondary token on another machine
-
-Two options:
-
-1. **Overwrite the token file** (simplest, matches what hooks expect):
- ```bash
- mkdir -p ~/.redlog
- echo -n '' > ~/.redlog/api-token
- chmod 600 ~/.redlog/api-token
- # api-port needs to be set too if the machine isn't running its own RedLog
- echo -n '6660' > ~/.redlog/api-port
- ```
- Every existing hook (`claude-code-hook.sh`, `redlog-hook.zsh`, `mitmproxy-addon.py`, `redlog-agent.sh`) reads that file, so they now attribute events to the secondary operator.
-
-2. **Environment variable in one specific hook** (leaves the primary token intact for other tools on the same box). Edit the hook to prefer an env var:
- ```bash
- TOKEN="${REDLOG_TOKEN:-$(cat ~/.redlog/api-token)}"
- ```
- then launch just that agent with `REDLOG_TOKEN= claude`.
-
-## Rotate a token
-
-Rotation invalidates the old token immediately.
-
-- UI: Operator Tokens list ▸ **Rotate** next to the operator.
-- REST:
- ```bash
- curl -sX POST http://127.0.0.1:$PORT/api/operators//rotate \
- -H "Authorization: Bearer $TOKEN"
- # → { "token": "" }
- ```
-
-Rotating the primary operator also rewrites `~/.redlog/api-token`.
-
-## Revoke a token
-
-Softer than deleting: the operator row stays but any request with that token returns 401.
-
-- UI: **Revoke**.
-- REST: `POST /api/operators//revoke` (primary only).
-
-Past events keep pointing at that operator id in the timeline — you want that: audit logs of revoked accounts must remain attributable.
-
-## Delete an operator
-
-Hard delete of the row (primary cannot be deleted from the UI or REST).
-
-- UI: **Delete** (with confirm dialog).
-- REST: `DELETE /api/operators/`.
-
-## Verify from an agent
-
-At session start:
-
-```
-redlog_whoami
-# → { "operator": { "id": "codex-agent-a1b2c3", "name": "Codex agent", "isPrimary": false, ... }, "engagementId": "..." }
-```
-
-If the returned name doesn't match who's actually driving the session, you loaded the wrong token — halt and fix before continuing.
-
-## REST reference
-
-| Method | Route | Notes |
-|---|---|---|
-| GET | `/api/whoami` | Anyone with a valid token |
-| GET | `/api/operators` | Anyone with a valid token |
-| POST | `/api/operators` | Primary only. Body: `{ "name": "..." }`. Returns token **once**. |
-| PATCH | `/api/operators/:id` | Primary only. Body: `{ "name": "..." }` |
-| POST | `/api/operators/:id/rotate` | Self or primary. Returns new token **once**. |
-| POST | `/api/operators/:id/revoke` | Primary only |
-| DELETE | `/api/operators/:id` | Primary only, secondary operators only |
-
-## Threat model — what this does and doesn't protect
-
-**Does protect against:**
-- Confusion about which teammate ran a command
-- A stolen token being used indefinitely (rotate whenever suspected)
-- Deleted / renamed operators erasing their past activity from the timeline (they can't — events keep the old id)
-
-**Does not protect against:**
-- Someone with local root on the RedLog machine — they can read the SQLite file, alter `token_hash`, or forge events by writing directly to the DB. Combine with [audit-trail](audit-trail.md) OpenTimestamps anchoring to detect after-the-fact tampering.
-- A network attacker on 127.0.0.1 — the API only binds localhost; anyone with local access can read `~/.redlog/api-token`.
-- Sharing a token: two people using the same token are indistinguishable in the log. Give each teammate their own.
-
-## Related
-
-- [Skill: redlog-pentest](skills/redlog-pentest.md) — how an agent uses `redlog_whoami` at session start
-- [Audit trail](audit-trail.md) — how the chain + OpenTimestamps anchoring works
-- [Agent integration](agent-integration.md) — full REST / MCP / hook reference
diff --git a/docs/plugin-development.md b/docs/plugin-development.md
index b7dfd70c..5846c9f1 100644
--- a/docs/plugin-development.md
+++ b/docs/plugin-development.md
@@ -68,7 +68,6 @@ Every path referenced in the manifest must stay **inside** the plugin directory
"contributes": { … }, // required — see below
"capabilities": ["…"], // 🔴 only — what the code needs (least authority)
"signature": "…", // optional — reserved for signed publishing
- "publisher": "…" // optional — reserved
}
```
diff --git a/docs/screenshots/cloud-share.jpg b/docs/screenshots/cloud-share.jpg
deleted file mode 100644
index 78f00968..00000000
Binary files a/docs/screenshots/cloud-share.jpg and /dev/null differ
diff --git a/docs/screenshots/marketplace.jpg b/docs/screenshots/marketplace.jpg
deleted file mode 100644
index 6dfab2dc..00000000
Binary files a/docs/screenshots/marketplace.jpg and /dev/null differ
diff --git a/docs/skills/redlog-pentest.md b/docs/skills/redlog-pentest.md
index 3aa238bf..3e45d64c 100644
--- a/docs/skills/redlog-pentest.md
+++ b/docs/skills/redlog-pentest.md
@@ -171,7 +171,6 @@ Before you sign off, in this order:
If someone else is taking over the session on their own machine:
-1. Open RedLog ▸ Settings ▸ General ▸ Operator Tokens ▸ **Add operator** — give the teammate their own token.
2. Show the token **once** — copy it, don't screenshot it.
3. Teammate puts it in `~/.redlog/api-token` on their machine (overwrite existing) and re-launches whatever hooks they use.
4. From that moment on, every event they generate is attributed to **their** operator id — mixing you and them in the same timeline is exactly what audit logs are for.
@@ -193,5 +192,4 @@ Do not share the primary operator's token. It rotates every session anyway.
## Reference
- HTTP API + tool schemas: [docs/agent-integration.md](../agent-integration.md)
-- Operator token lifecycle: [docs/operators.md](../operators.md)
- Evidence chain + OTS verification: [docs/audit-trail.md](../audit-trail.md)
diff --git a/e2e/chrome-selection.spec.ts b/e2e/chrome-selection.spec.ts
new file mode 100644
index 00000000..4720cebf
--- /dev/null
+++ b/e2e/chrome-selection.spec.ts
@@ -0,0 +1,120 @@
+import { test, expect, type ElectronApplication, type Page } from '@playwright/test'
+import { launchWithTempHome, openTestProject, openView } from './helpers'
+
+// The "native desktop feel" work (PR #17) made the app's chrome behave like a
+// desktop app rather than a web page: dragging across the sidebar, the status
+// bar or the tab strip no longer paints everything blue, while content an
+// operator needs to copy stays selectable.
+//
+// It shipped with the rule written down and never checked in a running app —
+// the session that built it ended blocked on being able to launch one. This
+// spec is the objective half of what that session listed as outstanding: the
+// selection boundary is a computed style, so it can be measured rather than
+// squinted at. (Native OS context menus, the other two items, are outside
+// what Playwright can see and still need a human on each platform.)
+//
+// It also guards a boundary that moved afterwards. The export control (§10)
+// now lives in the title bar, which is both `select-none` chrome and a
+// `-webkit-app-region: drag` surface — and a button inside a drag region is
+// decoration, because the OS takes the mousedown to move the window.
+
+let app: ElectronApplication
+let page: Page
+
+test.beforeAll(async () => {
+ const launched = await launchWithTempHome()
+ app = launched.app
+ page = launched.page
+ await openTestProject(page, 'chrome-selection')
+})
+
+test.afterAll(async () => { await app?.close() })
+
+/** Resolved `user-select` for the first element matching a selector. */
+async function userSelect(sel: string): Promise {
+ return page.evaluate((s) => {
+ const el = document.querySelector(s)
+ if (!el) return null
+ return getComputedStyle(el).userSelect || getComputedStyle(el).webkitUserSelect
+ }, sel)
+}
+
+async function appRegion(sel: string): Promise {
+ return page.evaluate((s) => {
+ const el = document.querySelector(s)
+ if (!el) return null
+ return getComputedStyle(el).getPropertyValue('-webkit-app-region').trim()
+ }, sel)
+}
+
+test.describe('chrome is not selectable, content is', () => {
+ test('dragging across the sidebar does not select its labels', async () => {
+ expect(await userSelect('[data-view-btn="timeline"]')).toBe('none')
+ })
+
+ test('the title bar does not select', async () => {
+ // It is a drag surface. Text that highlights while you are moving the
+ // window is the single most obvious "this is a web page" tell.
+ const region = await appRegion('.h-10')
+ expect(region).toBe('drag')
+ expect(await userSelect('.h-10')).toBe('none')
+ })
+
+ test('the version string stays copyable inside that chrome', async () => {
+ // Deliberate exception: it is the first thing anyone reporting a bug is
+ // asked for, and it sits in the least selectable part of the app.
+ const sel = await page.evaluate(() => {
+ const el = [...document.querySelectorAll('span')].find((s) => /^v\d/.test(s.textContent ?? ''))
+ if (!el) return null
+ return {
+ userSelect: getComputedStyle(el).userSelect,
+ region: getComputedStyle(el).getPropertyValue('-webkit-app-region').trim()
+ }
+ })
+ expect(sel?.userSelect).toBe('text')
+ expect(sel?.region, 'a drag region swallows the mousedown that starts a selection').toBe('no-drag')
+ })
+
+ test('the export control is reachable, not decoration behind a drag region', async () => {
+ // §10 put one export control in the title bar. Inside a drag region the
+ // OS takes the mousedown to move the window and the click never lands —
+ // the button would look right and do nothing.
+ const btn = page.locator('[aria-haspopup="menu"]').first()
+ await expect(btn).toBeVisible()
+
+ // `-webkit-app-region` does not inherit — its initial value is `none`, and
+ // the drag surface is resolved by hit-testing the region boxes, not by
+ // reading the computed style of the element under the cursor. So the
+ // question is which region the button sits inside, which means walking up.
+ const region = await btn.evaluate((el) => {
+ for (let n: HTMLElement | null = el as HTMLElement; n; n = n.parentElement) {
+ const v = getComputedStyle(n).getPropertyValue('-webkit-app-region').trim()
+ if (v === 'drag' || v === 'no-drag') return v
+ }
+ return 'none'
+ })
+ expect(region).toBe('no-drag')
+
+ await btn.click()
+ await expect(page.locator('[role="menu"]')).toBeVisible()
+ await page.keyboard.press('Escape')
+ await expect(page.locator('[role="menu"]')).toHaveCount(0)
+ })
+
+ test('timeline content selects', async () => {
+ await openView(page, 'timeline')
+ expect(await userSelect('[data-testid="view-root"]')).toBe('text')
+ })
+
+ test('overlays outside the view root select too', async () => {
+ // Toasts routinely carry the error message going into a report, and
+ // confirm dialogs carry the exact path or session name. They render
+ // outside the view root, so they needed the exception spelled out; the
+ // failure mode is silent, because a dialog you cannot select looks
+ // completely normal until you try.
+ const containers = await page.evaluate(() =>
+ [...document.querySelectorAll('.select-text')].length
+ )
+ expect(containers, 'no select-text opt-outs found at all').toBeGreaterThan(0)
+ })
+})
diff --git a/e2e/cloud-share-flow.spec.ts b/e2e/cloud-share-flow.spec.ts
deleted file mode 100644
index d018e096..00000000
--- a/e2e/cloud-share-flow.spec.ts
+++ /dev/null
@@ -1,328 +0,0 @@
-import { test, expect, type ElectronApplication, type Page } from '@playwright/test'
-import { existsSync, mkdirSync, readFileSync, openSync, readSync, closeSync } from 'node:fs'
-import { fileURLToPath } from 'node:url'
-import { join } from 'node:path'
-import { launchWithTempHome, openTestProject } from './helpers'
-
-const SCREENSHOT_DIR = join(__dirname, 'screenshots')
-
-// Preload bridge slice for the cloud-share endpoints. Only the fields the
-// tests actually read need to be named — the rest of the surface lives in
-// src/renderer/src/env.d.ts.
-interface CloudShareBridge {
- preview: () => Promise<{
- ok: boolean
- preview?: {
- eventCount: number
- sanitizedEventCount: number
- sanitizedEventCountTotal: number
- approxSizeBytes: number
- screenshotCount: number
- castCount: number
- chainHead: { hash: string; eventCount: number } | null
- }
- error?: string
- }>
- prepare: (
- engagementId: string,
- reviewedByOperator: boolean
- ) => Promise<{ ok: boolean; zipPath?: string; error?: string }>
-}
-
-// Read the first N bytes of a file synchronously — used to sniff the ZIP
-// local-file-header magic (`PK\x03\x04`) so we prove the on-disk artefact is a
-// real archive, not just an empty placeholder.
-function readMagic(path: string, n: number): Buffer {
- const fd = openSync(path, 'r')
- try {
- const buf = Buffer.alloc(n)
- readSync(fd, buf, 0, n, 0)
- return buf
- } finally {
- closeSync(fd)
- }
-}
-
-// Shared electronApp across the four tests — one launch, one project, and
-// Test 2 depends on Test 1's UI state (Data tab already open, panel visible).
-// Same pattern as marketplace-flow.spec.ts.
-test.describe.serial('cloud share', () => {
- let app: ElectronApplication
- let page: Page
- let tmpHome: string
-
- test.beforeAll(async () => {
- const launched = await launchWithTempHome()
- app = launched.app
- page = launched.page
- tmpHome = launched.tmpHome
- // Force English so we can target Data-tab label / preview counts by text.
- await page.evaluate(() => localStorage.setItem('redlog-locale', 'en'))
- await openTestProject(page, 'e2e-cloud-share')
- })
-
- test.afterAll(async () => {
- if (app) await app.close()
- })
-
- test('preview shows counts + review gate disables Share until checked', async () => {
- // Settings is ⌘9 (dashboard=1..settings=9 order from project-flow.spec.ts).
- const mod = process.platform === 'darwin' ? 'Meta' : 'Control'
- await page.keyboard.press(`${mod}+9`)
- const viewRoot = page.locator('[data-testid="view-root"]')
- await expect(viewRoot).toHaveAttribute('data-view', 'settings', { timeout: 2_000 })
-
- // The Data tab hosts the Cloud share panel. The tab bar is a row of
- //