# AI Traffic Control — Conventions for AI Agents

Read this before generating any code in this repository. These conventions are enforced by ESLint and CI — code that violates them will fail to merge.

AI Traffic Control (`ai-tc`, by AKA Security — the `aka` CLI and plugin names come from
the company) is a **local-first** security control plane for AI coding agents. The whole surface
runs on one machine with **no server, no Docker, and no database engine**: the Claude Code
plugin, the Codex CLI plugin, the Antigravity plugin, the browser extension (ChatGPT +
Claude.ai web chat, bridged over Chrome native messaging — no port, no listener), and the
`aka` CLI capture agent activity into a local SQLite store at `~/.aka/data/aka.db`, and the
web dashboard reads that same store directly. There is no
account and no AKA backend — nothing is sent to a service AKA runs. (Narrow outbound paths
do exist — the default-on update notice's `npm view` lookups, package-manager installs, the
opt-in `/aka:setup` calibration, which sends raw findings to the model API via the `claude`
CLI, and an attached machine's forwarding to a control plane the USER's organization runs —
enumerated in §4. "No AKA backend" stays true across all of them: `aka attach` names a
deployment the customer operates, not one AKA does.)

## Tech stack

- **Language:** TypeScript strict mode, ESM everywhere (`"type": "module"`)
- **Monorepo:** pnpm workspaces + Turborepo
- **Runtime:** Node.js 24+ (the CLI and plugin hooks use the built-in `node:sqlite` — no native dependency); `.nvmrc`, CI, and `@types/node` all track the Active LTS line, matching the `engines` floor
- **Local store:** SQLite via `node:sqlite`, wrapped by `@akasecurity/persistence`; the schema is defined with Drizzle in `@akasecurity/schema`
- **Validation:** Zod schemas in `@akasecurity/schema` — the single source of truth
- **Web dashboard:** Next.js 15 + React 19 (Server Components read the store; Server Actions mutate it)
- **Testing:** Vitest
- **Packaging:** the `aka` CLI and the Claude Code / Codex CLI / Antigravity plugins, published to npm as self-contained bundles

## Architecture principles

### 1. Fail-open everywhere in the plugin

The plugin **must never break a user's Claude session**. Every hook entry wraps `main()` in
try/catch and then calls `process.exit(0)`, so a hook that fails writes **nothing to stdout and
exits 0** — which the hook protocol reads as "no opinion", i.e. allow. Failing open is the
_absence_ of output, not a payload.

**No hook emits `{ action: 'allow' }`, and none should start.** `action` is a `CaptureResult`
field that never crosses the wire; the internal fallback `handleCapture` returns when the runtime
throws is `{ action: 'log' }`, and `'allow'` is only ever assigned to an **excepted** finding.
Emitting a real opinion means writing one JSON object to stdout via `emit()`
(`plugins/claude-code/src/hooks/shared.ts`), which takes the `HookOutput` union rather than
`unknown`, so a payload outside that union cannot reach the wire at all. Its seven shapes are
`decision` and `systemMessage` at the top level, plus one `hookSpecificOutput` per hook —
`PreToolUse`'s `permissionDecision`, `PreModelSwitch`'s `permissionDecision`, `PostToolUse`'s
`updatedToolOutput`, `MessageDisplay`'s
`displayContent` and `SessionStart`'s `additionalContext`. The write is awaited, because
`process.exit` does not flush a pending pipe write and a truncated object reads as invalid JSON,
passing the original payload through unscanned.

That enumeration is derived rather than remembered, because this is the sentence that drifted last
time — it claimed four shapes while six were reaching stdout, and the two it omitted belong to the
two hooks nothing else in this file mentions. `plugins/claude-code/test/hook-output-shapes.test.ts`
reads it and drives it against the union: the count word, the two top-level keys, and each
`hookEventName` paired with the field that distinguishes it. A seventh variant added to
`HookOutput` fails to compile until that test's map names it, and fails that test until this
sentence does.

`plugins/claude-code/test/e2e/fail-open.e2e.test.ts` is what holds this rather than review: it
drives the built hooks against malformed, truncated, binary and oversized stdin, asserting exit 0
and empty stdout, and its `expectNoActionKey` fails any emitted payload carrying an `action` key.
That last check is worth **nothing** on the rows above it, which have already asserted the stdout
is empty — `''` matches no pattern. So the file also drives a real finding through each enforcing
hook at block, redact, warn and log, asserts the shape each cell is expected to emit (the positive
control, without which a hook that stopped emitting would turn every absence assertion back into
the vacuous form), and reads `expectNoActionKey` over the payload that produced. Keep both halves:
the fault rows prove silence, and only the enforcement rows can prove what the noise looks like.

**One path fails CLOSED on purpose: a field too large to scan in time.** Every capture path
that answers to a deadline builds its runtime with `largeInput`, so a field of
`LARGE_INPUT_THRESHOLD_CHARS` (32,768) or more is scanned in a worker under a hard deadline,
and every bounded scan in one hook process — or one native-host request — draws on one
deadline. Each sits inside whatever would otherwise answer for that path first:

- **Claude Code and Codex** (`plugins/{claude-code,codex}/src/hooks/large-input.ts`): 6 s
  from process start for UserPromptSubmit and PreToolUse, PostToolUse's existing 7 s
  field-loop deadline — inside the host's 10 s.
- **Antigravity PreToolUse** (`plugins/antigravity/src/hooks/large-input.ts`): 5.5 s from
  process start, inside `runHookFailOpen`'s 8 s WATCHDOG rather than the host's 10 s,
  because the watchdog resolves an undecided body with `{"decision":"allow"}` — a refusal
  still being decided when it fires is printed as an allow.
- **Copilot preToolUse** (`plugins/copilot/src/hooks/large-input.ts`): 15 s from process
  start, inside its 24 s watchdog, which resolves with silence — the no-opinion on both
  dialects.
- **The browser extension's native host** (`captureLargeInput` in
  `plugins/browser-extension/src/native-host/host.ts`): 2 s from when it receives a prompt
  capture, with a 500 ms worker start budget drawn from it, because `background.ts`
  answers a relay the host has not answered within `RELAY_TIMEOUT_MS` (3 s) with an error,
  and the content script lets the original message through on an error.

A field whose bounded scan times out or finds no worker comes back as a `block` with
`CaptureResult.scanIncomplete` set and empty `findings`, and the caller REFUSES it:
UserPromptSubmit blocks the prompt, PreToolUse denies the call — Antigravity with an
explicit `{"decision":"deny"}`, Copilot in each dialect's own deny shape — PostToolUse
withholds the output, and the native host answers `action: 'block'` with
`CaptureResponse.scanIncomplete`, which the interceptor shows as a too-large banner with no
exception route, leaving the message unsent in the composer. Why: `scan()` cannot be
interrupted (§5), so without the bound a large enough field keeps the hook busy past
whatever answers for it — the host's timeout, a watchdog, the relay — and every one of
those passes the call through unscanned, except Antigravity's kill, which denies it with no
reason. Failing open there IS the gap. The refusal is a RETURNED decision, never a throw, so
nothing else in this section changes: a throw, bad stdin, an unopenable store and a
PostToolUse field the loop deadline never reached all still pass (silently, or with
Antigravity's explicit allow), and a native host that does not answer at all still lets the
message through on the relay's timeout. Anything that skips clean fields, or keys a deny on
a flagged rule, must read `scanIncomplete` first, because a refused field and a clean one
both have no findings — Antigravity's and Copilot's decisions keyed their deny on a flagged
rule, and read a refused field as clean until they did. The Claude Code, Codex, Antigravity
and Copilot `fail-open.e2e.test.ts` drive the refusal through the built hooks with the scan
worker removed, and the extension's `test/native-host/scan-worker-bundle.e2e.test.ts` drives
it through the built native host — so every bundle that runs this path also has to SHIP the
worker, since a missing one refuses every large field.

**What "fall back to allow" means is the HOST's convention, not a fixed payload.** Claude Code
and Codex read "exited 0, said nothing" as "no opinion" — so a silent `catch` is genuinely
fail-open there. **Antigravity reads it as a `deny`**: a hook that exits non-zero, is killed on
its timeout, or prints nothing blocks the tool call outright. On that host, failing open means
_printing an explicit_ `{"decision":"allow"}` on every path — see
`runHookFailOpen` in `plugins/antigravity/src/hooks/shared.ts`. Before writing a fail-open path
for a new harness, check which convention it uses; assuming this one is what turns a crashing
hook into a wedged session.

**That guarantee reaches exactly as far as the event loop does, and the gap is not academic.**
`runHookFailOpen` races the body against a `setTimeout`, so it covers a throw, an undecided
body, and a body that outruns the watchdog — everything that _yields_. It cannot preempt a body
that blocks the thread, because nothing on the calling thread can. A block that clears inside
the host's own 10s still emits and exits 0 — the denial needs the block to outlast the HOST,
not merely the watchdog. Two blocking stretches sit on the capture path and compose:
`openLocalDatabase` is synchronous `node:sqlite` whose `PRAGMA busy_timeout = 2000` is charged
per contended **statement** rather than once per open (so the reachable total is a multiple of
2s), and §5's fast-path `scan()` is synchronous and in-process whenever the ruleset carries no
pulled or custom regex rule. `emit` sits outside the race as well, so a stalled pipe has no
deadline at all — and on that path the closing `process.exit(0)` is never reached either. This is §5's argument arriving at a second boundary — the fix for an unbounded stretch is
to move it off the thread, never to lengthen the timer — and the consequence here is harsher
than a missed scan: the condition that blocks (a contended store, a held lock) persists, so the
denial does too. Do not describe the explicit-allow as unconditional; it is unconditional over
what yields.

Both halves are covered, and by different suites because they fail differently.
`plugins/antigravity/test/hooks/fail-open-wrapper.test.ts` drives `runHookFailOpen` itself —
the throw, the undecided body, the watchdog win, a late rejection that must not surface as an
unhandled rejection (which would exit non-zero, i.e. deny, by a route the wrapper never writes
to), and the synchronous-block limit pinned as behaviour rather than prose. It also asserts one
JSON object, not merely a non-empty stdout: two concatenated objects do not parse, so a second
write is a deny exactly like silence.
`plugins/antigravity/test/e2e/fail-open.e2e.test.ts` is the Claude Code sibling's mirror image
and drives all four built hooks against empty, malformed, truncated, scalar, binary and
oversized stdin plus an unopenable store. Every assertion there is a **presence** check —
absence checks are the right shape on a fail-open host and the wrong one here, where `''` is
the failure being guarded against.

### 2. Contracts before code

`@akasecurity/schema` is the spine. The Zod schemas in `src/zod/` define every data boundary. Add shapes there before implementing them anywhere else.

**Do not create new types and interfaces — use the ones exported from `@akasecurity/schema` to the maximum extent.** Consumers (web-ui, CLI, plugin) import the schema types directly rather than redefining local "view-model" shapes or adapters. A new type is justified only when there is genuinely no schema equivalent (e.g. pure presentation descriptors like `{ label, icon, color }`). If a shape is missing, add it to `@akasecurity/schema/src/zod/` first, then consume it.

**The agent vocabulary is ONE registry, and outside it an id is spelled through a
member rather than as a literal.** Unlike §3 and §4, this is a CONVENTION and not a
gate: no lint rule and no derived audit enforces it, so read it as what review looks
for rather than as something CI will catch. Two neighbouring id spaces are
deliberately outside it and are not violations — `AGENT_PLUGINS[].id` in
`packages/local-ops/src/registry.ts` (the ref `aka plugins install` takes, which
carries its own `sourceTool` field alongside) and the browser extension's
content-script provider ids, which name a web app rather than a capture and whose
files import nothing from schema on purpose. That is a statement about how an
`AGENT_PLUGINS` id is SPELLED, and only that: the entries' install coordinates ARE
gated, by `packages/eslint-config/test/release-identity.test.js` — `pluginName`,
`marketplace` and `cliBin` are all present or all absent, `installHint` is present
exactly when they are absent (Antigravity's permanent state: that host has no
marketplace and installs from a local directory), every `npmPackage` names a tracked
non-private workspace package, and an entry whose marketplace is this repository
agrees with `.claude-plugin/marketplace.json`.
`src/zod/harness-map.ts` holds both spellings as named-member const objects —
`SOURCE_TOOL` (the wire id a plugin stamps on a capture, `'claude-code'`) and `HARNESS` (the
display id the dashboard renders, `'claudecode'`). Four rules keep them from re-multiplying,
which they had done into five hand-typed copies:

- **A narrower enum is `Harness.extract([...])` over MEMBER NAMES**, never a fresh
  `z.enum([...])` of the same strings. `Provider`, `HarnessId` and `FindingProvider` are each
  that, so a subset structurally cannot carry an id the registry does not define, while each
  keeps its own member ORDER (`Provider`'s is the dashboard's display order, and
  `.extract()` preserves the order passed).
- **Call sites spell `HARNESS.ClaudeCode` / `SOURCE_TOOL.ClaudeCode`, not the literal.** A
  literal that merely equals a member is invisible to a rename, which is exactly how these
  drifted. Keyed tables use computed member keys (`[HARNESS.ClaudeCode]: …`), which keeps
  `satisfies Record<Harness, …>` exhaustiveness — so **a table over a vocabulary is
  ANNOTATED `Record<ThatVocabulary, …>`**, and that one rule needs no per-table test. It
  covers the `.extract()` subsets exactly as it covers the whole enum: a table annotated
  `Record<HarnessId, …>` fails to compile the moment `HarnessId`'s extract list grows
  (TS2741 — verified, not assumed). What the compile error cannot reach is a collection
  keyed on NOTHING — an array of rows carrying its id in a field — because adding a member
  changes no type it mentions. Give it a key, or it owes a TEST.
- **Adding an id is one edit to the registry PLUS a deliberate decision per subset.**
  `.extract()` takes explicit member names, so a member added to `HARNESS` joins no subset
  on its own: it is a compile error at every table keyed on the whole enum (which is what
  prompts the lettermark and the kind), and silently absent from `Provider`, `HarnessId`
  and `FindingProvider` until each is extended on purpose. Until then it renders under no
  scan-coverage row, gets no label, and buckets to the miss path. That is the intended
  shape — subsets answer different questions and none should widen by accident — but do
  not read the compile error as telling you the work is finished.
- **The two vocabularies are joined by MEMBER NAME**, so `TOOL_TO_HARNESS` pairs them without
  either spelling being retyped, and a member in only one of them is meaningful rather than an
  omission (`Cli`/`Unknown` capture under no harness; `Windsurf`/`Api` render under no
  capture). Anything that reads the join BACKWARDS derives it — `toDbProviderFilter` is the
  inverse of that one table rather than a second map, because the hand-written copy it
  replaced had to be edited in step with it and nothing checked that it was. Deriving it
  gives up the exhaustiveness that `Record<FindingProvider, string[]>` carried, and no
  compile error replaces it, so the agreement is asserted as SETS in
  `packages/schema/test/zod/harness-map.test.ts` — every provider but the miss bucket must
  name at least one stored value. That case is the only non-vacuous one: the round-trip
  iterates the table itself, and the forward check's inner loop does not run on an empty
  array. It has to be non-vacuous because an empty result is the miss bucket's own contract
  (`'api'` → `[]`, matching any unknown value in-memory), so a provider with no rows reads
  as the miss bucket rather than as no findings.

Declared as const objects rather than TypeScript `enum`s deliberately:
`packages/plugin-sdk/src/scan-worker.ts` is loaded by raw Node under type **stripping** and
reaches schema through `@akasecurity/detections`, so an `enum` — which emits runtime code
rather than erasing — fails at load on that path only. The repo has no `enum` declarations,
and adding one anywhere schema can reach is what would break it.

### 3. `process.env` is off by default

ESLint (`n/no-process-env`) forbids reading `process.env` across the workspace — a violation is a CI failure, not a warning. Ten places in shipped source genuinely need the host environment and opt out. Three kinds of file are out of this table's scope and carry inline disables of their own: test harnesses that spawn the real hooks, the real installer scripts, and `tools/` — repo tooling that is never shipped, where a CI gate reads the runner's own output channels. All three are inventoried by `packages/eslint-config/test/inline-disables.test.js` instead, which reads the whole tracked tree rather than only what this table calls shipped:

| Site                                              | Mechanism                         | Why                                                         |
| ------------------------------------------------- | --------------------------------- | ----------------------------------------------------------- |
| `packages/plugin-sdk/src/provider.ts`             | file-scoped ESLint config         | LLM-provider resolution at SessionStart                     |
| `packages/plugin-sdk/src/provider-codex.ts`       | file-scoped ESLint config         | Codex LLM-provider resolution at SessionStart               |
| `cli/src/commands/dashboard.ts`                   | inline `eslint-disable-next-line` | spawning the dashboard server                               |
| `plugins/claude-code/src/backfill.ts`             | inline `eslint-disable-next-line` | the host session id the self-contamination guard skips      |
| `plugins/claude-code/src/triage/judge.ts`         | inline `eslint-disable-next-line` | the judge subprocess must inherit PATH/auth                 |
| `plugins/codex/src/triage/judge.ts`               | file-scoped ESLint config         | the judge subprocess must inherit PATH + `CODEX_HOME` auth  |
| `plugins/antigravity/src/triage/judge.ts`         | file-scoped ESLint config         | the judge subprocess must inherit PATH + `~/.gemini` auth   |
| `packages/plugin-sdk/src/provider-antigravity.ts` | file-scoped ESLint config         | LLM-provider resolution at Antigravity's first invocation   |
| `packages/plugin-sdk/src/bare-command.ts`         | file-scoped ESLint config         | the env an env-less Windows spawn inherits, for `where.exe` |
| `plugins/claude-code/src/hooks/session-start.ts`  | file-scoped ESLint config         | the interface the host stamps on its own subprocess env     |

Prefer a file-scoped config opt-out over an inline disable — an inline disable is invisible to anyone auditing the ESLint configs. Adding an eleventh site means updating this table.

That last sentence is enforced, not merely asked: `packages/eslint-config/test/effective-config.test.js` parses this table and drives each column against the thing it describes — the site against the tracked tree, the mechanism against the resolved config and the file's own text, the count word against the row count, and the row set against every opt-out shipped source actually carries. So a site that never reaches the table fails CI, and so does a row that outlives the exception it describes. The `Why` column is prose about intent and is guarded by nothing.

**Both mechanisms are inventoried, not just the config one.** An inline disable is invisible to
anything that resolves configs, so the check above would never have seen one had it been the only
check: `packages/eslint-config/test/inline-disables.test.js` reads the **directives** instead, over
every tracked lintable file, and asserts the set that disables `n/no-process-env` **exactly** — a
floor would let a new one in, which is the whole failure mode. Its expectation is wider than this
table on purpose, because this table is scoped to shipped source: the test harnesses that spawn the
real hooks carry inline disables of their own, and until that suite landed nothing inventoried
them at all. Two forms this table's language does not reach are refused outright there rather than
tabled — a bare `/* eslint-disable */`, which takes every rule with it, and an inline
`/* eslint <rule>: … */`, which can empty a ban while leaving it at `error`. Both run to the end of
the file, so both exempt code nobody has written yet.

### 4. No network calls

The product is **standalone by default**: it runs on Node + the SQLite store under `~/.aka` and talks to no AKA service — no account, no backend, no HTTP hop to anything — unless a user attaches the machine to a deployment their own organization runs (`aka attach`). That is opt-in, off until both an endpoint and an access key are on disk, and reversible with `aka detach`.

**`@akasecurity/remote` is the one package permitted to open a socket, and `src/http.ts` is the one module inside it that does.** Everywhere else the ban below is absolute and unchanged. Keeping the exception to a single module — rather than relaxing the ban for a package — is what keeps it reviewable: everything else in that package builds requests and parses answers, and only that file sends. It uses `node:https` rather than `fetch` for two reasons: Node's client follows no redirects, so a credential can never be replayed to whatever a `Location` header names, and the module bans (unlike the globals ban) take an `allow`, so the exception is a config artifact the opt-out audits below enumerate instead of an inline directive only a directive inventory can see.

ESLint enforces that across the workspace — a violation is a CI failure, not a warning. Four rules carry it (`no-restricted-globals`, `no-restricted-properties`, `no-restricted-imports`, `no-restricted-syntax` — all defined in `packages/eslint-config/src/index.js`), banning:

- the network globals `fetch`, `XMLHttpRequest`, `WebSocket`, `EventSource`, `WebTransport`, both bare and hung off a container (`globalThis.`/`window.`/`self.`/`global.`), plus `navigator.sendBeacon`;
- the modules `http`, `https`, `http2`, `net`, `dgram`, `tls`, `dns`, `dns/promises` (each in both the `node:`-prefixed and bare form) and the clients `axios`, `undici`, `got`, `node-fetch` (including their subpaths), in the static **and** the dynamic (`import()`/`require()`/`process.getBuiltinModule()`) form.

Ten files carry a genuine local-only opt-out:

| Site                                                                                                            | Allowed specifier                                      | Why                                                                                                                                                                                                                                                                                                                                                                 |
| --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `cli/src/commands/dashboard.ts` (via `cli/eslint.config.mjs`)                                                   | `node:net`                                             | `isPortFree()` binds a probe server on 127.0.0.1 to find a free port before launching the dashboard — a local bind                                                                                                                                                                                                                                                  |
| `cli/test/helpers/loopback.ts` (via `cli/eslint.config.mjs`)                                                    | `node:http`                                            | the suite for `aka scan`'s Data Shares forward stands a real server on loopback and reads the request off the wire — a stubbed transport shows what the command decided, never what it sent, and every claim that forward makes is about the bytes that left the process                                                                                            |
| `cli/scripts/smoke-dashboard.mjs` (via `cli/eslint.scripts.config.mjs`)                                         | `node:http`                                            | the CI smoke test polls the launched dashboard over loopback to confirm it came up                                                                                                                                                                                                                                                                                  |
| `test/setup/no-network.ts` (via `eslint.root.config.mjs`)                                                       | `node:net`, `node:dgram`, `node:dns`                   | the vitest no-network guard wraps connect/send/resolve on all three transports to refuse non-loopback egress                                                                                                                                                                                                                                                        |
| `tools/ci/egress-probe.mjs` (via `eslint.root.config.mjs`)                                                      | `node:net`                                             | the CI egress probe opens a TCP socket to a loopback listener before trusting a failed connect                                                                                                                                                                                                                                                                      |
| `packages/eslint-config/test/no-network-runtime.test.js` (via `packages/eslint-config/eslint.guard.config.mjs`) | `node:net`, `node:dgram`, `node:dns`, `fetch` (inline) | the runtime half of the no-network guarantee imports the three transports to drive real connect/send/resolve calls against the patched guard; its one real `fetch()` carries an inline disable                                                                                                                                                                      |
| `packages/remote/src/http.ts` (via `packages/remote/eslint.config.mjs`)                                         | `node:http`, `node:https`                              | the control-plane transport — the one module that sends anything, and only to the deployment a machine's own settings name. `node:https` rather than `fetch` because Node's client follows no redirects, so a credential can never be replayed to a `Location` host; `node:http` is reachable only for a loopback endpoint, which `isSafeEndpoint` is what enforces |
| `packages/remote/test/helpers/loopback.ts` (via `packages/remote/eslint.config.mjs`)                            | `node:http`                                            | that transport's suite stands a real server on 127.0.0.1 — a deadline firing, an oversized body refused, a 3xx not followed are socket behaviours, and a mocked transport would leave all three unproven                                                                                                                                                            |
| `tools/installer/test/helpers/serve-release.ts` (via `tools/installer/eslint.config.mjs`)                       | `node:http`                                            | the installer suite serves its fixture release over loopback so the shipped `install.sh`/`install.ps1` run against a local base — the only base BOTH take, since PowerShell rejects a `file://` URI                                                                                                                                                                 |
| `web-ui/test/helpers/loopback.ts` (via `web-ui/eslint.config.mjs`)                                              | `node:http`                                            | the Scan page's Data Shares forward is a Server Action that sends, so its suite stands a real server on loopback and reads the request off the wire — a stubbed transport shows what the action decided, never what it sent, and every claim that forward makes is about the bytes that left the process                                                            |

All are **file-scoped**, never package-wide, and drop the static and dynamic bans together (`noNetworkImports` + `noNetworkSyntax`) so the exception holds whichever import form the file uses; every other network module stays banned in those same files. The one **global** opt-out — the runtime suite's deliberate `fetch()`, marked `fetch` (inline) above — is an inline `eslint-disable`, not a config `allow`, because `noNetworkGlobals()` (unlike its import/syntax siblings) takes no `allow` option, so §3's preference for a config opt-out cannot be met for a global today. It is pinned instead by the raw-guard measure in `no-network-runtime.test.js` (which lints with inline config **off**, so it sees the disabled `fetch` and would catch a second one), not by the `DOCUMENTED_OPT_OUTS` audit, which reads `no-restricted-imports` paths and structurally cannot see a global. Adding another opt-out site means updating this table.

**Inline disables are inventoried too, and across the whole tree rather than four files.** Every
audit named above resolves **configs**, so none of them can see an inline `eslint-disable` — and
one line of it admits `node:https` into any package's shipped source with `pnpm lint` at exit 0.
The raw-guard measure closes that for the four files it pins and only those.
`packages/eslint-config/test/inline-disables.test.js` is the tree-wide half: it reads the
directives in every tracked lintable file and asserts the set disabling any of the four network
rules **exactly**. That set is one entry long — this section's `fetch` (inline) — and an exact set
is the point, since a guard that only forbids removals lets the next one in. It also refuses the
two forms this table has no language for: a bare `/* eslint-disable */`, which disables all four at
once, and an inline `/* eslint <rule>: … */`, which can empty a ban while leaving it at `error`.

**Which configs that audit reads is derived, not globbed.** An ESLint config enforces
something exactly when a `lint` script points ESLint at it, so `no-network.test.js` walks the
eslint invocations a green `pnpm lint` really runs — every `-c` / `--config` / `--config=`
target, plus, for an invocation carrying none, the config ordinary flat-config lookup finds
from the directory that invocation runs in (asked of ESLint's own `findConfigFile`, not
modelled). A filename glob was the earlier answer and is a hole one rename wide: ESLint
honours `-c eslint.extra.config.js` exactly like the two conventional names, so a third
config was referenced by the lint script, applied on every pass, and inspected by nobody —
while the suite's own test count went _up_, because the extra invocation generates more probe
targets elsewhere. Two consequences for anyone adding a config:

- **Name it anything and it is still audited** — but an opt-out in it must then appear in
  `DOCUMENTED_OPT_OUTS` and in the table above, exactly as for the conventional names.
- **A config no invocation runs is a failure, not a no-op.** Dead config enforces nothing
  while reading like a lint surface, so the audit differences the tree's `*eslint*.config.*`
  files against the derived set and names anything left over. Wire it into a `lint` script or
  delete it.

**The BEHAVIOURAL prober is derived the same way, and for the same reason.** Knowing a config
is audited says nothing about whether its bans actually fire, so `PROBE_TARGETS` resolves each
package's real config and drives the four bans against a snippet. It used to pair a directory
with a config inferred from the directory's NAME — everything under `eslint.config.mjs`, plus a
hardcoded `scripts/` case — which is the same one-rename hole one level over: this package's own
`test/` runs under `eslint.guard.config.mjs`, so it was paired with a sibling config that
`--no-config-lookup` guarantees never applies there, and the enforcement suites sat behind a
config no probe exercised. Switching the ban off in it left every test green. Each pair now
comes from the invocation that really lints the path, so a THIRD pass shape is probed by
construction; and a path whose invocation resolves no config is reported rather than skipped,
because a shape nobody modelled would otherwise generate no case at all and a suite that runs
no assertion reports green.

The reader itself — which invocations a green run makes, which config each runs under, and
which paths each covers — is one shared module
(`packages/eslint-config/test/helpers/lint-invocations.js`), used by both that audit and the
per-package lint-coverage check in `effective-config.test.js`. Two readers of one shell string
would be free to disagree about what runs, which is how a file ends up covered by one guard
and audited by neither. `trackedFiles()` sits there for the same reason and is the one
`git ls-files` walk every audit in the package asks — including the ones below, which ask what
the tree really holds rather than what a lint script says.

`DOCUMENTED_OPT_OUTS` is a hand-written mirror of that table, and it is not what keeps the table true — it never opens this file. `packages/eslint-config/test/no-network.test.js` parses the table and asserts it twice: against that mirror, so the two cannot drift apart in either direction, and against the configs themselves, resolving each row's site through ESLint under the config the row names. The second is what covers the **Site** column, which the mirror does not carry and so cannot check — a row may not name a file the config never reaches, nor keep an exception that has been removed. Any entry in the **Allowed specifier** column that is neither a banned module nor a banned global marked `(inline)` fails rather than being skipped, since a token the module audit cannot see is enforced by nothing.

Both of those read the TABLE and check it against the workspace. The promise this section closes on — that a new opt-out site is documented here — runs the other way, so it is asserted the other way: the `files:` pattern on every config entry that permits a network specifier is derived from the configs and differenced against the tabled sites, in both directions. **So a site that never reaches this table fails CI** — for an opt-out written as an `allow` on either half of the ban, as an `off`, or as a severity naming nothing, since a rule that is merely PRESENT is not the same as one that bans something. Both halves are read: an opt-out relaxing only `no-restricted-syntax` permits its specifier through `import()` exactly as a static one does, and reading only `no-restricted-imports` made that invisible in both directions. The **globals** ban stays outside this audit, as the paragraph above says — `no-restricted-globals` carries no `allow`, so there is nothing to difference, and a file-scoped `off` on it is caught by the inline-disable inventory rather than here. Two things about it are load-bearing. A row whose exception has gone is caught on the SET rather than only on the count sentence — which matters precisely because two rows share `eslint.root.config.mjs`, so the config-keyed mirror keeps that key from the surviving row and moves not at all. And the **pattern** is compared rather than the files it currently resolves to: widening `tools/ci/egress-probe.mjs` to `tools/ci/*.mjs` reaches the same single file today while silently granting the exception to every `.mjs` added afterwards, so a basename carrying a glob is refused outright as the "file-scoped, never package-wide" violation it is, and a resolved-file comparison would have passed until somebody landed a second file.

Network access happens through child processes in every path but ONE. In all but the external-dispatch path, this repo chooses the program and its arguments; in that one it chooses neither. The exception is listed last and is the only place the source itself opens a connection:

1. `@akasecurity/local-ops` shelling out to package managers (`npm`/`claude`/`codex`) for update-and-apply. **Two shapes, and only one is user-initiated.** `aka update`, `aka check-updates` and `aka plugins install` run because someone typed them. The passive **update notice** does not: `cli/src/main.ts` calls `notifyFromCache` after every command outside `SKIP_NOTICE` when stdout is a TTY, and a stale cache spawns a detached `aka __update-refresh` whose `gatherReport` asks `npm view` for the CLI package plus every `AGENT_PLUGINS` entry carrying BOTH a `pluginRef` and an `npmPackage` — three today, and asked for whether or not the plugin is installed. It is on by default with no prompt and `--no-update-check` opts out for a single invocation only. Say so wherever this path is enumerated: a reader who sees only "update-and-apply" concludes a bare `aka stats` opens no connection, and both READMEs' `[^egress]` footnotes are held to naming it.
2. The Claude Code plugin's `npm audit signatures` child process — written to run from inside the plugin's dependency closure (a plugin script or `@akasecurity/plugin-sdk`, since the plugin cannot import `@akasecurity/local-ops`). **It is NOT reachable in the shipped artifact today**, and that is why it is still listed: `verifyProvenance` is called only by `buildVerifiedIntroCard`, which no `src/` file invokes — the shipped `intro.ts` calls the plain card builder and says so — so esbuild drops it and the spawn is in none of the built scripts. The READMEs' `[^egress]` footnote counted it for as long as `EGRESS_PATHS` was checked against the prose alone; it does not any more, and `privacy-claims.test.ts` now holds each row this plugin implements to the bundle, so wiring it back means restoring the row and the disclosure together. Keep the entry rather than deleting it: the code is still here, and a reader auditing "what could spawn" needs to find it.
3. The `/aka:setup` wizard's judge subprocess (`plugins/claude-code/src/triage/judge.ts`), which spawns `claude -p` and **sends findings to the model API** so it can rate false positives and severity. `runJudge` serializes a minimized projection (`toJudgePayload`), not the whole `TriageHit`: `rawMatch` (the raw, unmasked secret) crosses, along with `context` (a ±120-character window of the surrounding transcript text — see `plugins/claude-code/src/history/scan.ts` — re-masked with `maskText`, which scans the **bundled** packs rather than the installed set — coverage is still complete because `buildTriageHit` has already redacted every other finding from the full-ruleset scan, and this hit's own value is masked here where it appears in the window; `rawMatch` is therefore the only raw value that crosses), `id` (a sequential counter the rubric requires the model to echo), and the non-sensitive scoring labels `ruleId`, `category`, `severity`, `maskedMatch` and `confidence`. `filePath` (the source transcript's path), `valueFingerprint` (an HMAC of the secret), and `keyVersion` are dropped before egress — a new `TriageHit` field is not disclosed to the model unless `toJudgePayload` and the disclosure copy are updated together. A large history is chunked, so this is several `claude -p` calls, not one. It runs only on the user's explicit opt-in during setup — a consent distinct from the historical-read grant, recorded as `modelJudgeConsent` and re-checked against `MODEL_JUDGE_PAYLOAD_VERSION` on every run, so widening the payload invalidates consents given for the old one. `historicalAccess` gates the READ only — a `full` grant authorizes no egress by itself, and no consent surface may imply that it does. Both grants are revocable under Settings, where **Historical access** and **Model-judge consent** are separate controls; revoking stops future scans but cannot recall what was already sent. The subprocess asks the CLI to suppress its transcript (`CLAUDE_CODE_SKIP_PROMPT_HISTORY=1`), but that is transcript isolation, **not** network isolation — a copy of the raw values leaves the machine, because the whole point is to reach the model. Consent copy must state the payload, the egress, and that limit on revocation plainly; it must never be described as staying "inside an isolated subprocess." Four surfaces carry that copy and move together: `plugins/claude-code/commands/setup.md`, the `[^egress]` footnote in each of the two READMEs that describe the judge (the root and Claude Code plugin pages), and the Settings copy in `packages/dashboard-ui/src/settings/WorkspaceSettingsFormView.tsx`. `cli/README.md` carries an `[^egress]` footnote too, but it is not one of the four: the CLI ships no judge, so that page discloses the CLI's own outbound paths and points at the plugin page for the payload rather than restating it — which is why widening the payload must not add a fifth surface there.
4. **Git-style external subcommand dispatch** (`cli/src/lib/external-dispatch.ts`). `aka <name>` execs `aka-<name>` from the user's `PATH` when no built-in owns the name, inheriting the caller's environment and stdio. The child is resolved by name at call time — this repo does not bundle, depend on, verify or version-pin it — so its behaviour, including any network access, is outside what this codebase can describe. AKA Security ships one intended occupant, `aka-claude` from `claude-tools`, which launches a Claude Code profile and is network-bound by definition; the dispatch gives it no special status, and any other `aka-*` on `PATH` runs identically. A built-in always wins, so this can never shadow a shipped command, and the path is POSIX-only (disabled on win32). An allowlist or provenance check is a deliberate non-goal: the precondition for abuse is write access to a `PATH` directory, which already permits shadowing `aka` itself. The invariant that is enforced is that a built-in always wins.
5. The Codex `aka-setup` wizard's judge subprocess (`plugins/codex/src/triage/judge.ts`), which spawns `codex exec` and sends the same minimized `toJudgePayload` projection to the model API as the Claude Code judge — `rawMatch`, the re-masked `context` window, and the sequential `id`; never `filePath`, `valueFingerprint`, or `keyVersion` — chunked into several `codex exec` calls for a large history, under the same distinct `modelJudgeConsent` opt-in, the same `MODEL_JUDGE_PAYLOAD_VERSION` re-check, and the same revocation limit (revoking stops future scans but cannot recall what was already sent). The `--ephemeral` flag stops the judge session from being written under `~/.codex/sessions` (which AKA's own backfill scans — a persisted judge session would re-ingest the raw findings it carries), but that is session-persistence isolation, **not** network isolation. The same consent-copy honesty rules apply (`plugins/codex/skills/setup/SKILL.md`).

6. The Antigravity `aka-setup` wizard's judge subprocess (`plugins/antigravity/src/triage/judge.ts`), which sends the same minimized `toJudgePayload` projection to the model API as the other two judges — `rawMatch`, the re-masked `context` window, and the sequential `id`; never `filePath`, `valueFingerprint`, or `keyVersion` — under the same distinct `modelJudgeConsent` opt-in, the same `MODEL_JUDGE_PAYLOAD_VERSION` re-check, and the same revocation limit. **This host is still weaker than the other two, and the consent copy must say so.** Its CLI documents no `exec` subcommand and no ephemeral mode, so every judge run **persists** a conversation under `~/.gemini/antigravity/brain/<conversationId>/` — the same store AKA's own backfill sweeps — which this module then deletes itself in a `finally`, best effort only (a killed process leaves it). Deletion is a local-write cleanup, **not** network isolation. Attribution for that deletion is deliberately conservative — the reported `conversation_id`, else a newly-appeared conversation only when exactly one appeared — because deleting a conversation the user started is unrecoverable while leaving a judge conversation merely re-surfaces their own known secrets. The prompt does **not** ride argv: the entrypoint is `agy --input-format stream-json --output-format stream-json` with one NDJSON `user` event on stdin, closed to end the session, so the raw values are off `ps`, off any echoed command line and off `ARG_MAX` entirely — the same stdin shape the other two judges use. The two `stream-json` formats are paired because the host documents that pairing (a streaming input against a non-streaming output emits its one envelope only as the process exits), and the consequence for `parseEnvelope` is that the verdict is one line of NDJSON rather than the whole of stdout. The same consent-copy honesty rules apply (`plugins/antigravity/skills/setup/SKILL.md`).

7. **The attached control plane** — the one path that is NOT a child process, and the only place this repo's own source opens a socket. A machine attached with `aka attach` (see §4's opening paragraph) forwards captures, inventory and audit events to the deployment its settings name, and pulls that deployment's policy bundle. Everything goes through `@akasecurity/remote`, whose `src/http.ts` is the single module carrying a transport import; the gateway in `packages/plugin-runtime/src/attached/` forwards on the write path, the detached `sync.js` child pulls policy, and both manual scan surfaces — `aka scan` and the dashboard's Scan page — forward the Data Shares register they just recorded (destinations and call sites, no source text; `--no-forward` skips the CLI's for one invocation). It is inert until BOTH an endpoint (in `settings.json`) and a credential (`~/.aka/settings/control-plane-credential.json`) are present and name the same host, and `aka detach` removes it. Unlike the judge paths above, what crosses is the activity the organization is entitled to see rather than a one-off opt-in payload — so the disclosure lives in the READMEs' `[^egress]` footnote, whose count is derived from `EGRESS_PATHS` in `plugins/claude-code/test/privacy-claims.test.ts` rather than written by hand.

These are the **shipped product's** egress paths. Repo CI additionally talks to the npm
registry: `.github/workflows/audit.yml` (via `tools/audit-gate`) runs `pnpm audit` on every
PR and daily, sending the workspace dependency graph — package names and versions,
including the `@akasecurity/*` workspace importers — to the registry's audit endpoint; it
also resolves and audits the published CLI's runtime dependency ranges with `npm` in a
temp dir, sending that (public-package) graph the same way. The binary channel's packaging
is the second such path: `cli/scripts/package-sea.mjs` shells out to `npm install` to place
the Next standalone server's runtime dependencies (`next`, `react`, `react-dom` and their
transitive graph) inside the archive, so `release-binaries.yml` and `build-binaries.yml`
both reach the registry — and `pnpm package:sea` does not run offline. A third path reaches
somewhere other than the registry: on a `bin-v*` tag push, `release-binaries.yml`'s attestation
step exchanges the runner's OIDC token for a short-lived signing certificate from Sigstore
(Fulcio), logs the signed provenance to its transparency log (Rekor), and uploads it to GitHub's
attestations API. A fourth is the pair of publish jobs behind it, which write the rendered Homebrew
formula and Scoop manifest into `akasecurity/homebrew-tap` and `akasecurity/scoop-bucket` through
GitHub's contents API, each job with a one-hour token it first mints from a GitHub App and scopes to
that one repository. All four are repository tooling, not product paths; nothing a user installs
performs any of them, and the installers never contact Sigstore.

**Four gates enforce this, and they cover different things.** Losing track of which is
which is how "enforced by ESLint and CI" becomes a claim nobody has checked:

| Gate                                          | Catches                                                                                                                                                    | Cannot see                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The ESLint ban (`@akasecurity/eslint-config`) | A network primitive **written** into source                                                                                                                | A transitive dependency, a non-literal `import()` or `getBuiltinModule()`; a network global reached through a parameter, an alias or a structural type — the ban matches one only bare or hung off `globalThis`/`window`/`self`/`global`, so `win.fetch` on a `Window` parameter passes with no opt-out; a file no lint pass targets; **itself** — an inline `eslint-disable` takes the ban off the line below it, which is why the directives are inventoried separately (above) |
| `test/setup/no-network.ts` (every vitest run) | A non-loopback connect **called** at test time, on this thread and in any **worker** spawned from it                                                       | A child process — it has its own copy of `node:net` — and therefore any worker that child starts                                                                                                                                                                                                                                                                                                                                                                                  |
| The `No-network` CI job (`ci.yml`)            | Anything in the process tree, subprocesses included                                                                                                        | A path the suite never executes; it is Linux-only                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| The `Packaged artifact` CI job (`ci.yml`)     | A PUBLISHED-tarball path that DEPENDS on reaching the network — while `npm ci` installs it, and while `aka init`, `aka scan` and the bundled dashboard run | A call the artifact makes and SWALLOWS: a namespace makes the connect fail, and this product fails open by design, so only a path that needs the answer reports here. Also a packaged path those commands never reach; the three PLUGIN tarballs and the extension, which nothing packs here; and it is Linux-only                                                                                                                                                                |

The first one is only as wide as the files something points ESLint at, which is why
coverage is derived and guarded rather than remembered — every package's source dirs and
root files, and every lintable file belonging to no package at all (see "Adding a new
workspace package", step 5). The middle one is a vitest `setupFiles` entry every package
wires (see [Testing](#testing)); the last runs the whole suite inside a loopback-only
network namespace via `tools/ci/no-network-test.sh`, and fails if it cannot first prove
egress really is blocked (`tools/ci/egress-probe.mjs` is that proof — it connects to its
own loopback listener before trusting a failed connect, so a probe that cannot reach
anything is never mistaken for an absent network).

**What none of the four sees** is code nothing runs. All of them observe either source
text or an executed call, so an unexercised path can still reach out — which is why the
gate table's third row says "a path the suite never executes" rather than "nothing", and
its fourth "a packaged path those three commands never reach".

The fourth gate is the newest and the narrowest, and it is worth being precise about what
it added. The first three all stop at the workspace: lint reads source, the vitest guard
patches transports inside a worker, and the namespace job covers whatever the SUITE
starts. A published tarball is none of those — it is a build output, nothing loads it into
a worker, and no test installs it — so the bundling step, which is exactly where a
dependency arrives that the workspace source does not carry, was outside every gate.
`tools/ci/packaged-cli-egress.sh` closes that by installing the packed tarball inside the
namespace and exercising it there.

Be precise about what that establishes, because it is weaker than the middle gate and in a
way the row above has to spell out. The runtime guard **records** a refusal and fails the
run even when a fail-open `catch` swallows it. A namespace records nothing — it just makes
the connect fail — and this product fails open everywhere, so a packaged path that reaches
out and shrugs stays green here. What this leg proves is that the published artifact does
its work with no route off the host, not that it asked for none. It also covers the **CLI**
tarball only; the three plugin tarballs and the browser extension are still packed by
nothing that runs under a block.

That leg carries its own control, and it is a different one from the wrapper's. The
wrapper proves the block is real before anything runs. What the script proves is that the
run was not vacuous in the other direction: a scan that examined nothing exits 0 and
reports no findings, which is byte-for-byte a clean run, so it seeds a file from a
detection rule's own fixture and fails unless that rule fires.

Note also that the four gates scope to the **product** and to `ci.yml`: `audit.yml`
reaches the registry on purpose, as above, and runs in its own workflow rather than inside
either job's namespace.

**Checking the first gate has one trap, and it is in the package that defines the ban.**
Every package's `eslint.config.mjs` imports `@akasecurity/eslint-config`, so ESLint
**executes** that package's `src/` to build any config at all. Append a `fetch()` there and
no rule ever runs: the call fires during config resolution and the process dies with
`TypeError: fetch failed`. It exits non-zero, which is exactly what makes it dangerous — a
check asserting only that `pnpm lint` failed passes on that crash, and would go on passing
with every network rule deleted. Verify this one by planting into a file ESLint does not
load as config (`vitest.config.ts`, `test/`). The suite covers that `src/` without executing
anything and must stay that way: it resolves the cascade at the real path and parses the
real bytes, planting nothing on disk.

The plugin — and the web dashboard's folder scan — also start a **worker thread** (`@akasecurity/plugin-sdk`'s isolated scan, below). A worker is not a child process and opens nothing: it runs the same in-repo detection engine on a second thread of the same process, and today it reaches out nowhere. Say that as a description of what it **does**, never of what it **can** do — a worker has the full `node:net` surface, and its fresh module registry means an in-process patch does not reach it unless something puts one there. Two things do. The runtime guard re-installs itself into every worker spawned from a guarded thread (see [Testing](#testing)), which covers the suite; the Linux `No-network` job covers the shipped artifact, because a network namespace applies to every thread in the tree. The worker is listed here so an audit of "what else executes" finds it.

### 5. A scan that cannot be interrupted runs off the main thread

`scan()` is synchronous and a regex has no upper bound, so a catastrophic pattern is not a stall but a **detection bypass**: the hook blows its 10 s harness timeout, and a timed-out hook fails open, letting the whole tool call through unscanned. Nothing on the calling thread can interrupt a running `exec`; the fail-open `catch` only catches throws.

Two gates sit in front of that, and **both run on a worker thread**, because both are unbounded runs of an untrusted pattern:

- **The timing pre-flight** (`packages/plugin-sdk/src/rule-quarantine.ts`) measures every pulled/custom-pack regex rule against the adversarial probe battery once, caches the verdict locally, and excludes a rule that blows the budget. This is **empirical** — it proves a pattern did not backtrack on the inputs the battery constructs, not that it cannot. And the battery decides by _driving the pattern into backtracking_, so measuring a rule is itself a way to hang on one: `(a|a|a|a)+$` passes `Rule.safeParse` and never returns on the battery's own derived probe. So `filterUnsafeRules` takes a **prober** and the plugin runtime always supplies one — the measurement runs where it can be killed. Without a prober it falls back to the calling thread, which is for callers that already control the rules they pass (tests, tooling), never a pulled pack.
- **The isolated scan** (`packages/plugin-sdk/src/guarded-scan.ts` + `isolated-scan.ts` + `scan-worker.ts`) is the bound on the scan itself. Whenever the effective ruleset contains a regex rule that only the pre-flight stands behind, the whole scan runs in a `worker_thread` under a wall-clock deadline; `worker.terminate()` reaches V8's execution terminator, which interrupts a spinning regex. The terminated rule is quarantined through the pre-flight's own cache, so the next process never loads it, and the built-in packs keep detecting meanwhile.

Five properties are load-bearing and easy to break by accident:

- **The fast path must stay free.** A machine with no pulled or custom regex rule — the overwhelming majority — starts no worker and pays nothing. Nor does a machine whose verdicts are all cached, which is the steady state: the prober is built on first use. Do not widen the isolation to every scan; the permanent per-call tax is exactly why this was deferred once. The one exception is the opt-in large-input bound (§1), and only on a path that answers to a deadline — a hook or the extension's native host: a field of `LARGE_INPUT_THRESHOLD_CHARS` or more is scanned in a worker whatever the ruleset, because that is the field whose in-process scan can outlast the host. A field below the threshold still pays nothing.
- **An ordinary scan is one `scan()` call.** Naming the rule that hung needs a pass that walks the unverified rules one at a time, and that pass is a **retry** of a scan that already timed out — never a tax on the scans that succeed. Worst case for a hang is therefore two deadlines, paid once per scanner (per process for a hook, per request for the dashboard); move that pass onto the happy path and the isolated cost stops scaling like the in-process cost.
- **The whole ruleset goes into the worker, never half of it.** Splitting the scan across two `scan()` calls breaks `requiresNearby` corroboration between the halves and silently drops findings.
- **Worker startup is charged to no deadline.** The worker posts `ready` before the parent starts the clock. Fold startup into the job budget and a cold or contended machine looks exactly like a catastrophic rule — and that misreading gets a legitimate rule quarantined forever. The large-input bound is the exception, and it is safe there for the reason it is not here: its start has its own budget (`LARGE_INPUT_START_BUDGET_MS`, or the native host's 500 ms) clipped to what is left of the caller's deadline, because that path quarantines nothing and names no culprit — a slow start costs one refused field, never a rule.
- **The kill leaves no trace in anything the deadline returns.** The `timeout` outcome, the named culprit, the replacement thread — every one of those comes from the timer, and a rule that never returns reports nothing either way, so replacing `terminate()` with a no-op keeps the whole suite green while each hang leaks a thread that goes on burning a core. What pins it is a fixture that spins for a bounded stretch and reports through shared memory (`plugin-sdk/test/helpers/spinning-scan-worker.ts` + `spin-counters.ts`), because only work that WOULD have finished can show that it did not: a killed thread must post neither a further heartbeat nor a completion. The **pre-flight** is the case to keep — it warns and keeps iterating, so one pass leaks a thread per hostile rule where a scan leaks at most one. Those cases assert no elapsed time and must not grow one: a thread that was not stopped is at every instant either still executing or already finished, so the two readings are exhaustive between them and a slow runner blunts one only by sharpening the other.

The first two are **counted, not timed** — a different claim from the fifth, which forbids an
elapsed assertion outright because its two readings are already exhaustive. How many threads a path builds is the property —
none on the fast path, one for the pre-flight, two for a hang that has to be recovered and then
attributed — and `IsolatedScanOptions.onWorkerStart` reports each construction so
`packages/plugin-sdk/test/{runtime-isolation,isolated-scan}.test.ts` can assert the number
directly. Elapsed ceilings sit beside those counts and answer a different question: a ceiling
loose enough to survive a cold start on a contended runner (the two-cycle case is granted 75s
against a shipped worst case of ~14s) swallows a whole extra cycle without noticing, so it can
only separate "this path got slower" from "this path stopped terminating" — which the per-test
timeout cannot. Do not fold one into the other, and do not answer a flake here by widening a
ceiling that was never measuring the property.

**The per-scan cost is the third instrument, and it is a RATIO rather than a ceiling.** What one
isolated scan costs is what a real payload multiplies — one scan per MCP leaf — so it is worth a
gate of its own; but it is a sub-millisecond quantity, and an absolute millisecond bound on one of
those is a statement about the runner. It was `medianMs < 25` and it reddened a Windows leg at
27.03ms on a branch whose diff could not reach the isolation path; the same commit re-run failed
completely differently, taking 90% longer and blowing 20s hook timeouts in three other packages
instead. So `runtime-isolation.test.ts` divides the median isolated round trip by the median of the
SAME work done in-process — a second runtime with no unverified rule, interleaved one pair per
iteration in the same loop — and bounds the quotient. Everything the two share cancels, so a runner
that is uniformly half as fast moves both medians and the ratio not at all: measured 1.07/1.10/1.04
quiet and 1.01/1.05/1.02 under 96 CPU burners on 14 cores, where both medians roughly doubled.

Three things about that shape are load-bearing. **Both sides must be the same statistic over the
same number of interleaved samples** — a stall-immune denominator (a min-of-N) against a noisy
numerator makes a stall EXPLODE the quotient instead of cancelling in it, which is how an earlier
attempt at a ratio elsewhere failed. **The baseline must be proven not to isolate** (`baselineStarts.count()`
is 0), or the ratio compares isolation against itself, sits at ~1 forever, and passes every mutation
it exists to catch. And **a ratio has a sensitivity floor**: against a ~0.17ms in-process median a
bound of 10 catches an added per-scan cost above ~1.5ms and nothing smaller, which is why it is the
`starts.count()` assertion beside it — not this — that catches a thread started per scan.

A quarantine verdict is the one detection decision the machine reaches on its own, from a wall-clock measurement, and it is cached forever. So it is **recoverable and visible**: `aka detections unquarantine` forgets every quarantine verdict (keeping the `safe` ones, which are measurements worth keeping), and `aka detections` reports the count. The stderr line the plugin writes names the command, because a hook's stderr is otherwise the only place the machine ever mentions it; the dashboard's folder scan writes the same line to the server's console and additionally returns a notice to the page, since nobody is reading a server log while they click Scan. Anything that adds a new way to quarantine must keep those surfaces true.

**Only a CORROBORATED breach is quarantined, and only that line may name the command.** A rule leaves the pre-flight four ways, and `rule-quarantine.ts` says which: over budget with the work clock agreeing (`quarantined rule "…"`, with the `unquarantine` hint, since a row exists to clear); never reached because the pass budget ran out (`skipped rule "…"`, no hint, back on the next pass); over budget on the wall while burning almost no CPU (`deferred rule "…"`, no hint — see below); and never measured because there was nowhere killable to measure it. The last is **not a per-rule line at all** — it is one line for the whole pass, carrying the prober's own `unavailable` reason and naming no rule, because a missing or unstartable worker excludes EVERY pulled/custom regex rule on the machine from every scan until the install is repaired. It is a property of the install, and a fault the ruleset cannot fix. Collapsing any of the four into the timing wording is the defect this replaced: three of them cache nothing, so a user sent to `aka detections unquarantine` reaches a list their rule is not on, and a user told their rule blew a timing budget goes to audit a ruleset that is fine. `packages/plugin-sdk/test/rule-quarantine.test.ts`'s `what the pre-flight says on stderr` holds it, pairwise — a future collapse goes red whatever the new wording is.

**Two clocks, and they answer different questions.** Exclusion is decided on WALL time and must stay that way: what a slow rule spends is the hook's harness timeout, and that timeout is wall-clock, so a pattern that stalls the hook has to be excluded however little CPU it burned. But the verdict is cached forever and nothing ever re-measures, so what may be WRITTEN DOWN answers a stricter question — was the elapsed time work? `checkRuleTiming` therefore takes a corroborating clock as a **required** parameter (an optional one is dropped in silence, which is how the dashboard lost `onWorkerStart`), and returns `safe` / `over-budget` / `uncorroborated` rather than a boolean, because a boolean cannot carry the third case and defaulting it to either of the other two is the defect. `@akasecurity/detections` takes no Node-API dependency and part of it is bundled for the browser, so it cannot read CPU itself; `packages/plugin-sdk/src/work-clock.ts` supplies `process.threadCpuUsage()` — **per thread**, because the pre-flight runs inside the scan worker and `process.cpuUsage()` would charge the rule under measurement with the main thread's work and V8's background GC and compiler threads, which errs toward corroborating a stall.

The floor is `CORROBORATION_FLOOR_MS`, 20% of the 100 ms budget, and it sits in a measured gap rather than a chosen one. On an arm64 Mac (14 cores, Node 24.18): a benign rule's whole battery costs 1.2 ms of CPU quiet, and under 96 CPU burners one crossed the wall budget at 104.9 ms having burned **0.5 ms**; a wider fleet run recorded the same shape on five different rules at 0.2–7.7 ms. Genuinely catastrophic patterns burn 163–458 ms quiet and 196–600 ms under the same load — except `(x+x+)+y` at 44.5–49.1 ms, which is the floor to sit under. **Why that one drops so far is what rules out the obvious threshold**: the walk stops at the first probe whose WALL reading crosses the budget, so a stalled machine ends it earlier in CPU terms, and requiring a full `BUDGET_MS` of corroborating CPU would refuse to quarantine a catastrophic rule exactly when the machine is busiest. The two errors are not symmetric, which settles which way to lean: too high and a hostile rule is excluded every run but never cached — enforcement still happens, it just re-measures, and it becomes permanent as soon as the machine is quiet enough; too low and a stall permanently disables a rule the user installed. Only the second needs `aka detections unquarantine` to undo.

One breach needs no corroboration and must keep bypassing it: a measurement the isolated scanner had to **terminate**. That is not "over budget", it is "never came back", and no stall makes a thread refuse to finish.

The worker is a **build entry**, not a source file the loader finds: the published plugin ships `scripts/` only, so `plugins/claude-code/tsup.config.ts` emits `scripts/scan-worker.js` beside the hooks and the SDK resolves it as a sibling. A worker URL resolved against a source path works in the repo and under vitest and fails only once installed — `plugins/claude-code/test/e2e/scan-worker-bundle.e2e.test.ts` is what pins it, by driving a **built** hook against a throwaway home with a pulled rule installed.

**Where else the bound applies, and where it does not.** The capture path is `runtime.evaluate`, and so every hook plus `@akasecurity/scanner`. The dashboard's folder scan is the second caller and reaches the same two gates through `packages/local-ops/src/guarded-scan.ts` — `web-ui/app/(app)/scan/actions.ts` builds a `createGuardedFileScanner` over the installed-pack snapshot and hands `scanPathIntoStore` its `scanText` seam, never the raw `rules`, which is the in-process path. Five things differ there and each is load-bearing:

- **The scanner is per REQUEST, not per process.** The first hang retires isolation for the scanner's whole life; the dashboard server outlives every scan it runs, so a process-wide scanner would cost it its pulled rules until someone restarted it. That is also why `createGuardedScanner` takes a `degradeScope` — the stderr warning must not claim a lifetime it does not have. It is held as BEHAVIOUR rather than as that sentence: `packages/local-ops/test/guarded-scan.test.ts` drives two scans over one store and requires the second to detect again with a rule the first dropped. The rule it reads has to be **collateral**, never the culprit — a quarantined rule is gone from every later scan by design, so it would pass whether or not the scanner was shared, while a rule dropped only because a neighbour hung is supposed to come back and does so only if the next scan builds its own scanner.
- **The isolation options are forwarded by SHAPE, not by listing keys.** Every field of `IsolatedScanOptions` is optional, so a pass-through that names a subset drops the rest by construction and nothing notices — not typecheck, not lint, not a test. That is not hypothetical: `onWorkerStart` was added to the SDK and reached the plugin's isolation and never the dashboard's, with `local-ops` green throughout. `createGuardedFileScanner` annotates its pass-through `CompleteIsolatedScanOptions` — `IsolatedScanOptions` with every key REQUIRED, each still allowed to be `undefined` — so every key must be named and the next field added to the SDK fails the build here until someone decides whether to expose it. Declining is spelled `field: undefined`, which reads as the choice it is rather than as an omission.
- **The worker location is a caller input** (`GuardedFileScannerOptions.workerUrl`), never the SDK's sibling lookup. A Next build replaces `import.meta.url` with the BUILD MACHINE's absolute source path, so that lookup resolves where the build ran and nowhere else — silently costing an installed dashboard its bound. `web-ui/tsup.config.ts` emits the worker to `web-ui/dist/scan-worker.js`, `next.config.ts` traces it into the standalone build, and `web-ui/app/lib/scan-worker.ts` resolves it against `process.cwd()` (the app dir: Next's standalone `server.js` chdirs to its own directory, and every other launch runs from the package). Those three move together, `web-ui/test/e2e/scan-worker-bundle.e2e.test.ts` pins the first and third, and `cli/scripts/bundle-web-ui.mjs` throws at pack time if the second stopped working. The emit itself is ONE turbo task, `web-ui#build:worker`, and every task that reads or ships the file depends on it — `build`, `test` and `dev` run no `tsup` of their own. That is not tidiness: `turbo run test` schedules `web-ui#build` beside `web-ui#test` (the CLI's test task reaches it through `^build`) with no ordering between them, so a `tsup` inlined into either script is a second writer whose `clean: true` removes and rewrites the worker while a vitest fork is between `existsSync` and `new Worker(…)` — a flake that reads as the action's own wording, not as a build race. `web-ui/test/scan-worker-build.test.ts` pins the single-writer shape, including that `build`'s declared outputs carry no `dist/**` (a cache hit restores outputs, which is a write too).
- **With no worker, a rule that cannot be bounded is dropped, not run** — including one the pre-flight already cleared, since that verdict is empirical. The scan says what it dropped (`GuardedFileScanner.dropped()` → the Scan page's notice); it does not quietly run a smaller ruleset than the Detections page lists.
- **A dropped rule is only ever pointed at `aka detections` when a verdict was actually cached.** `DroppedRules` splits on that — `quarantined` was measured and left a row, `unmeasured` was never CONCLUSIVELY timed here (no worker, the pre-flight's pass budget spent, or a wall breach the work clock refused to corroborate) and deliberately leaves none, since caching a verdict for a rule nobody measured would disable it forever. Only the first has anywhere to send someone, and `countQuarantined()` — the value the command itself prints from — is what the notice gates on. Any new way to drop a rule has to say which of the two it is.

Two `scan()` calls inside the SDK are not exposure: `mask.ts` and `vault-glue.ts`'s self-scan both pass the rules `mask.ts`'s `bundledMaskingRules()` returns — `getLoadedRules()`, the compiled-in registry the CI battery gates on every commit, so no pulled pattern reaches them. `aka scan` passes no ruleset and falls back to that same registry, so the CLI runs in-process by design and is not exposed — passing it an installed snapshot instead would put an unreviewed regex on an unbounded path.

**The bound is around `scan()`, and the folder scan makes a SECOND unbounded pass that it does not cover.** `scanPathIntoStore` runs `extractFileEgress` — `@akasecurity/detections`' URL/IP and manifest-SDK extraction — on the CALLING thread, before the guarded scan and independent of it. Nothing there can be interrupted, and the exposure is the worse of the two callers': `aka scan` at least runs to a terminal a human can Ctrl-C, while the dashboard's Scan Server Action has no harness timeout at all. So the property extraction owes is not "no catastrophic regex" but the stronger **linear in file length**, since the walker hands it whole files up to `MAX_BYTES` (1 MB) and a merely POLYNOMIAL pass is already minutes at that size. Six had to be fixed to make that true, and only the first two were regexes. **The other four are the shape to recognise, because none of them needs crafted input and none of them looks like a ReDoS: work proportional to the FILE, done once per HIT, in a file whose hit count grows with its size.** A minified bundle puts every hit on one line, so redacting that line per hit is quadratic (118,798 ms of CPU for an ordinary 1 MB bundle carrying 25,641 URLs, against 60 ms memoized); mapping a hit's offset into the redacted line by re-redacting the prefix is quadratic the moment anything on the line is masked, which is to say on exactly the files this product is pointed at (15,333 ms for a 500 KB bundle carrying one token, against 15 ms); and a manifest's line number counted from offset zero, and its quoted key found by scanning from the section start, are each quadratic in its dependency count — an ordinary pretty-printed 1 MB `package.json` cost 1,459 ms and a single-line `pom.xml` 73,925 ms, against 42 ms and 30 ms now. Anything new on this path is linear or it is a denial of service; `packages/detections/test/security/egress-redos.test.ts` holds every pass at the 1 MB cap, and `packages/local-ops/test/performance/fs-scan-egress.test.ts` holds the pipeline that feeds them. Both charge CPU rather than wall time, for the reason the per-rule gate does, and both carry a control proving their input is still adversarial — a budget case fed a harmless string passes for ever.

What remains uncovered here is the packaged artifact under a HOSTILE PACK. §4's packaged-artifact leg installs the published CLI and boots its bundled dashboard, but it drives neither a folder scan nor a pulled rule, so the chain above is still proven link by link rather than end to end.

### 6. Every writer of `settings.json` takes its lock

`writeOwnerOnlyFileSync`'s tmp+rename makes each PUBLISH indivisible. It does not make a
**read-modify-write** indivisible, and that is the shape every settings write has: read the file,
merge answers over it, rename the result. The plugin, the CLI and the dashboard are three separate
processes over one home, so two of them read the same bytes and the second rename discards the
first one's answers — with both reporting success, because neither ever learns the other existed.

`withFileLock` (`packages/persistence/src/file-lock.ts`) is the section around that pair. It is the
existence of a sibling `<file>.lock`, taken with an exclusive create — `flock` has no Windows
equivalent, and an fd-held lock is lost by any writer that opens the file a second time. Seven
properties are load-bearing:

- **It is ADVISORY, and it is scoped to one file.** A writer that skips it is not excluded by the
  ones that take it, so the guarantee is only as wide as the set of writers holding it. Two
  writers hold it today: `applyOnboarding` (which the `/aka:setup` wizard and the dashboard's
  Settings action both go through) and `aka init`'s create-if-absent. A third that writes
  `settings.json` directly reopens the hole for both. **Other `~/.aka` files are NOT covered** —
  `fingerprint.ts`'s key ROTATION and `local-ops`' `update-cache.ts` are unlocked
  read-modify-writes with the same shape, and each is its own outstanding fix.
  `plugin-sdk`'s `host-floor.ts` (`data/host-version.json`) is a third, and is the one
  that is NOT owed a fix — though not because it self-heals, which it does NOT. Its
  max-keeping loses the race that matters: two sessions reading `null` both pass the
  comparison, and the loser PERSISTS, because the only caller sits behind a
  once-per-session claim it keeps, so the winner never writes again. What earns the
  exemption is the BLAST RADIUS rather than the duration — nothing on the hook path
  reads that file, so a stale value costs a wrong line on `aka status` and
  /aka:health, pull surfaces somebody is reading because they are already debugging.
  `plugin-sdk`'s `hook-fail-opens.ts` (`data/hook-fail-opens.json`) is a fourth, and it
  holds the same exemption on its own account rather than by being listed here: it is
  written only after a hook has already failed open, and nothing but `aka status` renders
  it, as a count that already says "at least". A fifth writer does not inherit that by
  being on this list, and a file any enforcement path READS would not qualify for it at
  all. The first MINT
  is no longer one of them, and it was not fixed with a lock: `createKeyFile` publishes through
  `createOwnerOnlyFileSync`, which links an already-complete tmp into place, so exactly one
  caller wins and every loser reads the file back and ADOPTS the winner's key. That works only
  because a first mint has nothing to preserve — rotation must replace an existing file, so it
  still reads the current version and writes version+1 over it, and the same fix does not reach
  it. Do not read this section as a property the store has; it is a property `settings.json`
  has.
- **An ADMINISTRATIVE overlay is read, never written, and that is what keeps the writer count at
  two.** `managed-settings.ts` reads a root-owned file outside `~/.aka` that an MDM or
  config-management tool owns — `/Library/Application Support/AKASecurity` then
  `/Library/Managed Preferences` on macOS (FIRST READABLE WINS, so the order is part of the
  contract, not a detail), `%ProgramData%\AKASecurity` on Windows, `/etc/aka` on Linux. AKA overlays what it finds — `readEffectiveSettings` returns the settings in force
  plus the locked set — and writes it never. Making AKA a writer of it would be the third
  `settings.json`-class writer this section exists to prevent, on a file the lock does not cover.
  Three consequences. A pinned VALUE and a LOCK are separable: a value with no lock is a default
  the user may still change, a lock with no value freezes whatever they last chose — with one
  exception the writer cannot see: a pinned CONNECTION (`runMode` / `controlPlane`) is written
  through and then overlaid straight back on the next read, so both surfaces that change it —
  `aka attach` / `aka detach` and the dashboard's attach and detach Settings actions — refuse to
  move away from it ahead of every side effect, deciding against the effective settings rather
  than the lock list. That decision is ONE copy, `packages/persistence/src/managed-connection.ts`,
  worded by `connectionRefusalMessage` in `@akasecurity/schema`, and the Settings page withholds
  the controls it would refuse from the same call. What an attach under such a pin leaves in the
  user's file is judged one half at a time: an echo of the pinned mode is stripped like any other
  echo, but the descriptor the attach writes is kept even where it matches the pin, because it
  records what the pin cannot — when this machine enrolled, and what the user called the
  deployment. A descriptor without the mode is not an attachment, so once the pin is gone the
  machine is in the mode the user last chose, holding that record — never attached on the
  strength of an echo. A locked field
  is refused inside the lock (`ManagedFieldError`), by THROWING rather than dropping the key and
  writing the rest — a half-applied save reports success while losing the answer the user cared
  about, which is this section's whole failure mode. And a damaged managed file leaves the machine
  UNMANAGED rather than refusing to run, because a typo in an MDM payload must not break every
  hook on every managed machine at once; that direction is deliberate and stated in the module.
  One shape is deliberately NOT damage: a lock naming a key this build does not know is dropped
  from the locked set and reported (`unknownLockedFields`), never a reason to run unmanaged.
  That is what an older build sees when an administrator locks a key a newer build added, and
  refusing the whole file there cost it every pin and lock it understood, on exactly the fleets
  most likely to carry a version skew. A name outside the enum is still never honoured.
- **Anything derived from the current file is derived INSIDE it.** `applyOnboarding` takes an
  updater function for exactly this: a caller that reads first and passes a plain object has put
  its read outside the lock and kept the lost update, one frame further out. The dashboard's
  vault-consent carry-forward is the worked example — read outside, it writes a grant back over a
  concurrent revoke.
- **It fails loudly rather than writing unlocked.** The fields at stake include the vault and
  model-judge consent grants, so the write that goes missing can be a REVOCATION. A refused save
  the user retries beats a silent one they never learn about.
- **A killed holder must not wedge the file, and a slow one must not be evicted.** Taking an
  abandoned lock needs BOTH that its own recorded acquire clock is older than the stale window and
  that the pid it names is gone. Age alone evicts a holder that is merely suspended or throttled,
  which puts two writers in the section — the defect, reintroduced by its own recovery. The clock
  is read from the lock BODY, never from mtime, which on a network home is the file server's clock
  and drifts a fresh lock straight into staleness. `FileLockError.reason` separates `timeout`
  (contention) from `unavailable` (a directory that will never hold a lock); `aka init` branches on
  it, swallowing the first — another writer is already creating the file — and propagating the
  second, which is the fault its pre-lock write raised too.
- **A release must prove it still owns the lock.** Each acquisition writes a token and removes the
  file only while that token is still there — an unreadable body counts as somebody else's, since
  a lock exists for an instant between its exclusive create and its body write. Unlinking by path
  alone means a holder that was stolen from deletes its successor's lock, and the section is
  unguarded while that successor is mid-write, every writer reporting success.
- **Recovering an abandoned lock is itself a critical section**, held by a second exclusive file.
  Several waiters meet the same dead lock at once; unserialised they all judge it stale, one
  removes it and takes a fresh lock, and the next one's removal lands on THAT. Neither a rename
  nor a re-read closes it — every check is separated from its removal by a window another waiter
  acts in, and a rename that has to be undone clobbers whatever took the path meanwhile. Measured
  at 5 of 12 trials losing an update with eight waiters over one abandoned lock; zero once the
  judging and the removal became one section.

`packages/persistence/test/concurrency/settings-race.test.ts` is what holds this: real child
processes, released together on a readiness handshake, asserting no answer is lost and no
revocation is resurrected. It uses processes rather than worker threads deliberately — a worker
shares `process.pid`, and the atomic write's tmp path is per-process, so two threads meet a
collision two processes never can, which is the wrong axis.

### 7. A command spawned by BARE NAME goes through the planner

`planBareCommand` (`packages/plugin-sdk/src/bare-command.ts`, the `./bare-command` subpath)
is how every bare-name spawn in the plugins is built. Three facts have to hold together, and
handling any one of them alone makes things worse rather than better:

- **libuv does no PATHEXT.** Its own executable search tries `.com` and `.exe` and stops, so a
  shell-free `execFileSync('claude', …)` never sees the `claude.cmd` an npm global install put
  on PATH. It fails **ENOENT** — the same code a genuinely-absent CLI produces, which is why
  this read as "not installed" rather than as a batch-file problem for as long as it did.
  Reaching the shim needs a shell, exactly as `packages/local-ops/src/exec.ts` says.
- **Windows searches the working directory BEFORE PATH.** So the moment a shell is involved, a
  `claude.cmd` in a cloned repo runs instead of the real CLI. Every Windows spawn is anchored at
  `homedir()`; nothing spawned this way depends on the caller's cwd. POSIX never consults the
  cwd, and no anchor is added there.
- **A shell RE-PARSES the argv.** Node joins `[file, ...args]` with a space and hands the result
  to `cmd.exe` verbatim — it says so itself, in DEP0190: "the arguments are not escaped, only
  concatenated". So `shell: true` on its own is not a fix, it is a command-injection surface
  wherever an argument carries content this repo did not choose.

The planner's answers to the third are what must not be softened. It quotes each argument
itself and passes the joined line as the FILE with an empty args array, so Node has nothing left
to concatenate; it **refuses** — `BareCommandUnsupportedError`, never a best-effort escape — an
argument carrying `"`, `%`, `!`, a line break or a NUL, or a line over cmd.exe's 8,191
characters; and its refusal `reason` names an argv INDEX and a character class, never a value,
because the one caller that reaches it in practice is refusing a raw-bearing prompt. It also
skips the shell entirely when the bare name resolves (via `where.exe`) to a real executable,
which removes the re-parse, the 8 KiB ceiling and the extra `cmd.exe` process at once — the
binary channel's `aka.exe` takes that path.

**Two measurements inside that are easy to get subtly wrong, and both fail SILENTLY.** What
counts as "a real executable" is an **allowlist** — `.exe` and `.com`, what `CreateProcessW`
loads as an image — never a `.cmd`/`.bat` denylist. An npm global install writes an
EXTENSIONLESS launcher (a Bourne script, for Git Bash) beside the shim, and `where` prints that
line FIRST: `…\npm\aka` before `…\npm\aka.cmd`. A denylist therefore takes the direct path and
hands a shell script to `CreateProcessW`, which fails **ENOEXEC** — a different code from the
ENOENT every caller here tests for, so the CLI reads as installed and the spawn then does
nothing at all. And the 8,191 ceiling is charged against the line **cmd.exe is handed**, which
is Node's `<COMSPEC> /d /s /c "<line>"` and not `<line>`; measuring the inner line alone admits
one that is then truncated rather than refused, which is the whole failure the refusal exists to
prevent. `where.exe` also runs under its own timeout, because a PATH entry on an unreachable
network share blocks that search for as long as the OS takes to give up — and it runs in front
of a launcher meant to return at once, and before the judge's own spawn timeout, which cannot
bound something that has not spawned yet.

**Antigravity's judge used to be the case that could not be made to work through `cmd.exe`, and
what closed it is worth keeping.** Its prompt rode ARGV, so it was multi-line, several KiB, and
built from scanned transcript text — three independent reasons a Windows command line cannot
carry it — and the planner refused that plan outright rather than escaping it hopefully. The
repair was to stop needing the command line: the prompt now rides stdin (§4), so the argv is
fixed flags, it crosses `cmd.exe` intact, and an `agy.cmd` shim is reachable like any other
host's. That retired `judge-argv-unsupported.ts`, the gate that had kept that plugin's
wizard-journey suite off the Windows leg because its stub can only ever be a `.cmd`. The
property is pinned behaviourally in `plugins/antigravity/test/triage/judge.test.ts` — against
the real planner and the real argv `runJudge` builds, with a positive control that the prompt is
still large, multi-line and raw-bearing — so putting anything raw-bearing back on argv goes red
there rather than only on Windows. **A refusal is still the right answer where an argv genuinely
cannot cross**; what changed is that this argv no longer has to.

**A probe in front of such a spawn reads the plan rather than re-deriving it.** A shell-free
probe ahead of a shelled spawn reports a false miss and the reverse a false pass, and the PATH
shims fail OPEN (see [Testing](#testing)), so a false pass reaches the developer's real
installed CLI. The journey harnesses pass `plan.viaShell` and `plan.options.cwd` straight
through to `assertShimResolves` for that reason.

`packages/plugin-sdk/test/bare-command.test.ts` drives every branch from any host — the planner
takes `platform` and a resolution seam the way `judgeEnv`/`writeCommandShim` take a platform.
**Three** sites deliberately do NOT go through it, and they are unsafe to different degrees:

- `cli/src/lib/external-dispatch.ts` is POSIX-only by design, so none of the three facts apply.
- `plugins/claude-code/src/provenance.ts` shells out to `npm` with its own `USE_SHELL` because
  `npm audit signatures` reads the caller's project — it therefore carries the shell half
  **without** the cwd anchor. On Windows that is the cwd-before-PATH hazard this section opens
  with, reached from a repository the user merely opened. It is NOT exploitable today for a
  reason outside this module: the function has no shipped caller and is tree-shaken out of every
  built script (see §4). So it is a gap in the SOURCE rather than in the artifact — and wiring
  the check back without anchoring or resolving the spawn first is what would make it live.
- `packages/local-ops/src/exec.ts` carries `USE_SHELL` **and** the `homedir()` anchor, so it has
  both halves of the cwd defence — but no quoting and no refusal, so on the shell path Node
  concatenates its argv unescaped exactly as DEP0190 describes. It is not exploitable today, and
  the reasons now differ per argument. The CLI update's npm argv is no longer built entirely from
  the `CLI_PACKAGE` constant: it also carries a REGISTRY-SUPPLIED version — an update report's
  resolved answer for a channel, which `npm view` supplied. That version crosses only through
  `isExactSemver` inside `cliInstallSpec`, which is TOTAL (a non-string is refused rather than
  thrown on), refuses anything over npm's own 256-character limit, and admits no whitespace and no
  shell metacharacter — so nothing that reaches this argv can break out of the unescaped
  concatenation, and anything the check refuses falls back to a dist-tag from the closed
  `DIST_TAG` table instead. Plugin arguments are still refs resolved from the static
  `AGENT_PLUGINS` registry after validating the caller-supplied id against it — that half remains
  a **hand-maintained invariant in a comment**, the precise class this module converts into a
  structural one everywhere else — and `local-ops` already depends on `@akasecurity/plugin-sdk`,
  so routing it through `planBareCommand` is available rather than blocked by a package wall.
  Migrating it is tracked separately.

## Dependency advisories

CI gates every PR (and a daily run) on two audits via `tools/audit-gate`: `pnpm audit`
over `pnpm-lock.yaml`, and `npm audit` over an end-user resolution of the published
CLI's runtime `dependencies` (`--artifact` mode — coverage the workspace lockfile
cannot provide, since consumers re-resolve the published ranges and `pnpm.overrides`
do not reach them). A **high or critical** advisory in either fails the check, so a
new or bumped dependency that carries one will not merge. Fix a workspace hit by
upgrading, or by raising a floor in the root `pnpm.overrides` (the existing entries
there are exactly such floors); fix an artifact hit by raising the range in
`cli/package.json` — or, when the vulnerable copy is pinned by another package (e.g.
next's own pins), by bumping that package once upstream moves. Only when an advisory
has no fixed release reachable from the tree, add an **expiring** waiver to
`.github/audit-waivers.json`, scoped to the audit it applies to — format and process
in CONTRIBUTING.md ("Dependency advisories and waivers").
`auditConfig.ignoreCves`/`ignoreGhsas` is not an approved suppression route, and the
gate refuses to run (exit 3) when it is set. **It is detected by reading the two files
pnpm takes the setting from — the root `package.json`'s `pnpm.auditConfig` and
`pnpm-workspace.yaml`'s top-level `auditConfig` — not by inspecting what pnpm returns.**
The payload cannot reveal it: pnpm removes a muted advisory from `advisories`, decrements
the `metadata` severity counts to match, and leaves the top-level `muted` array **empty**,
so a muted run is byte-for-byte the shape of a clean one. A guard reading `muted` is
therefore correct in isolation and unreachable in practice — which is what it was, while
both audits reported green. `assertNothingMuted` is kept as the payload-side half in case
a later pnpm does populate the field; `assertNoAuditConfigMutes` is the half that fires.

Both channels are real and were measured; `.npmrc` is not one, in either the flattened or
the camelCase spelling. `findAuditConfigMutes` refuses on the **presence** of the
`auditConfig` key — in **both** channels alike, and whether or not the ignore lists under
it carry entries. So an empty one is refused too, and a third key added by a later pnpm is
caught without a code change, because the key names are never enumerated. The two halves
have to answer alike here or the rule stops being statable: the YAML half cannot tell an
empty `auditConfig` from a populated one without a parser. A file that **exists but cannot
be read** is refused rather than read as "no config here" — absence means `ENOENT` and
nothing else, since a failed read that returned "nothing to see" would reach the exact
outcome this check exists to prevent. Its tests pin the discriminating case directly: a
payload reporting nothing muted **and** a manifest configuring `auditConfig` must still be
refused — delete the file read and that case, alone, goes red.

## Package dependency rules

The store-reading packages read the local SQLite store directly through
`@akasecurity/persistence`; they never reach for an HTTP client or an ORM at the app layer.
Keep these package boundaries intact — a forbidden import across a package wall is a defect.

```
@akasecurity/schema        → zod (core Zod contracts + the SQLite local-store & rule-registry schemas,
                     defined with Drizzle; also the endpoint safety rule every
                     forwarder and client shares — isSafeEndpoint /
                     unsafeEndpointReason / originOnly / LOOPBACK_HOSTS — the
                     ControlPlaneFailure type, and the raise-only enforcement
                     clamp and its category anchor — mergeRaiseOnly +
                     ruleCategoryMap)
@akasecurity/persistence   → node:sqlite, @akasecurity/schema
                     (SQLite adapter + read/view ports, plus the shared ~/.aka
                     layout/settings/fingerprint file I/O, plus the egress wire
                     projection — toEgressIngestRequest / hashProjectKey, the one
                     place an EgressIngestRequest is built, pure and beside the cap
                     helpers it applies — NO fetch client, NO Drizzle)
@akasecurity/local-ops     → @akasecurity/schema, @akasecurity/persistence, @akasecurity/detections,
                     @akasecurity/plugin-sdk (repo-identity, project-file walkers, posix
                     path normalization, and the ReDoS gates the dashboard's folder
                     scan reuses — filterUnsafeRules + createGuardedScanner, see
                     src/guarded-scan.ts and Architecture principles §5)
                     (shared CLI/web-ui operations: update report + apply via npm/claude
                     child processes, the agent-plugin registry, the fs scan pipeline,
                     the project-inventory pass, and the shares-forward outcome state
                     machine — forwardProjectEgress is what a scan surface calls to
                     forward the register it just recorded (attached? opted out?
                     switch on? credential? and, on a scoped attachment, is the project
                     enrolled along with every repository nested in it? then send),
                     with the transport injected by the caller, plus the one copy of
                     the failure sentences both surfaces render; network ONLY via
                     package-manager shell-outs — no fetch)
@akasecurity/detections    → @akasecurity/schema (pure rule engine; no I/O, no Node-API deps)
@akasecurity/extract       → (no dependencies; pure CSV/tabular parsing — `extractCsv`.
                     Consumed by @akasecurity/detections' tabular suite as a
                     dev-only dependency, so it crosses no runtime package wall)
@akasecurity/dashboard-ui  → @akasecurity/ui-kit, @akasecurity/schema (types, plus the pure
                     shared constants and formatters — no I/O)
                     (bundler-agnostic presentational views; props-driven, no data fetching)
@akasecurity/ui-kit        → @radix-ui/react-*, Tailwind (design-token UI primitives)

web-ui            → @akasecurity/persistence, @akasecurity/dashboard-ui, @akasecurity/ui-kit,
                     @akasecurity/schema, @akasecurity/detections, @akasecurity/local-ops,
                     remote (the attach verb, and the Scan page's forward of the Data
                     Shares register while attached) (Next.js dashboard; reads the local
                     store in Server Components, mutates via Server Actions — no auth.
                     The TWO network calls are the settings page verifying an access key
                     against the deployment before it writes an attachment, and the Scan
                     page forwarding the register it just recorded; everything else on
                     every page is the local store)
cli               → @akasecurity/schema, persistence, local-ops, detections,
                     plugin-runtime + remote (the attach verbs, and `aka scan`'s forward
                     of the Data Shares register while attached) (the `aka` command;
                     ships the web-ui as a spawned Next server)

# Plugin
plugins/claude-code → @akasecurity/plugin-runtime, plugin-sdk
plugins/codex        → @akasecurity/plugin-runtime, plugin-sdk
plugins/antigravity  → @akasecurity/plugin-runtime, plugin-sdk
plugins/copilot      → @akasecurity/plugin-runtime, plugin-sdk (GitHub Copilot: the
                     Copilot CLI and VS Code agent mode, and the cloud coding
                     agent, which speaks the CLI's protocol. ONE package, TWO
                     payload dialects — the CLI's flat camelCase envelope and VS
                     Code's flat snake_case one — sniffed per payload in
                     src/hooks/dialect.ts and carried as a parameter from there
                     down, so the two scannable-field tables are never merged)
plugins/browser-extension → @akasecurity/plugin-runtime, plugin-sdk (the native-messaging
                     host only — Node side); the browser-side content script bundles just
                     `@akasecurity/plugin-sdk/browser` (mask.ts's Node-API-free slice) and
                     must never import anything Node-only. The MAIN-world tap
                     (`src/tap.ts`) runs with the page's own authority and REASSIGNS the
                     page's `fetch` and `XMLHttpRequest.prototype.open`/`send` to observe
                     chat traffic. It originates no network request and imports nothing,
                     and it passes §4's network ban with no opt-out because it reaches
                     those globals through its `win` parameter and a structural type — so
                     an audit of what touches `fetch` has to include it by name.
                     `test/tap-bundle.test.ts` holds the built bundle to that shape.
@akasecurity/plugin-runtime → @akasecurity/plugin-sdk, persistence, schema, remote
                     (the attached-mode gateway under src/attached/ — inert on a
                     machine that has not attached, which is why `remote` is a
                     runtime edge here and still costs a standalone machine
                     nothing: the client is constructed only once both halves of
                     an attachment are present and agree.
                     the wire projection lives in persistence and the gateway imports
                     it from there; the attached barrel re-exports it for consumers
                     that import it from this package)
@akasecurity/remote         → @akasecurity/schema, zod
                     (the control-plane transport, and the ONLY package in this
                     workspace permitted to open a socket — see §4. `src/http.ts`
                     is the one module inside it that sends; everything else
                     builds requests and parses answers.
                     TWO clients, split by whether the caller holds a credential.
                     `createRemoteClient` speaks the eleven routes an ATTACHED
                     machine is scoped to and requires a key. `createAttachClient`
                     speaks the two anonymous routes a machine uses to OBTAIN one
                     and holds none — a separate factory rather than an optional
                     key, so a caller cannot reach any other route without a
                     credential, since that client cannot express one.
                     It also owns the READING of its own failures: classifyRemoteFailure
                     maps one of its error classes onto a RemoteFailureKind, so no
                     caller re-derives a verdict from a status code — and
                     createSharesSender is the one forwarding adapter for a scan's
                     Data Shares register, so every surface sends and reads
                     failures identically. The exact
                     export set is pinned by test/public-surface.test.ts)
@akasecurity/plugin-sdk     → @akasecurity/detections, persistence, schema
                     (provider resolution for the session-root snapshot reads the host env
                     directly at SessionStart — `provider.ts` for Claude Code,
                     `provider-codex.ts` for Codex CLI, `provider-antigravity.ts`
                     for Antigravity, each its own file-scoped
                     `n/no-process-env` opt-out), ignore (gitignore semantics for
                     the SessionStart project-file walk), node:worker_threads
                     (the isolated scan — see Architecture principles §5)
                     `src/scan-worker.ts` is the worker's own entry, exported as the
                     `./scan-worker` subpath and reachable only from `@akasecurity/detections`
                     + `./isolated-scan-protocol.ts`. It must stay that narrow: Node loads it
                     directly (bundled `.js` when installed, type-stripped `.ts` in the repo),
                     so `src/bundled-packs.generated.ts` — a JSON import per rule, without import
                     attributes — would break it at load, and it never needs them anyway
                     because the ruleset arrives over `workerData`.
                     `src/bare-command.ts` is the shared bare-name spawn planner
                     (Architecture principles §7), exported as the `./bare-command`
                     subpath rather than off the index so the plugins' dashboard
                     launcher can reach it without inlining the bundled packs. It
                     imports node:child_process/os/path and nothing else.
@akasecurity/scanner        → @akasecurity/plugin-runtime, plugin-sdk, ignore (node:fs only; no fetch, no process.env)
@akasecurity/setup-wizard   → @akasecurity/plugin-sdk, schema, zod
                     (the harness-agnostic core of the /aka:setup calibration →
                     triage → remediation flow: pure logic plus dependency-injected
                     orchestration. It must NEVER name a host command, hardcode a
                     transcript location, or spawn a judge — each plugin supplies
                     those at its own I/O boundary, which is what lets multiple
                     harness plugins share this without forking it.)
```

All four CLI plugin packages bundle the SAME `plugin-runtime`/`plugin-sdk` core and differ
only in their own thin hook-entrypoint layer (stdin/stdout glue matched to each host's hook
contract) plus the harness-specific bits `plugin-sdk` deliberately keeps file-scoped (provider
resolution, tool-name → scannable-field tables).

**The hosts' hook contracts are NOT equivalent, and the differences are behavioral, not
cosmetic.** Each plugin's `skills/setup/SKILL.md` carries a "Known limitations" section that
is the authority on what its host can actually enforce; keep it accurate when the adapter
changes. The gaps that exist today:

- **Codex** fires its tool hooks per tool call, including each call a code-mode `exec`
  script makes (`exec_command` arrives as `Bash`, the web tool as `webrun`, MCP tools as
  `mcp__<server>__<tool>`); the script itself fires no hook. The plugin's PreToolUse
  matcher is `Bash|apply_patch`, so inputs are scanned for shell commands and file
  writes only. PostToolUse adds `webrun` and `mcp__*`, so web and MCP traffic is
  scanned on the way out only. PostToolUse cannot rewrite output, so a redact there
  withholds the whole result.
- **Antigravity** is the most constrained and the most different. It has only five events —
  no `SessionStart` and no `UserPromptSubmit` — so the once-per-session inventory pass hangs
  off the first `PreInvocation`. That event carries **no prompt text**, so prompts can be
  neither blocked nor redacted. `PreToolUse` has **no `updatedInput` equivalent**, so a
  `redact` policy cannot be carried out on any field — not just the executable ones the
  other two decline — and what happens instead is `WorkspaceSettings.redactFallback`,
  resolved inside `actionForFinding` rather than by the hook. That location is what keeps
  the emitted decision, `findings.actionTaken` and the ledger reading one answer; a hook
  that escalated for itself recorded a deny as `redact`. The fallback ships as `warn`, so
  a redact on this host lets the call through by default, and a `warn` has no channel to
  print on — which makes it indistinguishable from `monitor` on screen here.
  `PostToolUse` receives **no tool
  result at all**, so there is no live response scanning and no `tool-response.ts` /
  `scan-response.ts` counterpart in that package.
- **GitHub Copilot is TWO hosts behind one package, and what fails closed is the EXIT CODE.**
  On the **Copilot CLI** (and the cloud coding agent, which speaks its protocol) `preToolUse`
  is the one fail-closed event, and the hooks reference is specific about the channel: a
  hook that exits non-zero other than 2 **denies the tool call**, exit 2 denies and merges
  any stdout JSON into that deny, a timed-out one allows, and every other event fails open.
  **Empty stdout is in none of those lists.** That same reference's `preToolUse` decision
  table reads "Empty output uses default behavior", which hands the call to the host's own
  permission flow — so silence here is §1's fail-open unchanged, and the adapter writes
  **nothing on every path that reaches no verdict**. It is NOT unmeasured; the earlier
  reading that it might be is retracted, and `plugins/copilot/test/fixtures/cli/README.md`
  records under "Settled by the vendor reference" which questions the docs answer and which a
  recording still owes.
  **An explicit allow is a verdict, not a way of saying nothing**, so emitting one per clean
  call would pre-approve exactly the calls the user's own Copilot settings would have
  prompted about — a control plane widening the permissions it was installed to narrow.
  `CliPermissionDecisionOutput` therefore carries `'deny'` alone, which makes a CLI allow a
  **compile error**; VS Code's shape keeps `'allow'` because that host carries `updatedInput`
  only alongside one. What the adapter guarantees instead is that no path exits non-zero and
  none exits 2. On **VS Code agent mode** exit 2 blocks and everything else — any other
  non-zero exit, invalid JSON, a timeout — is a non-blocking warning, so that host fails
  open; it also **parses matchers and ignores them**, so the hook is spawned for every tool
  call and the unknown-tool exit is the common path rather than the rare one.
  **The two dialects are payload FORMATS, not hosts**, and the CLI speaks both: the reference
  selects the format by the event name's casing, so a manifest registering `preToolUse` and
  `PreToolUse` together spawns the hook twice per call — the second time with a payload whose
  `tool_name` is the Claude spelling (`Bash`, not `bash`) that the snake_case table has no row
  for. `plugins/copilot/hooks.json` therefore registers the camelCase event **alone**, and a
  VS Code entry belongs in the file that host itself reads.
  The VS Code half is built to the published contract and **confirmed against no live
  install**: its fixtures live in a separate `test/fixtures/vscode-provisional/` directory,
  every capability it claims is marked unverified, and
  `plugins/copilot/test/fixture-provenance.test.ts` is what keeps a doc-derived specimen from
  being filed among the recordings.
- **Antigravity also fails CLOSED**, which inverts this repo's §1 rule at the boundary: a
  hook that exits non-zero, is killed on timeout, or prints nothing is read as a `deny` on
  every tool call. "Fail-open" there therefore means _always printing an explicit_
  `{"decision":"allow"}`, which is what `runHookFailOpen` in
  `plugins/antigravity/src/hooks/shared.ts` guarantees — including on a throw and on its own
  watchdog. A silent exit is a bug that would block the user's every tool call. The guarantee
  holds over everything that **yields** and cannot reach a body that blocks the thread; §1
  states that limit and names the two suites that cover each half.

**Cross-cutting rules:**

- No `process.env` reads except the sites that explicitly opt out of `n/no-process-env` — §3 tables them, and deliberately is not restated here: a second copy of that list is how the count drifted last time.
- No `fetch()` and no transport module anywhere except `@akasecurity/remote`, which reaches only the deployment a machine's own settings name and only once it has been attached on purpose (§4). Every store-reading package (`persistence`, `local-ops`, `dashboard-ui`, `ui-kit`, `detections`, `scanner`, `web-ui`, `cli`) reads the local store directly — none of them acquires DATA over a network. Two of them, `cli` and `web-ui`, do take `@akasecurity/remote`: both for the attach verb, and both for forwarding the Data Shares register a manual scan just recorded — `aka scan` and the dashboard's Scan page — on a machine that is already attached. For a pasted key that is one `whoami` round trip, which proves the key before an attachment is written. The browser-approval path adds two more, both **unauthenticated** because the caller has no credential yet and obtaining one is the point: a grant POST, then a poll of `POST /v1/attach/token` on an interval the deployment sets, until somebody decides or the grant lapses — and then the same `whoami`. None of it acquires store data, and all of it happens only while a human is attaching, which is why it does not add an egress path to §4.
- Drizzle is imported **only** by `@akasecurity/schema`, which uses it to _define_ the local-store and registry schemas. Packages that read the store do so via `node:sqlite` through `@akasecurity/persistence` — they must not import Drizzle.
- The graph above lists **runtime** edges. Test suites may additionally take `@akasecurity/plugin-sdk` as a **dev-only** dependency for fixture seeding — the bundled detection packs (`bundledDetections()` / `registerBundledPacks`) live only there, so a test that must seed `installed_packs` or the engine registry needs it. Both `cli` and `web-ui` do this in their exception tests. A dev-only test dependency is not a runtime package-wall crossing.

## Comment & string hygiene

This repository is **public**. Shipped source must not contain internal narration:
design-doc/section/ADR/PR citations, team-member names, or other internal narration.
**Comments explain _what_ the code does, never the _why_ behind an internal decision.**
Keep prose factual and reader-facing; if you need to record rationale, put it in a commit
message — not in shipped comments or strings.

This is available to each package, and adopted per package: `comment-narration/no-process-narration`
in `@akasecurity/eslint-config` walks the comment table and refuses plan and spec paths, LLD
references, unit and task numbers, `§` section refs beside a plan cue, PR/issue and
review-response numbers, commit SHAs and dated decisions. Nothing consumes it yet — an adopting
package spreads it in its own `eslint.config.mjs` and passes `conventionsFile: 'CLAUDE.md'`, since
the rule's message names `AGENTS.md` by default and this repo's conventions live here. It is spread per package (like `noDrizzleImports`) rather than
folded into `base`, so a package adopts it in the change that clears its own backlog. Four
probes are deliberately narrow, because a ban that cries wolf gets a disable directive: a SHA must
carry a hex letter (a dated model-id suffix such as `-20241022` is not a SHA) and is skipped on a
frozen-artifact banner, where the SHA is the identity of what is pinned; a date must sit next to a
process cue (a fixture or retention date is not a decision); and a `§` must sit beside a plan cue,
because the shorthand inside a repo is the bare section number — an exemption list of standard
names left 40 false positives against 5 true ones over 1,812 files. Where a package has a
standing reason, `allowPatterns` exempts a comment and `disableProbes` switches off one probe —
both review-visible.

## Frontend UI components

Shared, reusable UI **primitives** live in `packages/ui-kit` (`@akasecurity/ui-kit`). Shared,
reusable **presentational composites** (stat tiles, charts, the security widget views)
live in `packages/dashboard-ui` (`@akasecurity/dashboard-ui`). App-specific composition and data
wiring live in the app (e.g. `web-ui/app`).

`@akasecurity/dashboard-ui` is **bundler-agnostic and props-driven**: it depends only on
`@akasecurity/ui-kit` + `@akasecurity/schema` types and does **no data fetching**, so the Next.js
dashboard (`web-ui`, via `@akasecurity/persistence` Server Components) can feed it. It imports no
SVG assets via a bundler loader (svgr) — icons are inlined or taken as an `IconComponent`
prop — and marks interactive components with `'use client'`. Put a widget's presentation
here (a dumb `*View`) and its data-fetching wrapper in the app.

When adding a **new reusable component** to `@akasecurity/ui-kit`, follow the shadcn/ui pattern:

- **Build on Radix UI primitives** (`@radix-ui/react-*`) for anything interactive or with
  accessibility/focus/positioning concerns (popover, dialog, dropdown, tooltip, select, etc.).
  Do **not** hand-roll outside-click, focus traps, or anchored positioning — Radix already
  solves these. Add the matching `@radix-ui/react-*` package rather than reinventing it.
- **Expose a compound, composable API** (`Card` / `CardHeader` / `CardTitle` / `CardContent`…),
  not a monolithic prop-driven component. Each part is a plain function component that spreads
  native props, merges `className` via `cn`, and carries a `data-slot="…"` attribute.
  On React 19, **`ref` is a regular prop** — type props with `ComponentPropsWithRef<'div'>` (or
  `ComponentPropsWithRef<typeof RadixPrimitive.X>`) and let `ref` flow through `...props`. Do **not**
  use `forwardRef` (deprecated in React 19). See `card.tsx`, `button.tsx`, `popover.tsx`.
- **Style with Tailwind + design tokens** from `theme.css` (`bg-surface`, `text-text-2`,
  `border-border`, severity/`ok` tokens…). Use `cva` for variants (see `button.tsx`,
  `badge.tsx`). No hardcoded hex — add a token to `theme.css` if one is missing.
- Each component is its own file under `packages/ui-kit/src/`, exported from `src/index.ts`.

### Tonal tokens come in pairs: a hue and an ink

Every tonal family (`sev-critical`, `sev-high`, `sev-medium`, `sev-low`, `ok`, `teal`,
`violet`) carries **two** foregrounds, and picking the wrong one is an accessibility bug
that compiles, renders, and reads fine in review:

- `--color-X` — the **hue**: chart series, status dots, bar segments. Non-text, so what
  matters is staying distinguishable from its neighbours (WCAG 1.4.11).
- `--color-X-ink` — **text** on that family's tint, and solid fills that carry text
  (WCAG 1.4.3, 4.5:1).

They cannot be collapsed. Forcing every hue dark enough to read as text on a pale tint
crowds them into one luminance band — severity chart separation measured 1.69 before and
1.05 after, with red/orange/yellow rendering as the same brown. So **a foreground always
spells the `-ink` form**, and every family carries one even where its hue already clears
4.5:1 (violet in light; all of them in dark, where ink == hue): a family missing its
`-ink` makes `text-X-ink` generate no CSS at all, because an undefined theme variable
produces no utility and the element silently inherits its color.

`--color-primary` is the one exception and reads the other way round — it **is** the ink,
and `--color-primary-solid` is its fill. So `text-primary` is right while
`text-sev-critical` is wrong, and nothing in the names says which. That is why it is
enforced rather than remembered: `tonalInkTokens` in `@akasecurity/eslint-config` is a
`no-restricted-syntax` ban on a bare hue reached through `text-*`, spread by `ui-kit`,
`dashboard-ui` and `web-ui`. It re-spreads `noNetworkSyntax()` because a flat-config
`rules` entry **replaces** the rule's options rather than merging them — drop that and
those three packages silently lose §4's dynamic-import ban.
`packages/eslint-config/test/tonal-ink-tokens.test.js` pins both halves.

What the ban does **not** see, and what still needs reading: a hue handed over as a CSS
variable string, since `iconColor="var(--color-ok)"` and `iconBg="var(--color-ok-fill)"`
are the same node to a selector (`toneColors()` is the live example); and a class
assembled from a non-literal.

`tonalInkTokens` is no longer only the tonal ban: it is the WHOLE
`no-restricted-syntax` value those three packages get, assembled by
`reactSyntaxBans()` from five groups (network, drizzle, tonal, ambient clock,
ambient locale) because a later entry replaces the rule's options rather than
merging them. A per-file opt-out is therefore written as `reactSyntaxBans({ … })`
with one group switched off, never as a hand-listed subset — the other four come
along by construction, so lifting one ban cannot silently lift five. A widening is
an option too, so it does NOT come along: in `dashboard-ui`/`ui-kit` `src/**` the
per-file form is `presentationalSyntaxBans({ … })`, which keeps both widenings on,
and in web-ui's `app/**` an entry has to pass `ambientLocaleEveryModule: true`
itself (see the clock exemption below, and `web-ui/eslint.config.mjs`).

### A time label is a function of two instants, and a client component is given both

A page under `'use client'` is rendered **twice** — once on the server, once when
the browser hydrates it. A label derived from the ambient clock is therefore two
different strings whenever a rounding boundary falls between those two renders,
and React resolves that by discarding the server HTML for the subtree. It is not
only cosmetic: `exceptionState` gates **structure**, since `ExceptionDetailView`
renders its revoke form only while the state reads `active`, so the two renders
can disagree about whether a whole subtree exists.

The class is closed in the type system rather than site by site. `relativeTime`,
`relativeTimeShort` and `exceptionState` take `now` as a **required** argument,
and every time-derived view takes a required `renderedAt: number` prop — a
DEFAULT would not do, because a default reads as safe at every call site that
omits it, which is every call site until somebody remembers. Required, the
compiler names each place that has to decide which instant it means, and a host
that forgets one fails the build instead of shipping the defect.

Where each instant comes from:

- A **server component** captures one per request with `renderInstant()`
  (`web-ui/app/lib/rendered-at.ts`) and passes it down. It is a function, not a
  module constant: a constant is captured once when the module loads and then
  served to every later request, so a long-lived dashboard process would render
  every age against the instant it booted. `exceptions-page.test.ts` pins that
  by rendering the route twice under two clocks.
- A **client component** that must not go stale feeds it to `useRenderClock`,
  which returns that instant for the render React compares against the server's
  HTML and the live clock from the commit after. It is built on
  `useSyncExternalStore`, whose third argument IS the server snapshot, so the
  split is in the signature rather than in a comment about effect ordering.
  Freezing is the floor everywhere; the refresh is applied where a derived
  lifecycle decays — a temporary grant expires in 30 minutes and a dashboard tab
  outlives that, so a frozen instant would label an expired grant `active` and go
  on offering to revoke it.

The remaining hole — satisfying the required argument with a fresh clock read at
the call site, which typechecks perfectly — is closed by the ambient-clock
selectors above. They are scoped by `Program:has(> ExpressionStatement[directive=
'use client'])`, so they fire only where the directive is a real directive-prologue
statement: a **server** component may still read the clock, which is what makes
capturing an instant possible at all, and a file that merely mentions the string
as a value is untouched. `Date.now()` and a zero-argument `new Date()` are clock
reads; `new Date(iso)` and `Date.parse(iso)` parse a value the caller already had
and are deliberately left alone.

Two things about that ban are worth knowing before trusting it. It reaches what
`react-hooks/purity` cannot — that rule reports a clock read inside a COMPONENT,
so a module-level helper in a client file is invisible to it, and that is exactly
where the one site this sweep did not find by hand was living
(`HarnessOverview`'s `formatEvent`). And it cannot follow a binding, so a clock
reached through an imported helper goes unseen; the required argument is what
covers that direction. `packages/dashboard-ui/src/lib/useRenderClock.ts` is the
one sanctioned reader, and its exemption is written in that package's
`eslint.config.mjs` through `presentationalSyntaxBans({ allowAmbientClock: true })`
— the presentational form, because a per-file entry re-states the whole rule and
one written through bare `reactSyntaxBans` would also drop the locale ban's
`src/**` widening for that file.

**A locale-formatted string is the same class, and it is closed the same way.**
`toLocaleString()` and its siblings, called with no locale, format in the
RENDERER's own locale, and the server's need not be the browser's: a Node host on
en-US emits `6,456` where a de-DE browser hydrates `6.456`, and passing one
instant does nothing for that. A server component has the mirror-image defect — it
renders once, but in the Node process's locale rather than the reader's. So the
locale is a second value a route captures per request and passes down:

- **`renderLocale()`** (`web-ui/app/lib/render-locale.ts`) reads the request's
  `Accept-Language` header and resolves it through `resolveLocale`
  (`packages/dashboard-ui/src/lib/locale.ts`) to the highest-weighted tag this
  runtime can format, else `en-US`. It is the sibling of `renderInstant()`, and
  for the same reason a function rather than a constant.
- Every shared formatter takes the locale as a **required** argument —
  `formatNumber` (`lib/numberFormat.ts`), `formatDateTime` (`lib/dateFormat.ts`),
  `startLabel`, `eventTime`, `fmtDateTime` — and every view that formats a count
  or a time takes a required `locale: string` prop, beside `renderedAt`. A
  default would hide every call site that forgot one, exactly as it would for the
  instant.
- The **ambient-locale** selectors are the ban that stops a call site formatting
  around those helpers: an argument-less `toLocale*String()`, its `[]` and
  `undefined` spellings, and the `Intl` formatter constructors in the same three
  forms. Unlike the clock ban, nothing in a UI package legitimately reads the
  runtime's locale, so it is widened to every module in `dashboard-ui` and
  `ui-kit` (`src/**`) and in `web-ui`'s `app/**` and `middleware.ts` — server
  components included, which is where the server-locale form lived. That
  widening is held against each package's REAL resolved config by
  `packages/eslint-config/test/package-walls.test.js`; `ambient-locale.test.js`
  tests the selectors and cannot see a config that stopped applying them. A NAMED locale passes, which is
  what lets the deliberate pins stay: compact notation (`compactCount`,
  `compactNumber`) is pinned to `en-US` because K/M/B/T is a product-wide
  convention shared with the CLI and the plugins, and `relativeTime` is pinned to
  `en` because its words are the dashboard's English copy rather than a format.

Two things this does not reach. The time ZONE is still each renderer's own; the
dashboard answers only loopback requests (`web-ui/middleware.ts`), so the browser
and the server share a machine and a zone, and a request carries no zone to pass.
And the same locale can still format differently under two ICU builds — Node's and
the browser's — so a CLDR change between them is a residual mismatch no prop
closes. Two suites hold the rest, one per layer.
`web-ui/test/pages/locale-hydration.test.ts` server-renders the route CLIENT
components and the dashboard views — never a `page.tsx` — under one runtime
default and hydrates them under another (`test/helpers/runtime-locale.ts` moves
the default); its first cases are the control that a bare `toLocaleString()` is
reported through the same harness. `web-ui/test/pages/render-instant-wiring.test.ts`
calls the pages themselves, and is what proves a route reads the request's
`Accept-Language` and hands that locale to every consumer.
`suppressHydrationWarning` silences a warning without reconciling anything, so it
is never a substitute for passing an instant or a locale.

## Detection rules

See `skills/write-detection-rule/SKILL.md`. A rule PR carrying fewer than 2 positive or
2 negative fixtures is rejected by CI — `packages/detections/test/engine.test.ts` asserts
the bar per rule, and each posture suite under `packages/detections/test/posture/` asserts
it for its own rules. Every one of them reads it from
`packages/detections/test/helpers/fixture-bar.ts`, so the number and its message live in
one place; the count is of DISTINCT cases, because a repeated fixture exercises nothing the
first one did not.

Any change to the `installed_packs` / `available_packs` **write semantics** must extend the
legacy-writers suite (`packages/persistence/test/repositories/legacy-writers.test.ts`) — it
replays frozen SQL from already-shipped binaries, which app-level guards cannot reach.

## Repository layout

```
cli/                  the `aka` CLI (self-contained npm bundle; ships the web-ui as a spawned Next server)
web-ui/               the OSS Next.js dashboard (Server Components read ~/.aka; Server Actions mutate it)
plugins/claude-code/  the Claude Code plugin (hooks + commands; self-contained npm bundle)
plugins/codex/        the Codex CLI plugin (hooks + skills; self-contained npm bundle)
plugins/antigravity/  the Antigravity plugin (hooks + skills; self-contained npm bundle)
plugins/copilot/      the GitHub Copilot plugin — the Copilot CLI, VS Code agent mode and
                      the cloud coding agent, one package over two payload dialects
plugins/browser-extension/  the Chrome extension for ChatGPT + Claude.ai web chat (MV3
                      content scripts + a native-messaging host; private — bundled into
                      the CLI by `bundle:extension`, installed via `aka extension install`)
packages/             the workspace libraries (schema · persistence · local-ops · detections ·
                      remote ·
                      extract · dashboard-ui · ui-kit · plugin-runtime · plugin-sdk · scanner …)
rules/                the built-in detection packs (rule JSON + fixtures)
skills/               agent skills (e.g. write-detection-rule)
tools/                repo tooling, never shipped: the installer one-liners and
                      the installer workspace package whose suite drives them end
                      to end, plus audit-gate (CI dependency-audit),
                      portability-gate (cross-platform test rules),
                      coverage-gate (per-PR diff coverage), and
                      package-manifests (renders the Homebrew formula, Scoop
                      manifest and VERSION file a binary release publishes,
                      from that release's own SHA256SUMS)
```

## Adding a new workspace package

1. Create `packages/<name>/package.json` with `"name": "@akasecurity/<name>"`
2. Extend `../../tsconfig.base.json`
3. Add an `eslint.config.mjs` extending `@akasecurity/eslint-config`, and spread
   `...rootConfigFiles` **after** the block that turns `projectService` on. Root config
   files sit outside the tsconfig `include`, so the type-aware parser rejects them
   outright (`was not found by the project service`) and reports nothing else about
   them; that block drops the type-aware rules for `*.config.*` at the package root so
   they lint. Every network ban is syntactic and still fires.
4. Export from `src/index.ts`
5. Add `"lint"` and `"typecheck"` scripts — the `lint` script must run `eslint` over
   **every directory the package ships code in and every lintable file at its root**,
   whatever they are named (a bare `.` counts; naming individual files counts only for
   those files, not for the directory they sit in). Turbo silently skips a package with
   no `lint` script, so a config nothing points ESLint at enforces nothing. `*.config.*`
   is the standing target for the build and tooling config; name any other root file
   explicitly (see `web-ui`'s `middleware.ts`). A `scripts/` dir of **hand-written
   (git-tracked)** scripts needs its own `eslint.scripts.config.mjs` plus a second pass
   (`eslint --no-config-lookup -c eslint.scripts.config.mjs scripts`) — a generated
   `scripts/` dir (the plugin's bundled hooks) is build output and is exempt.

   **Naming a target is not enough if the same invocation hands it back.** An
   `--ignore-pattern` cancels a target it matches, so `*.config.*` plus an ignore of
   `vitest.config.ts` reads as covering that file and lints it not at all. Anything an
   ignore flag excludes therefore counts as uncovered however the targets read, and the
   fix is to drop the flag rather than the directory. `--ignore-path` counts as excluding
   its whole invocation: flat-config ESLint rejects the flag outright, so that `eslint`
   call lints nothing. No `lint` script carries either flag today, and
   `packages/eslint-config/test/effective-config.test.js` is what would catch one that
   took coverage back. It bounds the damage rather than banning the flag: an ignore
   matching nothing the package ships stays green, which is what stops the guard
   over-reporting.

   **That holds at FILE granularity, not just directory granularity, and the
   difference is the whole exposure.** A directory target is checked as a directory,
   so an ignore is reduced to its literal prefix to answer whether the directory
   survives — and `--ignore-pattern test/probe.test.ts` reduces to a base that
   neither equals `test` nor prefixes it. Read at that level alone the directory
   goes on reading as covered while ESLint skips the file, which is worse than an
   unguarded path rather than equal to it: the reviewer who checks coverage finds a
   green guard. So the tracked files inside each covered directory are enumerated
   and checked individually, and one an ignore takes back out is named. The two
   buckets partition the failure — where the whole directory is excluded, the
   directory bucket names the package and the file bucket stays quiet, so one
   mistake prints one actionable line rather than that line plus every file
   beneath it.

   A `lint` script is a shell string, so **two ESLint calls are chained with `&&`
   and nothing else**. Behind a `||` the second runs only once the first has
   failed, so no green run ever lints what it targets; `|| true` is the mirror
   image, running the call and discarding its exit code. The same guard drops a
   segment carrying any operator but `&&` — reporting the package as covering
   nothing, rather than crediting a call a green run skips.

   That covers files a **package** owns. Files **outside every package** — at the
   repo root or under a directory no package claims — are a separate case with its
   own pass: `pnpm lint:root` / `pnpm typecheck:root`. Both run outside Turbo
   (`turbo run …` drives per-package scripts, each with its package as the working
   directory, and no package's `lint` script targets anything outside its own
   tree), and CI runs them as their own steps beside `format:check`. `pnpm lint`
   and `pnpm typecheck` additionally **chain** them with `&&`, so the pre-push
   hook, the two release workflows and a contributor running them locally all
   cover the repo root rather than reading green while a file there is unlinted or
   unchecked — those callers run only the workspace-wide scripts and never the CI
   steps. The chain has to be unconditional: behind a `||` the root pass runs only
   once the workspace pass has already failed, which is every green run skipping
   the repo root. `lint:root` is a single invocation: `eslint.root.config.mjs` runs
   the full ruleset over `test/setup/**`, `test/fixtures/**`, `test/helpers/**`,
   `test/vitest/**`, `tools/ci/**`, and the repo-root `*.config.*`.
   `typecheck:root` runs `tsc -p tsconfig.root.json` over the same set.

   It used to carry a second, network-only invocation over the plain-JS
   enforcement suites in `packages/eslint-config/test/**`, because that package's
   `lint` was a deliberate no-op and a root pass was the only thing that could
   reach them. It lints itself now — `eslint src *.config.*` then
   `eslint --no-config-lookup -c eslint.guard.config.mjs test`, the same
   full-ruleset + network-only split every other two-pass package makes — so those
   suites are covered by the ordinary per-package check and nothing about them is
   special any more.

   **Naming a target is not enough here either.** The rule the package paragraph
   above draws applies to `lint:root` unchanged: an `--ignore-pattern` cancels a
   target it matches, and an `--ignore-path` cancels the whole invocation, so the
   script reads as covering a repo-root file that ESLint then skips. Either way the
   file counts as **uncovered** and the failure names it, and the fix is to drop the
   flag rather than the target. This is enforced rather than remembered — the
   derived check below reads each invocation's ignore flags next to its own targets,
   so an ignore that takes a root file back out fails exactly as a missing target
   does.

   Anything new outside every package belongs in those passes — and a **file** at
   the root is named explicitly, not folded into a directory glob (the same rule
   step 5 draws inside a package). `*.config.*` is the standing lint target for root
   config and catches the root ESLint config and commitlint's; any other root
   file is named by hand — including one whose `.config.` segment is capitalized,
   since a shell glob is case-sensitive on every platform and `*.config.*` reaches
   `aka.config.mjs` but not `aka.Config.mjs`. A root file carrying `// @ts-check`
   (as `eslint.root.config.mjs` does) is also named in `tsconfig.root.json`'s
   `include`, or the directive is
   decorative — nothing runs `tsc` over it and a real type error surfaces nowhere.
   Miss the pass and esbuild strips its types unchecked and nothing lints it.

   **Forgetting is caught rather than remembered.** `effective-config.test.js`
   derives the set of git-tracked lintable files belonging to no workspace package
   (`git ls-files` minus the `pnpm-workspace.yaml` globs — never a hardcoded list,
   or a file added tomorrow would not be in it), and fails naming each one that no
   eslint invocation lints. The coverage side is derived too, and from what is
   actually **run**: the invocations are walked out of the root `package.json`
   starting at `lint` and following the scripts it **unconditionally** chains, so
   a pass sitting in a script no gate invokes — a `lint:fix`, say — covers
   nothing, and neither does one the chain reaches only on failure. That rule
   applies to the eslint calls inside a script as well as to the scripts it
   chains: `eslint <a> || eslint <b>` reads as two passes and runs the second only
   once the first has already failed, so a green run never lints `<b>`. A
   fault-injection case then plants network code at each real path and requires
   all four network
   rules to fire with no fatal parse error, because a file outside every tsconfig
   `include` reports a parse error and NO rule violations — structurally wired,
   behaviorally correct, enforcing nothing. Adding such a file means adding a lint
   target for it and listing it in `EXPECTED_NON_PACKAGE_FILES`.

   It also means keeping the repo-root glob in `@akasecurity/eslint-config#test`'s
   `turbo.json` `inputs`: the per-package input globs all require a directory
   segment, so without it a new root-level file leaves that task's hash untouched
   and turbo replays a cached green in which the check never ran. Those globs spell
   their extensions by hand, so the same suite drives that list against the one it
   enumerates by — a file whose extension it counts as lintable and the glob omits
   is the same cached-green hole one extension wide.

   Those checks read `turbo.json` as TEXT and match its globs with
   `path.matchesGlob`, which is a MODEL of turbo's hashing rather than turbo's
   hashing. One case asks turbo instead: it plants a lintable file at the repo
   root, runs `--dry=json`, and asserts the hash really moves — the only reading
   that decides whether this suite RUNS. Two things in it are load-bearing. It
   selects the task by `taskId`, because `tasks[0]` is `#build`, whose inputs are
   package-local and which no repo-root file ever moves. And it carries a
   POSITIVE CONTROL that plants under a different input (`tools/ci/**`) first, so
   a broken measurement is reported as a broken measurement rather than as a
   missing glob — without it, "the hash did not move" reads the same whether the
   glob went or the selection did.

6. Add the package name to `EXPECTED_WORKSPACE_PACKAGE_NAMES` in
   `packages/eslint-config/test/effective-config.test.js`. That pinned list only
   forces a human to notice the new package — what actually stops it shipping
   unguarded are the assertions next to it (a missing config, a config that never
   extends the shared one, a `lint` script that misses a directory or a root file, a
   root file the linter cannot parse). A package under `tools/` also takes a
   `WALL_EXEMPT_PACKAGES` entry in `package-walls.test.js`, and moves the lint-script
   count `required-checks.test.js` pins.

7. If it has a `test` script, run those tests through **vitest** and wire the
   no-network guard into its `vitest.config.ts`, then add its name to
   `EXPECTED_VITEST_PACKAGES` in
   `packages/eslint-config/test/no-network-runtime.test.js`. Copy the block from a
   neighbour at the same depth — the relative path is `../../` from
   `packages/<name>/`, `plugins/<name>/` or `tools/<name>/`, and `../` from a
   repo-root package (`cli`, `web-ui`):

   ```ts
   const noNetworkGuard = fileURLToPath(new URL('../../test/setup/no-network.ts', import.meta.url));
   // …
   test: { setupFiles: [noNetworkGuard], … }
   ```

   A package whose dependency closure can load `@akasecurity/persistence` wires a
   SECOND setup file beside it, `test/setup/no-managed-settings.ts` — the
   administrative overlay is read from absolute system paths a temp home cannot
   redirect, so without it the package's suite reads whichever administrator the
   developer's own machine is enrolled with. That set is DERIVED rather than
   listed, by `packages/eslint-config/test/no-managed-settings-guard.test.js`, so a
   new package that takes such a dependency is told to wire it rather than
   discovering it as twenty-eight unrelated-looking failures later. See "The
   no-managed-settings guard" under Testing.

   A package that also declares `testTimeout`/`hookTimeout` in that config must add
   itself to `TIMEOUTS` in `packages/eslint-config/test/hook-timeout-ratchet.test.js`,
   which pins every package's ceilings as an EXACT map. The ratchet holds in BOTH
   directions on purpose. Raising a timeout is the change that must not pass unnoticed:
   the per-test SQLite migration above was 'fixed' once by moving this number from
   vitest's 10s default to 20s, which bought a green run and let the same failure spread
   from one package to four. Lowering it is a good change — setup is a file copy now, so
   there is headroom to give back — but a deliberate one. Raising it through the `test`
   script's CLI flags instead is refused by the same suite, since the pin reads config
   literals and a flag would bypass it silently.

   The runner is not incidental: the guard is a vitest `setupFiles` entry, so a
   package testing through anything else (`node --test`, say) runs with no runtime
   network guard at all. That suite enumerates every package with a `test` script
   and fails on any that is not vitest, so such a package has to be argued into
   `EXPECTED_NON_VITEST_TEST_PACKAGES` — a list that is empty and should stay that
   way, since every entry is a hole in the guarantee.

8. If any file the package ships or lints imports a `node:` builtin or reads
   `import.meta.dirname`, declare `@types/node` in its OWN `devDependencies`, at
   the single range every other manifest carries. Omitting it typechecks here and
   fails nowhere in this repo: TypeScript walks up to the repo root's
   `node_modules/@types` and finds the copy the root's devDependencies installed,
   so the manifest reads as complete while the package is only ever built from
   this workspace. It breaks the moment the package is consumed from one that does
   not install this root — TS2307 on the `node:` specifier, TS2339 on
   `import.meta.dirname`. Undeclared, it also lets pnpm resolve vitest's optional
   `@types/node` peer for that importer independently of what the compiler
   resolves from the root, putting two majors in play for one package. Three
   packages carried this at once, which is why it is derived rather than
   remembered: `packages/eslint-config/test/effective-config.test.js` reads each
   package's own tracked source against its own manifest, so a package that skips
   this reds CI instead of being caught in review.

**All three of those guards enumerate the tree at RUN time, which is what makes adding a
package the action most exposed to a stale base.** A `pull_request` check runs against a
merge commit GitHub built when the branch was last pushed and never rebuilds as `main`
moves, so a package added on one branch and a file it would judge added on another are
each green against a tree the other has already changed. Neither diff contains the
mismatch and the first run to see both is the post-merge run on `main` — which is how a
new package's unlinted root config file reddened `main` from a PR whose own diff was
innocent. Deriving is still right; a pinned list stays green through exactly the drift
these exist to catch. What closes it is a repository setting rather than anything in this
tree, and CONTRIBUTING.md's "Branch freshness" section records which one is in use and
what it obliges `.github/workflows/` to carry. Read it before assuming a green PR check
saw your package.

## Commit messages

Follow Conventional Commits: `feat:`, `fix:`, `chore:`, `test:`, `docs:`, `refactor:`. Enforced by commitlint on commit-msg.

## Releasing (CLI / plugin versioning)

All four shippable artifacts are **self-contained bundles of the workspace** — their `tsup.config.ts`
sets `noExternal: [/^@akasecurity\//]`, so every `@akasecurity/*` package they use is inlined into
the published output (the user's machine has no `node_modules`). So a change to a _bundled_ package
changes the shipped artifact **even when the app's own `src/` is untouched**:

- **`plugins/claude-code`**, **`plugins/codex`** and **`plugins/antigravity`** each bundle
  `@akasecurity/plugin-runtime` + `plugin-sdk` and everything they pull in —
  `@akasecurity/schema`, `persistence`, `detections`. A change to any of those changes ALL
  THREE plugins' `scripts/*.js`.
- **`cli`** bundles the same `@akasecurity/*` packages **and** ships the OSS web-ui
  (`web-ui` is `external` to the CLI JS but copied in by `prepack`'s `bundle:web-ui` and
  spawned as a separate Next server). So a web-ui change — or any bundled-package change — changes the CLI.
- **`plugins/copilot` bundles the same core and is NOT one of the four.** It builds
  `scripts/*.js` through the same `tsup` config and inlines the same `@akasecurity/*`
  packages, but it is still `"private": true` and publishes nowhere, so it moves on no
  version line and belongs in no bump above. That sentence stops being true the moment
  `private` is unset — which is also what moves `required-checks.test.js`'s temporary
  Windows pin and the `ci.yml` prose that justifies it, so all three change together or
  `main` reds on a diff that looks innocent.

When a change touches the web-ui or any bundled package and the user wants to publish:

1. **Bump every affected artifact**:
   - web-ui / `local-ops` / `dashboard-ui` / `ui-kit` change → `cli` (bundled into the CLI JS
     and/or the web-ui it ships; the plugins bundle none of these).
   - `schema` / `persistence` / `plugin-runtime` / `plugin-sdk` / `detections`
     change → **all four** of `cli`, `plugins/claude-code`, `plugins/codex` **and**
     `plugins/antigravity` (all bundle them).
   - `setup-wizard` change → `plugins/claude-code`, `plugins/codex` **and**
     `plugins/antigravity` (all three bundle it; the CLI does not).
   - The CLI and all three plugins normally move together on one shared version line.
   - **Any `cli` bump → `plugins/browser-extension` too, to the SAME version.** The extension
     ships inside the CLI tarball, its `package.json` is the one place its version lives
     (`scripts/build.mjs` stamps it into `dist/manifest.json`, and the native host records it as
     `meta.pluginBuild` on every session), and `cli/scripts/bundle-extension.mjs` REFUSES to
     bundle a built manifest whose version is not the CLI's. That script runs from the CLI's
     `prepack`, which `ci.yml`'s `packaged-artifact` job runs — so a CLI-only bump reddens
     every pull request, not one release run. The committed `manifest.json` carries `0.0.0`,
     which Chrome rejects, so an artifact nobody stamped is detectable rather than shippable.
     A STORE archive (`package:zip`) is built from a bare version only, and only from a `dist/`
     that agrees with `package.json` on both halves (`storeArchiveRefusal`): every pre-release of
     one core collapses onto the same Chrome `version` and the same archive name, so at most one
     could ever be uploaded, and a stale `dist/` would otherwise ship under the old number.
     Chrome's `version` takes one to four integers and **no suffix**, so a pre-release package
     version is built as its numeric core in `version` plus the whole string in `version_name`
     (`builtManifest` in `src/packaging/store-zip.ts`); the pack-time check reads both halves.
2. Keep `plugins/claude-code/.claude-plugin/plugin.json` in sync with
   `plugins/claude-code/package.json`, `plugins/codex/.codex-plugin/plugin.json` in sync
   with `plugins/codex/package.json`, and `plugins/antigravity/plugin.json` (Antigravity reads
   its manifest from the plugin ROOT, not a dotted dir) in sync with
   `plugins/antigravity/package.json` (identical version within each pair) whenever that plugin
   is bumped.

**The bump is not a decision to surface during feature work, because it is not made there.**
Pre-1.0.0 the version numbers are chosen **ad hoc**, at the **scheduled release** (twice a week)
— not per change. So do not ask which release type to use, and do not raise "this touches a
bundled package, so it needs a version bump" as an open question, a follow-up, or a caveat in a
summary or PR description. The steps above are what a **release** does: which artifacts move is
derivable from the bundling rules, and how far they move is settled at release time by whoever
cuts it.

Versions are bumped by hand in a `chore(release):` commit (no changesets). A stable release is
a bare `X.Y.Z` on the one shared version line; a pre-release carries a suffix and is routed by
it (below). The CLI and all three plugins currently share the `0.9.x` line.

### Release channels — the suffix decides the dist-tag

Every npm publish step in `release-cli.yml` and the three `release-plugin-*.yml` routes on the
version it is publishing, with one shell `case`: a `-beta.*` version goes to the `beta` dist-tag,
`-nightly.*` to `nightly`, any other suffixed version to `rc`, and a bare `X.Y.Z` to `latest`.
**On npm, only a bare version moves `latest`** — and only a bare `cli-v*` tag moves the
`cli-latest` alias — so a published pre-release stays reachable by its dist-tag and reaches no
machine that did not ask for it. The binary channel gives the same guarantee by a DIFFERENT
mechanism, and the difference is load-bearing: see "Binary (SEA) channel" below. There is no separate
tag prefix for a pre-release: `cli-v0.10.0-beta.1` rides the existing prefix, which is what keeps
each workflow's tag-to-manifest equality check in force. Nothing here notifies the marketplace
that pins the Claude Code plugin: its importer reads what it needs from npm and accepts only an
exact `x.y.z`, so declining a suffixed version is a rule that lives on that side, in one place.

`packages/eslint-config/test/release-channels.test.js` holds that by EXECUTING each publish
step's extracted `case` block under bash against sample versions, rather than by reading the
arms — arm ORDER is the defect that matters (`*-*` ahead of `*-beta.*` sends every beta to `rc`),
and only running it shows that. It reaches the workflows through one shared reader,
`test/helpers/release-workflows.js`, which `release-identity.test.js` uses too: two readers of
one shell string are free to disagree about what a workflow publishes.

**A pre-release moves the whole shared line.** `release-identity.test.js` puts the four
published artifacts on one version and each plugin's identity suite pins it to the CLI's; the
extension is private, so it is outside that audit's set and is pinned to `cli/package.json` by
its own `test/manifest.test.ts`. So `0.10.0-beta.1` is a `chore(release):` commit
moving all four published manifests, the three host manifests and the extension together. Cut
that commit on a release branch: on `main` it would sit in front of the next stable cut until the
matching stable shipped.

**A machine's channel is DERIVED, never stored.** `channelOfVersion` (`@akasecurity/local-ops`,
`src/release-channel.ts` — pure, no I/O) reads it off the first pre-release identifier of the
version a component is RUNNING, per component, every time the update report is built. There is
no settings field, and adding one would be wrong rather than merely bigger: a stored channel can
disagree with the bytes on disk, and it would be a third `settings.json`-class writer (§6). The
update surfaces ask `npm view <pkg> dist-tags --json` and offer the newer of the channel's own tag
and `latest` — that max is the whole graduation mechanism, since `beta` goes on pointing at
`0.10.0-beta.3` after `0.10.0` ships. A stable machine reads `latest` and nothing else.

**What is offered is what is installed.** The install spec is `<package>@<the resolved VERSION>`,
never `@<dist-tag>`, on all three paths — `aka update`, `aka update --channel`, and the
dashboard's apply — because all three build it through one function (`cliInstallSpec` in
`install-channel.ts`). Installing the tag instead is what made a graduated beta machine re-install
its own pre-release and be offered the same update for ever. That version is REGISTRY-SUPPLIED
text headed for argv, so it crosses `isExactSemver` first — not `isSemver`, which trims, so
`' 1.0.0 '` and `'1.0.0\n'` pass it. Anything the strict check refuses is treated as unresolved,
and only then does the spec fall back to the dist-tag from the closed `DIST_TAG` table. Where the
offered version came from is a typed answer (`RELEASE_TAG_SOURCE`: channel, graduated,
unpublished, unknown) rather than something folded into `latest`.

The vocabulary is two const-object registries joined by member name (§2's rule, for the same
reason): `RELEASE_CHANNEL` is what a user types (`stable`), `DIST_TAG` — annotated
`Record<ReleaseChannel, string>` — is what the registry serves (`latest`). Collapsing them makes
the documented `--channel stable` unparseable. The switchable set is derived from `DIST_TAG`'s
keys, never re-listed.

`aka update --channel <stable|beta|nightly>` switches the **CLI only**, including with the `all`
target: a plugin's channel is its host's marketplace registration, which this process cannot
change, so the flag is refused outright for a plugin target. The token is parsed against the
closed union BEFORE any spawn, because the Windows shell path concatenates argv unescaped (§7).
An explicitly requested channel is refused before the report is rendered, before any
confirmation and before any spawn, in either of two registry states — rendering first would
print the stable version beside the requested channel's name, which is the sentence a user acts
on in both. `Unpublished` is a channel that serves NO tag at all. `Graduated` is the other:
the channel HAD a tag, but the stable release has since overtaken it, so `resolveChannel`
answers the request with the stable version rather than one the requested line ever published —
`resolveChannel({latest:'0.12.0',beta:'0.11.0-beta.4'},'beta')` answers `{version:'0.12.0',
source:'graduated'}`, and unrefused that renders as `aka CLI: 0.9.11 → 0.12.0 (beta)`, a stable
release captioned with a channel name it never carried. The derived path keeps its fallback to
`latest` on BOTH states, which is intended: a beta machine whose tag has been retired, or that
has already graduated onto stable, must still be offered the stable rather than refused for a
request nobody made. The binary and Homebrew installs cannot express a channel and say so.
A standalone binary's plan names WHO manages it: `SEA_OWNER` (Homebrew / Scoop / Standalone,
`install-channel.ts`) is derived from the running executable's real path — a brew-prefix-anchored
`Cellar/aka/` keg is Homebrew (`brew upgrade aka`); an `apps/aka/<version or current>/` run is
Scoop (`scoop update aka`) only when a sibling `shims` directory exists at that root, because
`install.ps1 --dir <…>\apps\aka` lays down a byte-identical path on a machine with no Scoop;
anything else is Standalone (re-run the installer one-liner), and only Standalone carries an
`installRoot`. The advice table is annotated `Record<SeaOwner, …>` and `describeSea` has no
default, so a fourth owner fails to compile at both. The npm-under-brew case (`kind: 'homebrew'`,
an npm global inside brew's `node` keg) still prints `brew upgrade aka`, which now names the
tap's formula: it fails on a machine that has not tapped it, and on one carrying both it upgrades
the standalone copy rather than this npm one. Changing that advice is deferred on purpose —
`brew install akasecurity/tap/aka` collides at link with the npm `aka` already in that prefix.
`aka update` only moves forward: switching to a channel whose newest release is behind the
running copy installs nothing and says so. A marketplace `version`
pin is honoured only when it is an EXACT semver — a range used to be returned verbatim, which
froze `updateAvailable` at false for ever.

What is NOT built: nothing publishes a `nightly`, and no beta marketplace entry exists. A Claude
Code marketplace entry pins an exact version or a range, never a dist-tag, while a Codex entry
may name a dist-tag; and Claude Code installs from a different marketplace REPOSITORY
(`akasecurity/marketplace`, as `ai-tc@akasecurity`) than Codex (this one, as `aka-codex@ai-tc`),
so the two entries live in different manifests. A beta door for plugin users is therefore a
per-host decision, and it has not been made. `rc` is a dist-tag the workflows publish and NOT a
channel: `--channel rc` is refused and a machine running an `rc` build reads as stable, so an
`rc` is installed by exact version or `@rc`.

### Release branches

`ci.yml` triggers for `main` AND every `release/**` branch on both `push` and `pull_request`, so
a pull request based on a release branch runs the same jobs and reports the same check NAMES as
one based on `main`. There is deliberately **no lighter gate** for a promotion: the jobs have no
`needs:` edges and run in parallel, so dropping legs saves no wall clock; and "this code already
passed on `main`" is true of each commit but not of the merged tree, which no run has seen.

Two things are outside the tree and stay human steps. Branch protection for release branches is
a repository setting — until it names the same required checks, a release-base pull request
REPORTS them and can still be merged red. A classic protection pattern uses the same `fnmatch`
rules as a workflow filter — `*` stops at a `/` — so `release/*` covers `release/stable` and not
`release/0.9/stable`; the protection-side spelling that matches what `ci.yml`'s `release/**`
matches is `release/**/*`. A branch named bare `release` matches neither list and gets no CI at
all, so release branches are named `release/<something>`. On the push side, the dependency audit,
CodeQL and the internal-path guard still run post-merge for `main` only.

**Where a stable Claude Code plugin tag goes.** A bare `plugin-claude-v<X.Y.Z>` is cut only after
its release pull request merges, on the commit that merge put on `main` (the merge commit, or the
squash or rebase commit when the pull request used one), never on the release branch before the
merge. The commit a stable version's npm provenance names is then one `main` holds, which is what
verifying a release against reviewed code needs; a tag cut before a squash or rebase merge would
name a commit `main` never holds. A pre-release is the exception above: it is cut on its release
branch. `release-plugin-claude.yml` publishes only on the tag push: its manual dispatch has no
publish input and is always a dry run, its npm publish sits behind the tag-to-manifest check, and
its GitHub Packages job waits for that check's job. `release-plugin-claude-gates.test.js` (in
`packages/eslint-config/test/`) pins those gates, and that the workflow reads no secret but its own
`GITHUB_TOKEN`. The release job also refuses a stable tag whose commit `main` does not contain,
before anything publishes; the same suite executes that check against a scratch repository. It is
an early catch for a mistake, not a control: whoever pushes a tag also chooses what that file says
at the tagged commit.

### Binary (SEA) channel — `bin-v*`

The `aka` CLI also ships as a self-contained **Standalone Executable Application (SEA)** — a native
binary that embeds the Node runtime, so end users need neither Node nor npm. This is a **separate
release channel** from the `cli-v*` npm publish:

- **Bare versions only — there is no pre-release lane.** A pushed tag that is not exactly
  `bin-v<X.Y.Z>` is REFUSED by a first `verify-tag` job the build fan-out `needs:`, so it costs
  one runner rather than four and reaches neither the Release nor the `bin-latest` move (which is
  additionally wrapped in a bare-version `case`). That refusal is the ONLY thing standing between
  a pre-release binary and every default install: both installers take the NEWEST `bin-v*`
  release and deliberately ignore GitHub's prerelease flag, and they are themselves fetched from
  `bin-latest`. A real lane would need an opt-in channel selector in both installers, a default
  ref narrowed to bare tags, and a second tag to serve the installers from — none of which
  exists. The guard EXECUTES the refusal block under bash, and separately pins that the block is
  reached at all: falsifying the step's own `if:`, `continue-on-error` on the job, and a
  job-level `if: always()` on the fan-out each left every executing case green.
- **Trigger:** push a tag `bin-v<version>`. It must equal `cli/package.json`'s version —
  `release-binaries.yml` gates on it and fails the release on a mismatch. `workflow_dispatch` runs a
  build-only dry run (no Release).
- **Build:** `release-binaries.yml` builds the SEA on native runners per platform (no cross-compile)
  — `darwin-arm64`, `linux-x64`, `linux-arm64`, `win32-x64` — via `build:sea` → `bundle:web-ui` →
  `package:sea` → `archive:sea`.
- **Assets:** `aka-<version>-<triple>.tar.gz` (`.zip` on Windows) plus an aggregated `SHA256SUMS`,
  and three files the release job RENDERS from that sums file with
  `tools/package-manifests/src/render-manifests.ts` (run under Node 24 type stripping, no pnpm
  install — the release job sets up Node 24 for it): `aka.rb` (the Homebrew formula), `aka.json`
  (the Scoop manifest) and `VERSION`. Both release steps set `fail_on_unmatched_files: true`, so
  a missing asset fails the run instead of publishing a partial release. The
  renderer takes `--version`, `--repo` (required, no default — a fork's dispatch run must not
  render `akasecurity` URLs), `--sums` and `--out`, renders everything in memory and writes
  nothing on any refusal. The formula and manifest point at the IMMUTABLE versioned asset URLs,
  never at `bin-latest`. The `tools/installer/` one-liners verify the download against
  `SHA256SUMS`, fail-closed.
- **`bin-latest`:** on success the workflow force-moves `bin-latest` to the release commit; the
  installer one-liners (`install.sh`/`install.ps1`) are fetched from `bin-latest`, not `main`.
  It then updates a ROLLING GitHub Release on that tag (`Publish the rolling bin-latest release`,
  gated on a push AND on the staging step having seen a bare `bin-vX.Y.Z`, so a dispatch dry run
  cannot touch it) carrying VERSION-LESS alias copies of the four archives
  (`aka-darwin-arm64.tar.gz`, `aka-linux-x64.tar.gz`, `aka-linux-arm64.tar.gz`,
  `aka-win32-x64.zip`), a `SHA256SUMS` naming those aliases, and the three rendered files. That
  is what makes `https://github.com/akasecurity/ai-tc/releases/download/bin-latest/<alias>` a
  stable one-click URL: GitHub's own `/releases/latest` is unusable here — every release is
  flagged pre-release, and six tag prefixes share one Releases list, so its election could land
  on a plugin release. The aliases are staged in `dist-latest/`, never in `dist/`, because the
  versioned release's `dist/aka-*` globs would otherwise sweep them in under names it never
  promised. The rolling release stays `prerelease: true` and `make_latest: false`.
- **`bin-latest` only moves FORWARD.** The workflow's concurrency group is per tag, so nothing
  else orders two releases: a re-run of an older tag's failed release, or two tags pushed out of
  order, would otherwise move it back. `Decide whether bin-latest moves forward` reads the
  version the tag names on origin and lets through only one at least as new — the SAME version
  included, because re-running is how a half-finished rolling upload is repaired — and the move
  pushes `--force-with-lease` against the value it read, so a release of another version that
  moved the tag in between fails the push instead of being overwritten. A read that fails for
  any reason but "no such tag" refuses rather than moving blind. There is no fixed concurrency
  group: GitHub cancels a pending run when a newer one joins, which drops a whole release, and
  the `queue: max` key that avoids that is rejected by the actionlint version CI pins. What the
  lease does not cover is two releases whose rolling UPLOADS overlap after both moved the tag in
  turn; the tag cannot go backward, the assets can end up mixed, and re-running the newer
  release's job makes them whole. A failure after the tag moved is named in the log by
  `Explain a bin-latest left half-published`. The CAS loser is the other half of the same race:
  by the time its lease is rejected, its OWN versioned Release has already published (the
  `Create GitHub Release` step runs before the move), so it ends with a real Release on a version
  that `bin-latest`, the rolling release and both package managers do not yet serve.
  `Explain a bin-latest move that lost a race` names that case in the log with no retry loop —
  the repair is re-running this tag's whole workflow, which re-reads the tag from scratch and
  only ever moves forward, so a newer winner makes the re-run a no-op.
- **Package managers.** Two jobs after `release`, `publish-homebrew-tap` and `publish-scoop-bucket`,
  push the rendered `Formula/aka.rb` to `akasecurity/homebrew-tap` and `bucket/aka.json` to
  `akasecurity/scoop-bucket` through the contents API, authenticating as a GitHub App rather than a
  person: each job mints its own token with `actions/create-github-app-token` (pinned by commit
  SHA), and the token is revoked when the job ends. The app is configured by one repository
  VARIABLE, `AKA_RELEASE_APP_CLIENT_ID` (it names the app and grants nothing), and one secret,
  `AKA_RELEASE_APP_PRIVATE_KEY`. The mint step is the only step handed the key, and the token it
  returns is handed to the push step alone, as `GH_TOKEN` in its `env:`; the job's gates read a
  boolean (`${{ vars.X != '' && secrets.Y != '' }}`) that tests only that the variable and the
  secret are set, because a job-level mapping of the key would put it in every step's environment,
  the artifact download included. The token's REACH is the mint step's inputs, and the guard pins
  that whole mapping: `owner` AND `repositories` are both named, because given `owner` alone the
  action scopes the token to EVERY repository the app is installed on; `permission-contents: write`
  is named, because a token otherwise inherits all of the installation's permissions; and
  `skip-token-revoke` stays unset. The mint step takes no `env:` and the job's own `env:` holds only
  that boolean, because the action also reads its `permission-*` inputs from `INPUT_PERMISSION-*`
  environment variables, which have no default; no `INPUT_` name appears anywhere in the file. Every
  action in the two publish jobs is pinned by commit SHA, and the two mint steps carry one revision,
  because every step in a job runs as the same user and an unpinned one runs before the key is
  handed out. The key is looked for in every expression in the file rather than in the two publish
  jobs, because the `release` job holds `id-token` and attestation write scopes and a step added
  there reads the same secret by the same name. The workflow is read as a PARSED YAML document
  (`yaml`, YAML 1.2 — a dev dependency of `@akasecurity/eslint-config`, in
  `test/helpers/workflow-yaml.js`), so every check runs on decoded structure: jobs, then steps, then
  each step's keys, `with:`, `env:` and `run:`. A key written quoted, in flow style, behind an
  escape (`"\x24{{ … }}"` spells the dollar of an expression) or through an alias reads the same as
  its plain spelling, and a line that only looks like a key inside another key's block scalar is not
  one. The expressions are read from the DECODED strings, keys and values, since a `#` line inside a
  `run: |` block is script text GitHub still substitutes and a YAML comment is not evaluated by
  anything; string literals are respected, since a `}}` inside one does not end the expression; the
  match is case-insensitive, `toJSON(` is refused outright because it serializes a whole context
  without naming a key, and no job may carry a `secrets:` key at any depth, because a call to a
  reusable workflow hands the key over with `secrets: inherit` without writing an expression. The
  top level and each publish job are built from a fixed set of keys, as are a publish job's steps,
  so a `shell:` template, a `defaults:` block, a `continue-on-error`, a `container:` or a
  workflow-level `env:` is a failure in whatever spelling it is written rather than something no
  case reads; a permission is `write` however its value is quoted; the download step's whole `with:`
  mapping is pinned, because `repository`, `run-id` and `github-token` would make it fetch another
  repository's run; and the fail-closed, notice and explanation scripts are RUN and their exit
  status and output read, since a pattern cannot tell `exit 0 # exit 1` from `exit 1`. The reader
  refuses what it cannot decode faithfully rather than guess: a parse error or warning, a duplicate
  key, more than one document, a `%YAML` directive, a `%TAG` directive that names a handle other
  than `!!`, a key that is not a string, an alias that points at a node it sits inside, a document
  that decodes to 20,000 nodes or more (the alias-chain backstop, `MAX_NODES` in
  `helpers/workflow-yaml.js`; the largest workflow here decodes to under 300, so raise it only if a
  real workflow approaches it), and a `<<` merge key — GitHub's parser follows YAML 1.2, where that
  is an ordinary key, so a reader that expanded it would report a mapping the runner may never
  build. Anchors and aliases are decoded. The version a pinned
  `uses:` names in its trailing `# v1.2.3` comment is a comment, which the reader keeps beside the
  value it trails. These are an early catch for a mistake, not a control — whoever can change the
  workflow can change its guard. The installation is the ceiling on all of this and nothing in this
  tree can observe it: the app must be installed only on the tap and the bucket, with Contents (read
  and write) as its only chosen repository permission, and Metadata: read is mandatory for every
  app. CONTRIBUTING.md's setup steps are what establish that. Both targets are HARDCODED rather than
  repository variables, so the READMEs can be held to the same spelling. Each job holds
  `contents: read` here and no write scope. On the canonical repository an unconfigured app FAILS
  the job with an `::error::` naming both halves — the Release and the `bin-latest` move have
  already happened, so the red run is the signal to configure the app and re-run, which a content
  compare makes a no-op. A mint that fails once the app is configured (not installed on the
  repository, a wrong client id, a key rotated incorrectly) is explained by a step gated on that
  step's own outcome, since the action's own error says nothing about what already shipped. A re-run
  executes the workflow as it was at the tagged commit, so configuring the app and re-running
  repairs a release whose tag already carries the app steps; one tagged before them is not repaired
  that way. A fork with no app configured skips. Each push only moves forward as well: the version
  already there is read off its versioned download URL, which both formats carry, and a file serving
  a newer one is left in place — so re-running an older release, or this job alone, cannot downgrade
  the tap or the bucket. A non-empty existing file that names no such URL at all — hand-edited, or
  written by something other than this renderer — has nothing in it to compare against, so the push
  `::warning::`s and still writes rather than either silently keeping it or silently overwriting it.
  And the write itself is wrapped: a PUT the contents API refuses (two releases racing the same
  file, say) ends the step with an `::error::` naming the repair — re-run this job, which re-reads
  the file and leaves a newer one alone — instead of the script's own success line. The formula
  installs the whole archive directory into `libexec` and symlinks `bin/aka` (Node resolves
  `process.execPath` through the symlink, which is how `boot.cjs` still finds its sidecars);
  `brew install ./aka.rb` from the attached copy is refused by current Homebrew, so the attached
  `aka.rb` is an audit copy and the tap is the only Homebrew door. `scoop install <url>` of the
  attached `aka.json` IS an install path. There is no darwin-x64 build, so the formula refuses Intel
  macOS (`depends_on arch: :arm64`).
- `cli-v*` (npm) and `bin-v*` (binary) are **independent** — a binary release needs no npm publish
  and vice versa, though they normally share one version line.
- **Signing state, stated exactly.** The macOS binary is AD-HOC signed (`codesign --sign -`), which
  is mandatory rather than decorative: injecting the blob invalidates Node's own signature, and an
  unsigned arm64 Mach-O is killed at exec. It is NOT Developer ID signed and NOT notarized. The
  Windows binary carries no VALID signature. `package-sea.mjs` strips `node.exe`'s own
  Authenticode signature before the inject, but best-effort: it ships unsigned when the strip
  lands, and carrying `node.exe`'s now-invalid signature — which Windows reports as tampered
  rather than as unsigned — on any of the three other outcomes the step reports: no SDK
  `signtool` found, the SDK lookup failing, or the strip itself failing. None of them fails the
  build, and the build log says which.
  The order is load-bearing on both platforms (remove signature → inject → sign on
  macOS; remove → inject on Windows) and is pinned over a COMMENT-STRIPPED read of the script,
  since commenting a call out kept a plain text match green.
- **Provenance.** Every asset the two releases publish — each archive, `SHA256SUMS`, the three
  rendered files, and the rolling release's alias set — carries a build-provenance attestation,
  made in the `release` job by `actions/attest` pinned to a commit SHA and gated on `push`, so
  the `workflow_dispatch` dry run attests nothing. The alias ARCHIVES are byte-identical renames,
  and `gh attestation verify` identifies an artifact by its digest, so they verify against the
  same attestation under their new names; the alias `SHA256SUMS` is new bytes and is attested in
  its own right. Only that job holds `id-token: write` and `attestations: write`; the build
  fan-out and the two publish jobs hold neither, and the workflow's top-level block grants no
  write scope for any of them to inherit.
  `packages/eslint-config/test/release-binaries-integrity.test.js` holds all of that, slicing the
  attest STEP rather than the job (the release-creation steps name the same globs, so a job-wide
  match is inert) and requiring the attest subjects to EQUAL the union of every softprops step's
  `files:` in both directions — which is why the aliases must be staged before the attest step
  and why a rolling asset can never ship unattested. **The installers do not verify the attestation and never require `gh`** — they
  verify the checksum and stop. That checksum arrives from the same release as the archive, so it
  catches a corrupt download and not a replaced release; `gh attestation verify <asset> -R
<owner>/<repo>` is the manual check that does. `install.sh`/`install.ps1` themselves come from
  the movable `bin-latest` tag and are covered by neither.

## Running locally

No server, no Docker, no database engine — just Node and the local SQLite store.

```bash
pnpm setup        # install dependencies + git hooks (pnpm install && lefthook install)
pnpm dev          # run the workspace dev tasks via Turbo

# Or exercise the CLI directly against your ~/.aka home:
pnpm --filter @akasecurity/cli dev -- init
pnpm --filter @akasecurity/cli dev -- dashboard   # launches the Next.js web-ui over ~/.aka/data
```

Everything AKA owns lives under `~/.aka` — `settings/settings.json` (preferences) and
`data/aka.db` (the SQLite store: events, findings, policies). To start over, remove `~/.aka`
and run `aka init` again. There is **no demo/sample data anywhere** (removed by product
decision) — dashboard pages render only real data; do not add ad-hoc seeding. The rich
sample datasets survive only as repository test fixtures in
`packages/persistence/src/test-fixtures/` — never shipped, because only a package's own
`test/` and `bench/` files import it and `src/index.ts` never re-exports it. That rule is
about DIRECTORIES rather than filename suffixes (`test/helpers/corpus.ts` is an importer
and is neither a `*.test.ts` nor a `*.bench.ts`), and
`packages/eslint-config/test/test-fixtures-imports.test.js` derives it from the tracked
tree — so a product importer fails CI rather than being caught in review. Two kinds live
there and they answer different questions: the
`seedSample*` datasets are FIXED rows shaped to exercise a read surface, while
`generate.ts` is a deterministic GENERATOR for the benchmark harness, because the store
sizes that matter there cannot live in git.

**Derive the importer set from import SPECIFIERS, never from filenames or a grep.** Both
mislead, in opposite directions. A grep for the directory name counts a file that merely
mentions it in a comment — `test/migrations.test.ts` does, and imports nothing from here,
which is how an earlier count of this set came out one too high. Filenames miss the other
way: a `*.bench.ts` reaching the fixtures through `test/helpers/corpus.ts` is a transitive
reader, not a direct one. Today no `*.bench.ts` imports the directory directly, and that
is a fact about the seam rather than a second rule — seeding needs the raw connection, and
`test-only-seam.test.js` classifies a `.bench.` file as shipped source. The two predicates
answer different questions and are deliberately not shared; do not collapse them into
"`bench/` may not import fixtures".

## Documentation

This repository is **public** (open source). Keep internal documentation out of it:
planning docs, decision records, roadmaps, and design docs are maintainer-internal and
do not belong in this tree. Only agent conventions (`CLAUDE.md`, `skills/`) and the
top-level contributor docs (`README.md`, `CONTRIBUTING.md`) belong here.

## Testing

```bash
pnpm test                                    # all workspaces
pnpm test --filter @akasecurity/detections   # just the detection engine + fixtures
pnpm test --filter @akasecurity/persistence  # just the local-store adapter + repositories
```

Never mock `node:sqlite` or the filesystem — every store test runs against a real
database in a real temp dir, which is what catches real SQLite semantics.

### Coverage: per-package floors, no global threshold

Every package's `vitest.config.ts` spreads one shared block —
`coverageOptions(import.meta.url)` from `test/vitest/coverage.ts` — which carries the
provider, the reporters, the exclude list and **that package's floor**. A package with no
entry in `COVERAGE_FLOORS` throws rather than defaulting to zero, because an unlisted
package is one nobody chose a floor for.

Four properties are load-bearing:

- **Coverage is always on, not behind `--coverage`.** `turbo.json` declares
  `outputs: ["coverage/**"]` for the `test` task, and an output a task produces only
  sometimes is worse than one it never produces: turbo restores outputs on a cache hit, so
  a hit taken from a run without the flag hands back a report belonging to another commit.
  Unconditional is what makes that declaration true — and what makes a floor bind locally
  rather than only on the machine that remembered the flag.
- **There is NO global or aggregate threshold, and adding one is a regression.** It would
  be satisfied by covering `dashboard-ui`'s SVG icon sheet while a mutating Server Action
  stayed at zero — the number rises, the risk is untouched. Floors are per package and
  cannot be paid off that way. `coverage-config.test.js` pins the floors table as an
  **exact** set against the packages that run tests, so a global threshold has nowhere to
  live in it.
- **`test/vitest/**` is in `globalDependencies`.** It holds the floors, which are what
  `test` is allowed to pass with. Outside the hash, lowering one would move no package's
  hash and every package would replay a cached green taken under the stricter floor.
- **Floors are MEASURED, never estimated.** Each is one point below what its suite actually
  reported, with the measurement in a comment beside it. The estimates this replaced were
  wrong in both directions — `scanner` read ~75% and measures 96.8%, while four packages
  measured _below_ the floor their estimate implied, which would have failed CI on the
  first run. Numbers were taken on macOS/Node 24; platform-gated code (the macOS keychain
  backend most clearly) can read lower on another leg, and the fix there is to re-take that
  package's number on the platform that reports lowest — not to widen every floor.

The second half is **diff coverage**: of the lines a PR adds, how many does the suite
execute? A global percentage on a repo this size moves by fractions on any one PR, so it
can neither pass nor fail one honestly. `tools/coverage-gate` reads the reports
`turbo run test` already wrote, intersects them with the diff against the merge base, and
reports to the PR's step summary. It is a workspace tool rather than a third-party
uploader, so no coverage data leaves the runner.

```bash
pnpm turbo run test          # writes every package's coverage/ report
pnpm check:diff-coverage     # then measures the diff against origin/main
```

### Benchmarks are a trend, never a gate

```bash
pnpm bench                                    # every package that has benchmarks
pnpm bench --filter @akasecurity/detections   # just one
```

`vitest bench` (no second framework), one `bench/*.bench.ts` per package, a `bench` turbo
task, and a nightly `bench.yml` on `main` that uploads each package's
`bench-results.json` as a workflow artifact. **Nothing gates a PR on wall-clock.** Hosted
runners vary by a large factor on neighbour load alone — this repository has retuned three
timing assertions for exactly that — so a wall-clock check on a PR fails for reasons
unrelated to the diff, and a check that cries wolf is one people learn to re-run until it
is green.

What DOES gate a PR is the timing **guards**, which are correctness assertions rather than
measurements: the adversarial-rule bound in `packages/detections/test/security/redos.test.ts`
and the isolation ceilings in `packages/plugin-sdk`. Keep the two apart — a benchmark that
threw would be a timing gate wearing a different name.

Six properties are load-bearing, and each is enforced by
`packages/eslint-config/test/bench-harness.test.js` rather than remembered:

- **`cache: false` on the `bench` turbo task.** Every other task derives its output from
  its inputs, so replaying one is sound; a benchmark returns a MEASUREMENT, and the machine
  it ran on is in none of the hashes. A cache hit hands the trend another runner's number
  and labels it this run's — the one stale green that cannot be spotted by reading it.
- **A package holding benchmarks declares a `bench` script.** `turbo run bench` skips a
  package without one silently and exits 0, so the nightly job goes on reporting success
  having measured nothing.
- **`bench/` is a lint target and a tsconfig `include` like any other source directory.** A
  bench file imports product code; outside those, its types are stripped unchecked and the
  network bans never apply to it.
- **`bench.yml` has no `pull_request` trigger**, still runs nightly, and still uploads.
- **`bench.yml` actually invokes the task.** An upload step is not a measurement: a
  workflow that schedules, checks out, installs and uploads — but never runs `pnpm bench` —
  satisfies every other property here, finds no files, and WARNS rather than failing,
  because `if-no-files-found: warn` is part of what keeps the job advisory. The Actions UI
  shows a green nightly run either way while the trend silently stops gaining points.
- **A claim about how the benchmarks run names the unattended run, wherever it is made.**
  The turbo comment is not the only place the repo explains this — a perf suite saying why
  its durations live in `bench/` rather than in an assertion makes the same claim about the
  same sibling file, with nothing between them. That is not hypothetical: this walk suite's
  header called the benchmarks "run by hand" in a comment written four hours AFTER
  `bench.yml` landed, and stayed green through the merge and every run since. So the sites
  are DERIVED rather than listed — any prose block in the tracked tree that talks about
  benchmarks and says a human is the only thing that runs them — because a two-entry list
  would read as exhaustive the day a third site was written. A specimen of the bad phrasing,
  quoted anywhere, is a real match rather than a false one; describe the shape instead, or
  name the workflow in the same block.

A benchmark carries no assertions, so anything a bench file would otherwise have checked
about its own input belongs in a test instead. `generateCaptureCorpus` is the worked
example: it writes through the product's own `recordCapture` (so the corpus cannot drift
from what the product writes), wraps the whole corpus in one transaction, and — because
`recordCapture` is fail-open — **counts the rows that actually landed and throws when they
are not there**. Without that last check a locked or full store hands back an empty one and
every downstream measurement is a timing of nothing, reported as a fast one.

### Store scale: what is bounded, and what grows for ever

`packages/persistence/test/performance/` holds the scale guards, and they are **tests
rather than benchmarks** because each asserts a size, a query plan or a row set — none
of them a wall clock. A benchmark reports a trend and gates nothing; these fail a PR.

**No gate here asserts an elapsed time against a budget, and one used to.** The two
per-call store costs are gated as a **ratio of the same cost at two store sizes**
(`scale-budgets.test.ts`, 2k against 20k), because a ratio cancels the runner: half the
machine halves both sides and moves the quotient not at all. The absolute form was tried
first — a p95 against a budget ~165x the median, which reads like unmissable headroom —
and it reddened a healthy tree twice, at 43 ms and 277 ms against a 30 ms budget on one
commit. A shared runner does not get 1/165th as fast, it gets **preempted**, and a
preempted sample has no upper bound; no headroom multiple fixes that. Anything newly
added here follows the ratio shape, and **`p95` is not the estimator to build one from** —
measured across idle and 3x-oversubscribed runs, a p95 ratio spread 4.6x on capture and
17x on open, where the same ratio over the **fastest** sample spread 1.08x and 1.06x.
Noise only ever adds time, so the minimum of n is the estimator a loaded runner cannot
inflate.

One absolute bound survives, and only as a **gross-regression backstop** four orders of
magnitude clear of the measurement: a ratio is blind to a constant-factor regression
(0.06 ms → 500 ms at every size keeps a ratio of 1.0), just as the backstop is blind to a
scaling one. They catch different defects; neither substitutes for the other.

The numbers, measured on arm64 macOS / Node 24 against corpora from
`src/test-fixtures/generate.ts`. The two ratio rows are ranges over six interleaved runs of
the suite itself (below), three on a machine carrying a video call and an antivirus scan and
three with 24 CPU burners added on its 8 cores:

| Property                                    | Measured                             | Gate                   |
| ------------------------------------------- | ------------------------------------ | ---------------------- |
| Store growth, 5k → 10k                      | **1,066.6 B/event** marginal         | ±15% band ✅           |
| `recordCapture` 2k → 20k                    | ratio **1.02–1.08** (fastest of 200) | ratio < 3 ✅           |
| `openLocalDatabase` 2k → 20k                | ratio **0.72–1.03** (fastest of 20)  | ratio < 3 ✅           |
| `recordCapture` at 1M rows                  | 0.076 ms median, 0.116 p95 (n=200)   | backstop ≤ 1,000 ms ✅ |
| `openLocalDatabase` at 1M rows              | 0.55 ms median, 0.72 p95 (n=20)      | backstop ≤ 1,000 ms ✅ |
| `/security` (8 aggregations) at 50k events  | **159 ms** (was 11,197)              | 3 flatness ratios ✅   |
| `/security` (8 aggregations) at 150k events | **350 ms** (was 125,987)             | 3 flatness ratios ✅   |
| `/security` (8 aggregations) at 300k events | **729 ms**                           | 3 flatness ratios ✅   |
| `/security` at 1M events                    | **~2.5–3 s** extrapolated            | ungated, unmeasured ❌ |

**Both pairs came down from a decade higher, and the reason is worth carrying.** They
were 5k → 50k and 10k → 20k, and at those sizes the two files were the largest single
pieces of work `@akasecurity/persistence` does — `scale-budgets` seeded for 117 s on a
macOS CI run that passed and 135 s on one that did not, against a 120 s hook ceiling. A
3% margin is not headroom, and it landed both ways inside one afternoon. The property in
each case is a RATIO or a SLOPE, and neither needs a particular absolute size, so the
corpus came down rather than the ceiling going up. The prices are stated where they are
paid: the ratio's sensitivity floor moved by 2.5x (below), and the growth band's centre
had to be retaken, because the marginal creeps with size — 902.8 B/event across 2.5k→5k,
902.8 across 5k→10k, 923.2 across 10k→20k, all measured, all byte-identical run to run.
**Do not carry a centre across a size change**; a stale one still reads green.

**Nor across a SCHEMA change**, which is what moved it last: migration 0029's
`idx_audit_capture_rollup` is bytes per row like any other index, and the 5k→10k marginal
went 902.8 → 1,048.6 — past the old ceiling of 1,038.2, which is how it announced itself.
That is the index being paid for rather than a regression, and the figures above describe
the corpus before it. Migration 0037 moved it again, 1,048.6 → 1,066.6 (+1.7%): three nullable
`inspection_findings` columns and a partial index, paid for even by findings that leave all
three empty. Inside the band, so nothing failed — which is exactly why it was re-taken anyway.

**Nor across a CORPUS change, which is the harder one to remember because the test's own
size did not move.** That centre was 797.9 until the generator's finding rate went from
0.1 to a measured 0.33: three times the finding rows per event took the marginal to 902.8,
which the old band's 917.6 ceiling **passed, by 1.6%**. So a 13% growth regression went
undetected and the band was left one unrelated commit away from failing for no reason — at
which point widening it is the obvious-looking fix. Re-take the centre whenever anything
about the corpus moves, not only its event count.

**A ratio gate has a sensitivity floor, and it is worth knowing before trusting one.**
The quotient is `(base + 10k) / (base + k)`, so clearing a ceiling of 3 needs the
size-dependent term to reach 2/7 of the baseline — ~15 us against `recordCapture`'s ~53 us
(measured 53.4 us at 2k and 53.0 us at 5k, i.e. flat in the corpus size),
i.e. a per-row slope of ~7.6 ns. Adding a `SELECT COUNT(*)` to that path is genuinely
linear and does **not** redden it: SQLite answers the count from a covering index. The
same scan with the index defeated (`WHERE LENGTH(id) = 999`, ~40 ns/row) reads 4.06–4.18
and fails. So a ratio gate catches a linear cost that changes what the operation costs, not
one inside its noise floor — and the floor is proportional to the SMALL size, so cutting
the pair by 2.5x raised it by 2.5x.

**Three of `/security`'s eight reads are BOUNDED, and a plan test cannot tell you that.**
`hot-read-query-plans.test.ts` drives every read the `/security`, `/activity` and
`/vault` pages issue and confirms each one runs indexed. That capture runs at 3k and
nothing re-captures the plans above it: the store carries no `ANALYZE` statistics, so
SQLite plans from the schema rather than from row counts and the plans are EXPECTED to
hold at 1M — reasoning, not a second measurement, and worth wording that way.

An indexed plan is not a bound, in either direction, and this page is where both halves of
that were learned. `mttrTrend` ran every step of its plan as a SEARCH while driving from
`audit_events` and joining every capture event before its window predicate could reject a
row. `recentFindings` now deliberately SCANS `idx_audit_started_at` so its `LIMIT` can stop
early, and EXPLAIN QUERY PLAN has no text for "terminates after 500 rows". So the plans pin
the mechanism and `security-page-scale.test.ts` pins the consequence, as a ratio across a
10x store step with `severitySummary` as the growth control. Neither implies the other.

`recentFindings`, `recentlyResolved` and `mttrTrend` are the three now flat in store size —
ratios **1.05–1.18**, **1.22–1.29** and **1.26–1.32** over 2k → 20k across the same six
interleaved runs as the table above, against a ceiling of 3 and a control reading
11.87–12.86. What each needed differs, and none of it was tuning:

- **`recentlyResolved` was QUADRATIC**, O(code_change events x resolved keys), because the
  join key was unreachable: `f` was reached FROM `latest`, so `latest` got probed on
  (rn, status, method) and the plan enumerated every pair before `f` could reject it. 10,966
  ms at 50k events and 125,322 ms at 150k — 3x the store for 11.4x the cost — returning 20
  rows. It read 8 ms to everyone who measured it before, because an EMPTY
  `finding_resolution` collapses the cross product to nothing.
- **`mttrTrend`'s `EXISTS` bounded the RESULT, not the scan.** Fixed by driving from
  `finding_resolution` over a `resolved_at` range. Migration **0021**'s index on that column
  is NOT what buys the flatness — remove it and the ratio does not move, because the
  latest-resolution derived table already passes over the whole table, so the read is
  O(resolutions) either way and resolutions are not the store. What it buys is the criterion
  the plan test hard-fails on: without it the driving step is a bare `SCAN fr`. Two guards,
  two different defects, and neither catches the other's.
- **`recentFindings` could not push its LIMIT down**, since the sort key sat on the joined
  table. Fixed with `+e.event_type` (making `idx_audit_type_t` unattractive, so
  `idx_audit_started_at` yields `started_at` order directly) plus `CROSS JOIN`.

**`CROSS JOIN` is load-bearing in all three and must not be tidied into `JOIN`.** In SQLite
it is semantically identical and exists only to stop the tables being reordered; with no
`ANALYZE` statistics the planner prices `event_type IN (...)` as a selective probe and puts
`audit_events` back on the outside. On `recentFindings` the unary plus ALONE recovers almost
none of the win — 23.6 ms against 0.9 ms with both, from 35.0 ms.

The page's `Promise.all` still buys nothing: every repository method here runs its SQL
**synchronously** and returns an already-resolved promise, so the page costs the SUM of the
eight.

**The dominant cost on a REAL store was none of the above — it was the ROW FETCH**, and the
analysis above could not see it because a generated corpus does not reproduce it. `content`
is declared before `attributes` in the record and holds whole file bodies on a
`code_change` (42 KB average, 1.79 MB at the top end), so on a field store ~91% of
`audit_events`' bytes are overflow pages, and reaching either `e.id` — this is a rowid
table with a TEXT primary key, so a secondary index entry carries the rowid and never
`id` — or any VIRTUAL column over the bag means walking that chain past the body, once per
row. Measured on one 6 GB store: the same 53,383 rows cost 14 ms read through a covering
index over two VIRTUAL columns and 5,020 ms read from the rows. Migration 0029's
`idx_audit_capture_rollup` carries `(event_type, started_at, repo, id)` for the four
capture kinds so the four broad rollups answer from the index, and the four reads carry
`INDEXED BY`; the page went ~15 s to ~2 s warm, with `topSources` — which needs `repo` for
every capture event in its window — going 8,278 ms to 72 ms WARM, and 8,922 ms to 74 ms
COLD. The pairs are like-for-like on purpose: a before-figure and an after-figure measured
in different cache states describe no speedup at all, and `security-probe-plans.test.ts`
quotes the warm pair against this same warm page total. Two traps come with it.
`EXPLAIN QUERY PLAN` will NOT say COVERING for a read naming `repo`, because SQLite counts
a generated column's dependency on `attributes` as a reference to the row even while
reading the value from the index, so the timing is the evidence and the label is not —
`security-probe-plans.test.ts` pins the index by name and requires COVERING only of the
reads that can reach it. And `LENGTH()` on a TEXT value **stops at the first NUL**, which
real bodies carry (134,576 rows on that store), so any size query over `content` must be
`LENGTH(CAST(content AS BLOB))` — the text form under-reported a 1.79 MB body as 9.

What the index does NOT fix is `findingsInRange`, which feeds `findingsTimeseries` and
`enforcementActions` and so runs twice per render: its cost is ~740k index probes across
369k findings, and folding the JS rollup into SQL was measured at 1.0x — no gain. That one
needs a rollup or a window, not tuning.

**What remains is `severitySummary`, the budget is still missed at 1M, and closing it is a
product decision.** Measured at three points — 159 ms at 50k, 350 ms at 150k, 729 ms at 300k
— and the page is mildly SUPERLINEAR rather than linear, which is the part a two-point
reading gets wrong. `severitySummary` carries that: 46.6 → 177.7 → 472.2 ms, i.e. 0.93 →
1.19 → 1.57 us per event, a cost per event rising ~1.33x per doubling (an all-time `GROUP BY`
whose temp B-tree and working set both grow with it). Everything else is flat or
window-bounded, so it goes from 29% of the page at 50k to 65% at 300k.

Carrying that factor from 300k to 1M puts `severitySummary` near **2.6 s** and the page at
**~2.5–3 s** — still OVER the 2,000 ms budget, with that one read accounting for ~85% of it.
Read the RANGE as the finding, not either endpoint: it is an extrapolation over 1.7 doublings
from three points whose slope is itself moving.

**Nothing here measures 1M, and the reason is the corpus rather than the reads.** Seeding is
badly superlinear — 142 s at 150k against 733 s at 300k, **5.2x for 2x the events** — so a 1M
corpus is tens of minutes and belongs in neither a test nor a session. Do not size a change
against a 1M figure from this section without taking it.

So the engineering half is done and the arithmetic says it is not sufficient. Bounding
`severitySummary` means a maintained rollup (exact, but invalidated from the resolution path
as well as the capture path, plus a backfill for existing stores), windowing the card (cheap,
changes what the number means from "ever" to "the last 30 days"), or retention on the corpus
itself. All three are product calls, not tuning.

**A VIRTUAL generated column is only faster than the bag when an index stores its value.**
The token rollup (`tokenReports` / `tokenReportForSession`) reads the `llm_call` usage
columns — model, provider, the four token counts and the tier, cache-split and web-search
members 0026 added — grouped in SQL per `(session, provider, model, service_tier)`, per
long-context band and per set of billed columns, and priced once per group, which the cost
model's linearity makes exact (activity.test.ts holds the SQL fold equal to the per-call fold
across every bag shape). The band is in the key because one request's prompt selects it, never
a sum; the billed columns are, because a summed group billing a column its model has no rate
for prices to null as a whole, and without them one such call would null the priced calls
beside it. That last part costs the all-time read ~9 ms at 50k calls (34 → 43 ms, fastest of
the run), still under folding the bags in JS. Naming those columns against the
TABLE is slower than parsing the bag in JS (137 ms against 98 ms at 50k calls), because each
VIRTUAL column is a `json_extract` recomputed per row; the read is fast only through
`idx_audit_llm_usage` (0027), a partial index over the columns that stores the values once,
at write (8.5 ms for a seven-day window against 31 ms, 38 ms all-time). Two things to know
before touching it. `INDEXED BY` is deliberate — with or without `ANALYZE` statistics the
planner prefers the event-type index and refetches every row — and the plan will never say
COVERING for an index over VIRTUAL columns, because SQLite counts the column's dependency on
`attributes` as a reference to the row even as it reads the value from the index; the
timing, not the label, is the evidence, and `token-rollup-plans.test.ts` pins the index by
name. The per-session form of the old fold walked EVERY `llm_call` in the store through the
event-type index to find one session's (17 ms at 50k, growing with the store); it now seeks
`idx_audit_session_type`.
**`/activity` had the same shape of defect, and the same pair of guards now holds it.**
`liveNow` walked every descendant of every open root to find the latest one, and on a real
store every root is open — the local writer never stamps `ended_at` on a session — so
"sessions live in the last thirty minutes" read the whole table on every page load: 90 ms at
200k captures, growing with history. It is driven from the rows active in the window now, as
three index ranges and one primary-key seek per root they name, with three `INDEXED BY`s
the planner takes none of on its own: whenever the range column is the second column of a
`(root_session_id, …)` index it prefers a skip-scan over every root through that index, and a
skip-scan over every root is the walk again (2.8 ms against 0.1 ms); and left to itself the
outer query enumerates every session root through an event-type-led index to test each
against the list — linear in roots, and invisible inside the `stats` composite because the
four day-windowed counters shrink faster than it grows (4.8x across a ten-fold store, 1.2x
pinned to the primary key, whose implicit index name the statement therefore depends on). The list's per-session rollups read partial indexes over their own kind
(migration 0028), and the last-activity rollup is two seeks per session through `json_each`.
`activity-page-scale.test.ts` pins the consequence on the real shape (`endedRate: 0`, 2k →
20k captures: before, `stats` 9.4, the first page 4.5 and the detail pane 3.5 across a
ten-fold store; after, every page-load read under 1), and `activity-probe-plans.test.ts` pins
each read to the index it must be seen using, which the plan test cannot — a per-root walk is
a SEARCH there. What still grows is the search: `q` matches `content` and a `json_extract` of
`detail` over every descendant in its window, twice (the page and the empty-session count); a
key-presence `LIKE` guard on that parse measured 48 → 43 ms per statement and was not taken.

**A corpus must never `ANALYZE`, and every kind-scoped read is pinned because of it.** The
shipped store never runs `ANALYZE`, so SQLite plans from the schema alone, and from the schema
alone `root_session_id IN (…) AND event_type = 'prompt'` takes an event-type-led index — every
prompt in the store, then a sort — over the per-root partial built for it; the same for the
detail pane's token sum, tool grouping and model list of ONE session (`~7 ms` of a
never-analyzed 20k store each, growing with it). The activity corpus first ran `ANALYZE` after
seeding, and with statistics all of those reads took the right index unpinned, so both guards
were green over plans no field store gets (review on the PR that added them; the ratios above
are from the never-analyzed corpus). Every `root_session_id … AND event_type = '…'` read on the
page now carries `INDEXED BY` its root-led index, `activity-probe-plans.test.ts` refuses the
two event-type-led indexes (`idx_audit_type_t`, `idx_audit_events_sync`) by name, and the
corpus helper says why it does not analyze. Two things to know when reproducing: a dropped
`sqlite_stat1` stays loaded on the connection that dropped it — measure from a fresh handle or
seed without ever analyzing — and a single-id `IN (?)` may plan differently from a hundred-id
one, so probe the page's real page size.

**Two tables have a ROW retention policy; six do not.**
`BLOCKED_DETECTIONS_RETENTION_MS` (24 h) sweeps `blocked_detections`, and
`EXCEPTION_RETENTION_MS` (90 days) sweeps terminal `exceptions`. `audit_events`,
`inspection_findings`, `inspection_definitions` and the three `secret_vault*` tables have
none — and `audit_events.content` is a full prompt corpus. The vault tables raise
a second concern beyond size: an entry nobody will reveal again is a ciphertext that
stays decryptable for as long as its key epoch survives. `retention-surface.test.ts` pins
the split behaviourally, with a positive control on the swept pair, so adding retention
for one of the unbounded tables is a deliberate edit rather than a silent one.

**A THIRD sweep expires BODIES, and it is on neither list because the lists are about
ROWS.** Local body expiry (`bodyRetention` in settings, OFF by default, 30 days when
switched on) clears `audit_events.content` past a horizon and stamps `content_expired_at`;
the row, its timestamps and severity, and every finding derived from it are kept, so
`audit_events` stays among the tables nothing sweeps. The one thing a finding loses is its
masked excerpt (`inspection_findings.context`, the lines the dashboard drawer shows): it is a
copy of the body's lines, so it goes with the body, and a `tool_call` finding's excerpt —
which has no body to follow — goes once its event is past the same horizon. Its `line` and
`col` stay. An excerpt beside a body the sync lane holds stays with that body. That is where the bytes are — one
measured 6 GB store carried 5.27 GB of body text, 4.85 GB of it `code_change` at 42 KB a
row — so this is the difference between a store that settles and one that grows at
~165 MB/day for ever. Three things about it are load-bearing. **The sync lane is gated**:
a `prompt`/`response`/`tool_use` body with `synced_at IS NULL` expires only when nothing
could still make it owed. `canSweepSyncLane` is the yes-or-no (a standalone machine with no
half attachment and no history-sync grant lets the lane age out like any other body), and
`syncLaneRetentionFor` in plugin-runtime refines it through `syncLaneRetentionOf` once an
attachment can be scoped. It is the one helper the background pass and `aka prune` both
call, each handing it the settings it read, so the same settings and credential give the same answer. A
machine attachment, half an attachment, and a history-sync grant with no scoped attachment
hold every unsynced body, and so does every failure: an unreadable credential, a scope
record that is absent, for another deployment or partly invalid, or a throw. A scoped
attachment holds the bodies of its enrolled repositories and any body still marked owed (an
earlier machine attachment's undelivered forward leaves that mark, and enrolling its
repository later makes it reachable), and lets every other body age out, since nothing can
forward those from a scoped machine. **It deletes no row**, and `retention-surface.test.ts`
now runs it in the same pass as the other two so that membership is a live assertion
rather than a claim. **It frees no disk by itself**: SQLite returns the pages to its
freelist, so new captures reuse them and the file stops growing, but the file does not
shrink without a `VACUUM` — and an in-place one on a live store is unsafe here, because a
held handle blocks the rename on Windows and loses writes to the unlinked inode on POSIX.
`aka prune` runs a pass by hand; `triggerContentRetention` runs one hourly from
SessionStart, in a detached child, never on the hook thread.

**The horizon is configurable, and its RANGE has one definition.** `bodyRetention`
(`{ enabled, retainDays }`, default `{ false, 30 }`) is editable under Settings → Storage
and pinnable by an administrator as ONE unit, the way `runMode` + `controlPlane` are — a
lock that froze the toggle while leaving the day count editable would not be a retention
policy. `BodyRetention`'s own `min(1).max(3650)` is the only place a legal window is
defined: `SaveSettingsInput` checks the SHAPE and the action checks the RANGE against that
schema, so the two cannot drift. An out-of-range horizon is **refused, never clamped** —
silently rounding one expires a different set of bodies than the user asked for, and
expiry is not undoable. The Settings control holds the field as raw TEXT and disables Save
while it does not parse, because parsing per keystroke cannot represent a half-typed or
emptied field without substituting a horizon nobody chose.

**`wal_autocheckpoint` is not set, and that does NOT mean the WAL is unbounded.**
`openWithPragmas` leaves it alone, so SQLite's own default of 1000 pages applies: at the
store's 4 KiB page size the log settles at about 4.2 MB and stays there over 20,000
committed captures. The unbounded case is a long TRANSACTION — a checkpoint cannot run
inside one — where the same writes grow the log nearly four times as far (both figures sit
beside `MEASURED_SETTLED_WAL_BYTES` in `store-growth.test.ts`, and are not restated here),
and the fixture generator's 1M-event transaction grows the log by its whole page footprint,
which is hundreds of megabytes. Nothing on the capture path does that (every
`recordCapture` commits, and every hook is its own process), but a batch importer would.
Do not "fix" the pragma without re-reading `store-growth.test.ts`.

**That same property makes a two-size RATIO test compare two different states, and it has
cost a red `main`.** `seedCaptureCorpus` commits its whole corpus in one transaction, so
the log it leaves is the seed's entire page footprint rather than the steady state above —
measured 4,132,392 B for 2,000 events against 17,637,752 B for 20,000, a factor of 4.27
that belongs to the FIXTURE and to nothing the product does. Every measurement taken
against those stores then carries a log-proportional cost on one side only. It reads as
nothing while the pages are cached, which is why `scale-budgets.test.ts` reported a
`openLocalDatabase` ratio of ~1.0 locally and on CI for months; on a runner executing the
whole workspace suite in parallel those pages are not resident, and the same asymmetry
arrived as a ratio of **3.619** (2.1464 ms against 7.7686 ms) against a ceiling of 3 — on a
commit whose diff touched `CLAUDE.md`, a shell script and one unrelated test file, with no
product code in it at all.

So a file that seeds two sizes and divides one measurement by the other **checkpoints after
seeding** (`PRAGMA wal_checkpoint(TRUNCATE)` through `corpusConnection`), which leaves both
stores at the steady state — measured 4,148,872 and 4,144,752 B, i.e. equal — so the cost
cancels in the ratio the way every other shared cost does. All four two-size suites here —
`scale-budgets`, `security-page-scale`, `findings-page-scale` and `activity-page-scale` —
do this. It equalizes the LOG and not the DATABASE (2.2
MB against 17.0 MB, still 7.7x apart), so a size-dependent cost in the thing under test
survives it: verified by mutation, an index-defeated per-open scan fails at 4.14 with the
checkpoint and 4.08 without. `store-growth.test.ts` is the deliberate exception — the
unbounded log is what it measures, so it must NOT checkpoint.

The failure this guards is quiet in the wrong direction: it reddens a tree whose diff
cannot explain it, and the obvious-looking fix is to widen `FLATNESS_CEILING`. Widening it
answers a state mismatch by weakening the one number that separates flat from linear.

**The same file also samples the two sizes INTERLEAVED, and that is the other half of the
same contract.** A checkpoint puts both stores in one STATE; it says nothing about WHEN
each is timed. The four suites used to time each store straight after seeding it, which
put the large store's samples several seconds of CI seeding after the small store's — and
a fastest-of-n survives a slowdown that reaches SOME of a side's samples, never one that
lasts through all of them on one side only. That is how `activity-page-scale.test.ts`
reddened a Linux CI leg at a ratio of **3.035** (1.974 ms against 5.989 ms) on a change
whose diff could not reach this package, while the same run's two other full-suite legs
passed it. So both stores are seeded first, and every probe is then timed against the two
in alternation through `test/helpers/interleaved-samples.ts`: one probe per block, the
leading store alternating per iteration and per block, and the microtask queue drained
between blocks. Its own suite pins each of those without a clock. **Do not hand-roll the
loop in a new two-size suite** — the four copies it replaced had each got block entry
wrong in the same way, and a copy's defects are visible to no assertion but its own.

**That WAL case runs its fixture connection at `synchronous = OFF`, which is NOT the
product's configuration — and the quantity it asserts does not depend on the difference.**
Demonstrating the bound needs 20,000 SEPARATE commits — a checkpoint cannot run inside a
transaction, so batching them removes the property under test — and `openWithPragmas`
leaves `synchronous` at node:sqlite's compiled `SQLITE_DEFAULT_WAL_SYNCHRONOUS=2`, where
every one of those commits fsyncs the log. That made the setup cost the runner's fsync
latency times twenty thousand, and it overran its 180 s hook ceiling on a commit whose diff
could not reach it. At `OFF` the loop makes no fsync at all; `NORMAL` is not enough, because
it still syncs at every checkpoint. Durability moves the cost and not the log — the
autocheckpoint counts frames, and a frame is the same bytes synced or not — so both
measurements the ceiling sits between read identically under `FULL` and `OFF`. Those
measurements live beside `MEASURED_SETTLED_WAL_BYTES`, and the fsync counts and the
injected-latency reproduction in the fixture's own comment, and are deliberately not
restated here. The case reads back every pragma the result depends on — `synchronous`,
which SQLite silently resolves to `NORMAL` when handed a value it does not recognise, and
the `wal_autocheckpoint` and `page_size` the peak is a product of — so each is held by
assertion rather than by comment. A slow setup there is never answered by lowering the
autocheckpoint, cutting the event count or raising the ceiling.

It is still **skipped on Windows, on cost rather than on behaviour.** It overran its own
180 s setup ceiling on that leg while every commit was an fsync, and starved neighbouring
suites on the shared leg while doing it; what the loop costs there without those fsyncs has
not been measured, so the skip stands until it is. What it asserts is SQLite's page
arithmetic, which does not vary by filesystem, so the other two legs cover it. Lowering the
event count instead is the worse trade: the count is what puts a log that never
checkpointed several times over the ceiling, so cutting it weakens the assertion on every
platform to buy coverage on one.

**`packages/persistence/bench/` is a separate tier, and it gates nothing.** `pnpm bench`
(turbo task `bench`, `vitest bench`) reports a TREND — the trajectory the table above was
taken from — and carries no assertions, because this repo does not gate a PR on
wall-clock. Its turbo task sets `cache: false`: every other task returns the same answer
for the same inputs, but a benchmark returns a measurement, and the machine it ran on is
not in the hash, so a cached hit would report another runner's number as this run's.
The nightly `.github/workflows/bench.yml` runs it unattended and uploads each package's
`bench-results.json`; the table above was taken by hand and is re-taken the same way.
Never wire it to a PR gate. Anything that must HOLD belongs in `test/performance/`
instead — restated as a **ratio** against a second store size, not carried over as the
elapsed number the bench prints, which is the one form that cannot survive a shared
runner.

Corpus scale is what decides where a scale test can live. Seeding is not flat per event,
so a six-figure corpus belongs in a `beforeAll`, where it is charged to `hookTimeout`
rather than eating a test's own budget before the first assertion. A synchronous body
cannot be interrupted, so one that overruns runs to completion and is then reported as a
timeout, which reads as a budget failure and is not one. Cut the corpus rather than
raising the ceiling.

**Take the rate yourself before sizing anything against it, and take the CI-to-local
FACTOR too — that factor, not the local rate, is what has cost a red main.** Measured as
the fastest of three seeds into a fresh store through `seedCaptureCorpus`, one warm-up
discarded, on arm64 macOS 26.5.2 / Node 24.18.0 with nothing else running: **0.0434
ms/event at 2k, 0.0427 at 3k, 0.0448 at 5k, 0.0558 at 20k, 0.0732 at 50k** (87 ms, 128 ms,
224 ms, 1.12 s and 3.66 s for the corpus). Seeding is not flat per event — it creeps ~1.7x
across that range — but there is **no step in it**, and no page-cache cliff: the rate runs
flat straight through 2,400 events (0.0434 / 0.0434 / 0.0427 at 2k / 2.4k / 3k).

What the 120 s ceiling in `scale-budgets.test.ts` was really missing is the runner
multiple. Seeding 5k + 50k costs 3.88 s locally, which is what that pair was sized
against and is accurate; the same hook took **116,970 ms on a macOS CI run that passed
and 135,237 ms on one that did not**, against a 120,000 ms ceiling — a factor of **~30x**,
not "several times". Size against the factor: 2k + 20k is 1.20 s locally, so ~36 s there.

Older figures in this section (a 0.091 ms/event rate at 3k, a cliff at ~2,400 events, and
the 100k and 1M rates of 0.096 and 0.639 ms/event) ran ~5-6x high and are retracted or
untaken. Re-measure rather than budget from them.

### The no-network guard

`test/setup/no-network.ts` is loaded by **every** package as a vitest `setupFiles`
entry. It refuses any outbound connection that is not loopback — TCP (and therefore
TLS, HTTP, HTTP/2, `fetch`), UDP, and the DNS `resolve*` family — and its error
**names the call site**, digging past ~60 frames of bundled undici to find the line
that actually reached out. It prefers this repo's own code over a `node_modules`
frame, since a transitive dependency reaching out is the case the ESLint ban cannot
see and the frame a reader has to act on is the one that called it. Loopback
(`127.0.0.0/8`, `::1`, `localhost`) and unix/named-pipe sockets stay open; the CLI's
port probe and the dashboard boot test depend on them.

**It covers `vitest bench` too**, and by construction rather than by a second wiring:
bench mode resolves the same `vitest.config.ts`, so the same `setupFiles` entry loads.
Both halves were confirmed live there — the throw on a non-loopback connect, and the
`afterAll` backstop that fails a run where a refusal was swallowed. What that argument
rests on is a `bench` script not steering vitest at another config, which
`packages/eslint-config/test/bench-harness.test.js` asserts: a bench-only config missing
the entry would run product code unguarded while every existing assertion here, all of
which read the config the `test` script uses, stayed green.

Five things about it are load-bearing:

- **A refusal is recorded as well as thrown.** Almost every boundary here is
  deliberately fail-open, so a throw inside a `catch {}` would be swallowed and the
  run would go green having reached the network. `afterEach`/`afterAll` fail on any
  undrained record. A test that provokes a refusal on purpose must drain it with
  `takeBlockedAttempts()`.
- **It imports `node:net`/`node:dgram`/`node:dns`, which the lint ban forbids.**
  That is the enforcement, not a violation. `packages/eslint-config/test/no-network-runtime.test.js`
  lints the file and fails if it trips a **fourth** ban.
- **It re-installs itself into every worker it can reach.** A `worker_thread` gets a
  fresh module registry, so a worker used to escape the guard **silently** — nothing
  threw, and the parent recorded nothing for `afterEach` to fail on, which is worse
  than a miss. The guard wraps the `Worker` constructor (both the module property and,
  via `syncBuiltinESMExports`, the named-import binding `isolated-scan.ts` uses) and
  appends `--import <itself>` to the worker's `execArgv`. Wrapping the CONSTRUCTOR
  rather than the call sites is what reaches a worker **product code** starts, which
  is the case that matters; a worker's refusal cannot land in the parent's array, so
  it goes to a file `takeBlockedAttempts()` drains alongside it. Three consequences to
  keep true: that file is written only when a worker actually **refuses**, so a run
  that reaches for nothing touches the filesystem never and the drain reads a missing
  file as "no refusals"; it is **appended to and never truncated**, because a
  read-then-truncate drain destroys a refusal appended between the two; and a nested
  worker reports to the same file rather than stacking another preload onto `execArgv`.
  Any read failure that is not `ENOENT` fails the run — a channel that cannot be read
  reports zero refusals, which looks exactly like a clean one.
- **A child process is invisible to it**, which is the whole reason the `No-network`
  CI job exists. Do not describe the guard as covering shell-outs — and note that a
  worker started **by** such a child is out of reach for the same reason, since the
  wrapper lives in this process and the child never loaded it.
- **Every package must wire it**, at the right relative depth. The same suite fails
  the workspace if a package drops the entry, points it at a path that does not
  resolve to the guard, or is added without one. That last one holds for packages
  that test through **vitest**; a package testing through another runner cannot load
  a `setupFiles` entry at all, so the suite enumerates every package with a `test`
  script and fails on any non-vitest one unless it is named in
  `EXPECTED_NON_VITEST_TEST_PACKAGES` (empty, and each entry would be a real hole).

The only opt-out is **`takeBlockedAttempts()`**, and there is no env escape hatch or
config flag. Draining is how a test consumes a refusal it provoked on purpose, so it
is also the way to hide one — the seam is deliberate, narrow, and visible in review.
A test that needs the outside world needs an injectable seam instead — `local-ops`'
`ReportDeps.viewDistTags` and `judge.ts`'s `spawnClaude` are the two worked examples,
and neither `vi.mock`s `execFileSync`: they take the boundary as a parameter, so the
network call site is never reached rather than being intercepted.

`dns.lookup` is deliberately **not** patched (the connect guard already gates what it
feeds). Be accurate about what that leaves: a bare lookup still discloses the hostname
to whoever runs the resolver, which is real egress even though it is not a dependency
on a remote service. Only the Linux `No-network` job blocks it.

**The CI script is a tested artifact, not a script nobody reads.** It is the only gate
covering child processes, and its whole value is a positive control that refuses to run
vacuously — which is worthless if nothing exercises it. The same suite drives
`tools/ci/no-network-test.sh` with a `PATH` of hand-written stubs and pins every
outcome: probe tooling missing, DNS still resolving, the target still answering, the
probe reporting itself broken, the probe file gone, no command to run, started as root,
the privilege drop-back not having landed, and the one green path where the command
actually runs.
Change a probe and a case fails; delete one and the case that covered it fails.

The drop-back case guards the only **silent** outcome on that path: as root the
read-only-store cases in `packages/persistence` report `effective: false` and **skip**,
so the run stays green having quietly lost them. Refusing to START as root guards the
front door only, which is why phase 3 asserts the drop-back landed as well — and why
`id` is demanded before any of it, since a missing `id` makes every one of those checks
evaluate false without checking. The full reasoning is in the script's own header and
phase-3 comment; it is not restated here, because a rationale kept in two places is one
that goes out of step.

### The no-managed-settings guard

`test/setup/no-managed-settings.ts` is the second shared setup file, wired beside
the no-network guard by every package whose dependency closure can load
`@akasecurity/persistence`. It declares, once per test file, that the machine has
**no administrator**.

The administrative overlay (§6) is read from absolute system paths — outside
`~/.aka` on purpose, so a lock is not removable by the party being locked — and
`base` redirects only the home. So a suite that builds a whole fake machine in a
temp dir still reads the REAL managed file, and its result depends on who ran it:
a laptop enrolled for dogfooding pins `runMode: attached`, which failed
twenty-eight cases across `persistence`, `plugin-sdk` and the CLI on a clean
checkout of main. CI has no such file, so CI stayed green and the failure landed
only on the machines with no gate on them.

Five things about it are load-bearing:

- **A per-call override cannot replace it.** `readEffectiveSettings` and
  `applyOnboarding` both take a `managedOverride`, and neither reaches the reads
  that actually fail: `db.installedPacks.setPolicy()` reaches
  `readWorkspaceSettings` through `openControlPlaneFloors`, and `aka sync-history`
  reaches it again inside the attached-mode pass. Threading a parameter there
  means a test-only argument on `openLocalDatabase` and on the history-sync deps,
  and the next deep caller reopens the hole. The property is process-scoped
  because the thing it describes — which administrator owns this machine — is.
- **It moves the DEFAULT only.** `readManagedSettings` still honours paths it is
  given, which is what keeps the managed layer's own suite testing the managed
  layer rather than asserting an unmanaged machine. Verified by mutation: making
  `overlayManagedSettings` a no-op still reds 39 of that file's 75 cases.
- **The seam it installs is test-only, and audited.**
  `UNSAFE_TEST_ONLY_setManagedSettingsPaths` is named by exactly one shipped file
  and is NOT re-exported from the package entry point — the same two properties
  the raw handle carries, held by
  `packages/eslint-config/test/test-only-seam.test.js`. The setup file reaches it
  by relative path, which is why no `exports` entry is needed.
- **It imports ONE MODULE, never the package barrel.** A setup file loads before
  every test file in every package that wires it, so whatever it imports is cached
  before any of them registers a mock. Importing the barrel pre-cached `paths.ts`,
  `fingerprint.ts` and the vault against the real `node:fs`, and the three suites
  that `vi.mock('node:fs')` and then `await import('../src/paths.ts')` got the
  unmocked instance back — nineteen cases failing on a branch they could no longer
  enter. Their own "the interception fired" guards are what caught it. That import
  is pinned by its own case in the guard suite.
- **A CHILD PROCESS is invisible to it**, exactly as one is to the no-network
  guard, and for the same reason: the pin lives in ONE process's instance of
  `managed-settings.ts`, and a spawned child loads its own copy with the shipped
  `null`. A redirected `HOME` does not move an absolute system path — which is
  the premise this whole section rests on — so the child reads the real
  administrator's file. Measured rather than reasoned: with the pin installed,
  the parent reads `null` while a child spawned with `HOME` redirected reads the
  machine's own managed values. Every suite that drives a BUILT script is on the
  far side of that boundary. `plugins/*/test/e2e/fail-open.e2e.test.ts` runs the
  built hooks under a redirected home and `loadConfig` applies the overlay inside
  the child, so an administrator pinning `redactFallback` — a key that is both
  pinnable and lockable — flips rows asserting an exact wire shape. Read that as
  REACHABLE rather than currently failing: those suites pass on the enrolled
  machine that motivated this section, whose file pins `runMode` and not
  `redactFallback`. `plugins/claude-code/test/e2e/content-retention-entry-bundle.e2e.test.ts`
  is a second suite on that side, and the sharper one: `bodyRetention` is
  pinnable too, and it is the whole subject of three of that file's cases —
  the two SessionStart cases and the one that runs the expiry child directly
  with no settings file — so a disagreeing pin would flip a case rather than
  perturb a wire shape. Those three read the machine's managed file
  themselves — by explicit path, since the pin moves only the default — and
  skip where it overrides what they write. A worker thread sits on the same boundary and is out of reach
  today only because `scan-worker.ts` takes no persistence dependency. It is
  documented rather than closed, because the no-network guard's own mechanism
  does not transfer: it reaches a worker by appending `--import <itself>` to its
  `execArgv`, and a child spawned with a deliberately minimal env cannot be
  reached that way. Nothing covers this the way the `No-network` CI job covers
  shell-outs.

### The PATH shim, and why it fails OPEN

A suite that drives a **built** script cannot reach that script's spawn seams — they are
in-process, and the script is another process. So the external command is faked by putting a
controlled executable first on the child's `PATH`: the journey harnesses' judge stub
(`claude`/`codex`/`agy`) and `plugins/claude-code/test/provenance.test.ts`'s fake `npm`.

**A shim that does not land is not an `ENOENT`.** Resolution walks the rest of `PATH` and runs
the REAL installed binary — measured by joining `PATH` with `';'` on a POSIX host, which
resolved and executed the live `claude` CLI. So the failure mode is a suite that looks hermetic
while reaching a live model or the npm registry, and no gate above sees it: the ESLint ban reads
source, the vitest guard cannot follow a child process, and the Linux `No-network` job is the
only one that would — on the one platform where the shim happens to work.

`plugins/*/test/helpers/path-shim.ts` is the shared answer, and it is a **peer copy per
plugin** for the reason `no-echo.ts` is: a package wall blocks the import, and a copy takes its
`path-shim.test.ts` with it — or `assertShimResolves` can be weakened back into a no-op with
every caller staying green. Four properties are load-bearing:

- **Resolution is PROVEN before a chain is driven, not assumed.** `assertShimResolves` spawns
  the command the way the code under test will, so a miss is a red setup rather than a live
  call. It **performs** resolution rather than modelling it, so it agrees with libuv about
  `PATHEXT` instead of restating it.
- **`shell` and `cwd` must mirror the spawn being stood in for**, because the probe cannot
  discover either and PATH is not the whole of resolution. `provenance.ts` exports its
  `USE_SHELL` so the probe imports the runner's own condition rather than re-deriving it;
  `triage/judge.ts`'s `spawnClaude` uses no shell. **Windows searches the working directory
  before walking PATH**, so a probe taken under a different cwd than its subject faithfully
  performs a resolution the subject never performs — which is why the journey harness keys its
  proof by cwd rather than latching it once, its `run()` taking a per-step cwd.
- **The probe answer comes before anything else the stub does**, so probing is never recorded
  as an invocation — which is what a `judgeWasInvoked()`-style sentinel assertion rests on.
- **Four POSIX-only defects, not one**: `path.delimiter` rather than a literal `':'`, a `.cmd`
  launcher on win32 rather than an extensionless `#!` file, no reliance on a `chmod` that
  is a no-op there, and the launcher naming its script by ABSOLUTE path rather than through
  `%~dp0`. `tools/portability-gate`'s `path-separator-literal` rule catches the first
  returning to a call site, which is how it arrived. The fourth is the one that reads as
  correct and is not: `%~dp0` means "the directory of the running batch file" only when `%0`
  holds a path, and for a batch cmd.exe resolved from PATH under a bare name `%0` is the name
  AS TYPED — so `%~dp0` expands against the CURRENT DIRECTORY instead. It went unnoticed for
  as long as nothing anchored the spawn's cwd; the moment §7's Windows anchor landed, every
  shim-driven suite in all three plugins failed with node reporting `Cannot find module` for a
  path under the anchor that nothing had written. Each plugin's `path-shim.test.ts` pins the
  launcher's bytes under an explicit `'win32'`, so a POSIX runner catches its return.

`writeCommandShim` takes an optional `platform` (as `judgeEnv` does), so both branches are
driven from either host: writing the other platform's form is a resolution failure on this one.
Pin the **artifact** as well as the refusal there — a branch that wrote nothing also refuses,
and reads identically.

Two smaller things the same reasoning decides. The probe's own deadline sits **well under the
package's `testTimeout`**, because it runs inside a test body: equal deadlines mean vitest wins
the race and the refusal — the whole point of failing closed — is replaced by a bare timeout.
And `shimmedPath` returns the bin dir **alone** when there is no base PATH: an empty PATH entry
means the current directory to execvp and to libuv, so a trailing separator quietly puts the cwd
on a search path whose only purpose is that nothing but the shim is on it.

### The adversarial fixture corpus

`test/fixtures/adversarial/hostile-repo/` builds the hostile trees the tree walkers
have to survive — symlink loops, `..`-bearing names, nesting past the depth a path can
address, a `.gitignore` that ignores everything, 500k files ignored by directory and
the same number ignored by pattern. It sits at the repo root for the same reason the
no-network guard does: `project-files`, `local-ops`' folder scan and `scanner` all need
the SAME inputs, a package wall blocks the import, and three private copies of a symlink
loop drift apart. All three drive it now, each from its own `test/performance/`
directory — and the two that were added last found two things a shared corpus is for: the
folder scan aborted its whole generator on one unreadable directory where the other two
tolerate it, and the three walkers disagree about a `..`-prefixed DIRECTORY, which the
folder scan's dot-directory floor refuses and the other two descend into. Both are pinned
where they differ rather than smoothed over.

The corpus is in `globalDependencies` (see "Adding a new workspace package" on why every
shared `test/` subtree must be): it is generated, so weakening a shape — shrinking the
tree, dropping the loop, breaking the deep chain — changes what every guard driving it
actually runs, and outside the hash those guards replay a cached green at the one moment
they are being changed. `packages/eslint-config/test/shared-test-inputs.test.js` derives
that requirement from what the tree really references rather than listing it.

Two things about it are load-bearing:

- **Generators, not checked-in trees.** Most of these cannot live in git at all, and
  the ones that could would read as a mistake in a listing. Everything goes into a temp
  dir and comes back out through `cleanup()`.
- **A shape that cannot be built everywhere reports `created: false` with a reason, and
  the caller `ctx.skip`s on it** — a symlink needs a privilege on Windows, `chdir` is
  absent in a worker thread. An early `return` would report as a pass, which is the
  failure mode the store harness exists to remove.

**Depth is the part that is easy to get expensively wrong.** Creating a directory whose
absolute path exceeds `PATH_MAX` means descending with `process.chdir`, and the OS
re-resolves an ever-longer working directory on every step — so that descent is
QUADRATIC. Measured on an arm64 Mac: the first 500 levels take 35 ms, the next 1,000
take 4.0 s, the next 2,000 take 31 s, and a 10,000-level chain costs ~348 s to build
and ~347 s to remove. `deepChain` therefore builds the addressable part with plain
absolute `mkdirSync` (one syscall a level) and gets PAST the ceiling by making a
handful of NAMES 255 characters rather than the chain deep. A fixture that reaches for
literal 10,000-deep nesting is not thorough, it is a timeout.

`packages/persistence/test/helpers/` holds the shared store harness. Tests **in this
package** import it rather than re-rolling the `mkdtempSync` + `openLocalDatabase` +
cleanup dance, and that is enforced rather than asked:
`packages/persistence/test/harness-adoption.test.ts` derives the file set from
`git ls-files` and fails on any suite that opens a store and builds its own temp tree.
It carries **no** exception for that pair. The suites that build a tree and open no store
are pinned as an EXACT set with a reason each — a floor would forbid removals while
letting the next hand-rolled teardown in, which is the direction this actually drifts.
That guard strips comments before it matches anything, because the count this replaced
was taken with a plain grep and came out two files high: `paths.test.ts` and
`local-layout.test.ts` name `openLocalDatabase` in prose and open nothing.

**Reach for `store.openRaw()` by default; a bare `new DatabaseSync` needs a reason written
at the top of the file.** `openRaw()` keeps every handle it hands out open until teardown,
so it is wrong in exactly two places. Where the CLOSE is part of the setup:
`legacy-writers.test.ts` replays a legacy _process_ — one connection, one statement, closed
again, never overlapping — and `legacy-compat-views.test.ts`'s fixture handles leave the
store at one point in a migration and close before the next pass drains further, so a live
handle changes what that pass does. And where the file is not the store at all: `openRaw()`
only ever opens `<home>/data/aka.db`, so a `.legacy.` backup copy or a moved-aside store has
to be opened by hand (`database.test.ts` has both).

**What is NOT a reason, though it reads like one, is a descriptor probe.**
`descriptorProbe().leakedBy` measures a delta around a **synchronous window**, so a fixture
handle opened outside that window sits in the before-count and the after-count alike and
moves the number not at all — only a handle opened _inside_ the window can. Measured rather
than reasoned: converting `database.test.ts`'s fixture handles to `openRaw()` left all 24 of
its cases green, which is what retired an earlier version of this paragraph claiming the
opposite.

**Outside `packages/persistence` the harness is deliberately NOT available, and the
decision is not "nobody got round to it".** It lives under `test/`, and the package's
`exports` map is `"." -> "./src/index.ts"` plus `"./sqlite-free"` (the store-free modules the
plugin-sdk runtime path needs: a subset of the root entry's bindings that never loads
`database.ts`, both held by `packages/persistence/test/sqlite-free-entry.test.ts`) — which is exactly
what makes `UNSAFE_TEST_ONLY_RAW_HANDLE` unreachable elsewhere. A `./testing` subpath would undo
that: `open()` hands back a spread copy that CARRIES the seam symbol, so every consumer
package would gain a supported route to the raw `DatabaseSync`, and
`test-only-seam.test.js` would stay green throughout because the new callers are tests.
The harness also imports `vitest` at module scope, and `noExternal: [/^@akasecurity\//]`
inlines whatever a shipped entry reaches. So store tests in `cli`, `local-ops`,
`plugin-runtime`, the three plugins and `web-ui` still roll their own, and each of those
is a teardown re-derived rather than reused. Closing that means a **separate dev-only
workspace package** built on the public index (the one route that keeps both properties),
with the full "Adding a new workspace package" checklist — its own lint config and script,
tsconfig, a vitest config wiring the no-network guard, and entries in
`EXPECTED_WORKSPACE_PACKAGE_NAMES` and `EXPECTED_VITEST_PACKAGES`. Tracked separately; do
not reach for a `./testing` export instead.

- `withTempStore(fn)` / `useTempStore(prefix)` — a disposable `~/.aka` (`settings/` +
  `data/`) whose handles are closed and tree removed for you. Use `useTempStore` when the
  suite shares setup across hooks, `withTempStore` when one test body owns the store. An
  async body is awaited before teardown.
- **`{ migrated: true }`** — the second argument to all three, and the one to reach for by
  default. `openLocalDatabase` runs every migration in the ledger on a store that has none,
  so a suite with per-test isolation rebuilds the whole schema on every test; this seeds
  `data/` from a template built ONCE per worker and copied. Isolation is unchanged — each
  test still gets its own file, and no handle is shared. Measured at roughly 9-10x cheaper
  per setup (`packages/persistence/bench/store-template.bench.ts`), which matters because
  the Windows leg charges about 30x local cost for exactly this work and a `beforeEach`
  that blows the hook ceiling fails a package the PR never touched.

  **It is opt-IN, and that is load-bearing.** A seeded store has nothing left to migrate,
  so a suite whose SUBJECT is the open path must not take it: migrations, the lineage
  reset, the `.bak` a fresh migration leaves beside the store, `aka init` creating the
  store, a first-run flow, or a fault injected so that `applyMigrations` is the thing that
  refuses. Those assertions would hold **vacuously** rather than fail — which is worse
  than losing them, because they go on reporting green. `test/helpers/store-template.ts`
  is the shared builder (six packages copy from it; `migrated-store.ts` is this package's
  build step), and it refuses a build that left no store, one that left a live `-wal`, and
  any seed over a store or a foreign log that is already there.

- `withTwoWriters(fn)` / `withWriters(n, fn)` — N independent `LocalDatabase` handles on
  one file, the shape the product runs in (hooks, CLI and dashboard share `aka.db` with
  only WAL and `busy_timeout` between them).
- `corpus.ts` — `seedCaptureCorpus(db, options)`, a deterministic store of a stated SIZE
  written through the product's own `recordCapture` inside one transaction, plus
  `corpusConnection(db)` and the corpus clock (`CORPUS_EPOCH_MS`, and
  `GeneratedCaptureCorpus.endsAt` — prefer the latter). Two rules. **Drive every windowed
  read with the corpus clock**, never `Date.now()`: the corpus is stamped from a fixed
  2024 epoch, so the wall clock puts every `WHERE … >= :from` years past the data and the
  read matches nothing while still returning a real plan and a real, meaningless number.
  And **reach the connection through `corpusConnection`, never `UNSAFE_TEST_ONLY_RAW_HANDLE`
  directly, from anything outside `test/`** — `bench/` is not a `test/` path, so a
  `*.bench.ts` naming that seam is read as a product caller and fails
  `packages/eslint-config/test/test-only-seam.test.js`.
- `query-plans.ts` — `recordingConnection(db, into)` captures the SQL and the bound
  parameters as the repositories execute them, and `explain` / `classifyPlanRow` /
  `indexOwners` turn one into a plan step (`full-table` / `full-index` / `search`). The
  point is that no query is ever spelled twice: a plan assertion over SQL restated in a
  test is a real plan for a query no user issues. Record at EXECUTION, not at prepare
  time — several repositories prepare in their constructor. It leaves out the two
  statements an index-presence probe (`src/internal/index-presence.ts`) issues: the
  `sqlite_master` lookup would classify as `full-table`, and it runs only on a name's
  first call, so a statement count would depend on test order.
- `fault-injection.ts` — `corruptStore`, `readOnlyStore` and `lockStore`, plus the
  `SQLITE_*` result codes, `sqliteErrcode()` and `primaryCode()`. Each injector produces a
  real error code from the real engine and refuses to run rather than take effect
  vacuously — an absent store, a live handle. Where the platform or the privilege decides
  instead of the helper, `readOnlyStore` reports it as `effective: false` and **the caller
  must gate**: `if (!readOnly.effective) ctx.skip(reason)`. Pass the store's `onCleanup` to
  any injector that has to be undone before the tree can be removed, and the store itself
  to any that needs no live connection.
  `fillStore` is in the same file and its page cap is connection-scoped, so it bounds only
  the handle it is given — which is what decides where it can be aimed. A repository
  constructed over a raw handle takes it (`SqliteAuditEventsRepository(raw)`, the
  `activity.test.ts` pattern), and so does `applyMigrations` — but only with a cap sized
  against a real migration: the default headroom on an empty file stops the ledger's own
  `CREATE TABLE`, so the applier's loop never runs and every claim about a partial
  migration then holds vacuously. The blanket fail-open sites in `database.ts`
  (`recordCapture`, `ensureInventory`, `recordConfigScan`, `recordProjectFiles`, and
  `reconcileWorktreeProjects`, which reaches `withTransaction` directly) are closures over
  the connection `openLocalDatabase` opened, so they are reached by capping **that**
  connection — `db[UNSAFE_TEST_ONLY_RAW_HANDLE]`, below. Handing `fillStore` a second
  handle on the same file instead is the mistake to avoid: it carries none of the cap, the
  facade writes on undisturbed, and every "the write was dropped" assertion then passes
  because nothing was ever refused.
- **`UNSAFE_TEST_ONLY_RAW_HANDLE`** — not a helper but the seam a connection-scoped
  injector aims at, so it is listed here: the `DatabaseSync` a `LocalDatabase` writes
  through, exported from `src/database.ts`, and the only test-only seam in shipped
  source. It is
  symbol-keyed and **not** re-exported from `src/index.ts`, and the package's `exports` map
  is `"." -> "./src/index.ts"` plus `"./sqlite-free"` — a subset of the root entry's
  bindings that never loads `database.ts`, both held by
  `packages/persistence/test/sqlite-free-entry.test.ts` — so no other package can reach the
  module that defines it. Two properties are load-bearing and easy to break: it is a plain **enumerable data
  property**, because the helpers hand out `{ ...db, close }` wrappers and spread copies own
  enumerable symbols — a getter or a non-enumerable definition loses it silently; and it is
  the **real** connection, so `close()` reaches it.
  `packages/eslint-config/test/test-only-seam.test.js` is what keeps "test-only" true: it
  walks the tracked tree and fails if any file naming the seam is neither its one definition
  site nor a test. That is a derived rule, not a list — a new fault test needs no edit there,
  a product caller anywhere in the workspace fails CI. It lives in that package rather than
  in `persistence` because only that task's turbo `inputs` hash the whole workspace; the
  same check inside `persistence` would replay a cached green while a caller appeared in
  `cli/src`.
- `assertNoOpenTransaction(db)` — a fault that leaves a transaction open is worse than the
  fault; assert this after injecting one. It reads `db.isTransaction` rather than probing
  with a transaction of its own, so it cannot disturb the handle it is inspecting.
  **It belongs at two shapes, not only after a fault.** After a path that REFUSES inside a
  transaction — `applyMigrations` on a partially-present migration, a `runInTransaction`
  that drops a malformed leaf — because refusing is half the requirement and containing the
  refusal is the other half; a handle left inside its `BEGIN` makes every later write on it
  join a transaction nobody started, and the store reads as healthy from outside. And after
  a fixture SEEDER's `COMMIT`, because a seeder that returns still inside its `BEGIN` has
  committed nothing, and every read below it then measures an empty store and reports the
  number as a result.
- `errorFrom(fn)` — the error a thunk threw, captured OUTSIDE its own catch (see
  [Testing](#testing) on why the try/catch form asserts on the test's own guard).
- `descriptorProbe()` — how many OS descriptors a synchronous thunk left behind. A
  `DatabaseSync` that escapes a failed open is unreachable to the caller, and POSIX
  unlinks an open file happily, so the temp tree still goes and the leak shows up
  nowhere; Windows alone refuses the delete. This counts instead, which is what makes
  the leak observable on the legs that run most of the suite. Three properties are
  load-bearing: the window must be **synchronous** (`leakedBy` refuses a thenable —
  an await hands the loop back and unrelated I/O lands in the delta), the count is
  the whole descriptor table rather than a per-file one (macOS `/dev/fd/N` entries
  are character devices, so `readlink` cannot name the store the way Linux's
  `/proc/self/fd` can), and the probe **self-checks** before reporting
  `observable: true` — a counter stuck at a constant reports "nothing leaked" for
  every input, so it proves it can see one descriptor it opens itself. Where it
  cannot (Windows has no `/dev/fd`), it reports `observable: false` with a reason and
  **the caller must gate**: `if (!probe.observable) ctx.skip(probe.reason)`, the same
  shape `readOnlyStore` uses for a mode the platform ignored.
- `captureEvent()` / `captureFinding()` — the minimal `recordCapture` pair. Both vary
  their identity per call, which is load-bearing: `contentHash` feeds the event's
  content-addressed id and `maskedMatch` is half the finding-level session dedup key, so a
  shared constant makes a second capture land as zero rows **by design** — and every "the
  event is gone" assertion in `test/faults/` would then pass whether or not the fault did
  anything.

`packages/persistence/test/faults/` is where those injectors are pointed at product code —
one file per fault (corrupt is covered in `database.test.ts`, which predates the
directory). A fault case documents the **actual** behaviour rather than the desirable one:
no fail-open site inspects a SQLite result code, so contention, a full disk, a read-only
file and a caller bug all arrive there as the same discarded `false`, and a dropped event
leaves no counter, marker or log line behind. (Scoped to those sites deliberately: the
package reads `errcode` in two places — `internal/sqlite-errors.ts` for
`SQLITE_CONSTRAINT_UNIQUE`, and `fingerprint.ts`, which separates "no such table" from a
damaged or locked store. Neither is a fail-open path.) Write that down and assert it; do not assert a signal the product does not
emit. Two behaviours there are the opposite of what the fault's name suggests and are
pinned so nobody "fixes" them: a store chmod'd read-only under a **live** handle keeps
being written (a descriptor carries the permission it was opened with, so only the next
open is refused), and a read-only store's refusal lands **inside the migration applier**
rather than on the first `PRAGMA journal_mode = WAL`, which succeeds and mints the
sidecars. A tightened directory the owner still owns is a third case, and the distinction
is the point: `ensureDataDirSync` widens the **data dir** back to 0700, so a 0500 there
never reaches SQLite — but a 0000 `~/.aka` is **reported**, as `EACCES` out of `mkdir`,
long before any chmod runs. `aka init` repairs a 0000 home because it calls
`ensureDataDirSync` on the home itself; `openLocalDatabase` does not, and the tests in
this directory assert the refusal.

Assert the result code, not an error message or an elapsed time — Windows CI runs several
times slower, and a timing assertion there is a flake. Compare with `primaryCode()`:
`errcode` carries the **extended** code, so `SQLITE_READONLY` also arrives as
`SQLITE_READONLY_DIRECTORY`. Do not add vitest `retry`.

Where a platform or a privilege makes an assertion meaningless, use `ctx.skip(reason)`.
An early `return` reports as a pass, which is the failure mode the store harness exists
to remove — and it is worse than a missing test, because a green tick is read as
coverage. The macOS-only `O_EXCL` case in `paths.test.ts` is the one that shows the cost:
it is the only thing anywhere pinning `flag: 'wx'`, so while it returned, deleting that
flag left every other leg green.

**Which of the two shapes to write is decided by what the test still asserts on the
guarded platform, not by taste.** A guard that ends the body before any assertion runs
must `ctx.skip(reason)`, because a pass there is a claim the run never checked. A guard
that only gates a subset — the test still verifies something real on that platform — is
written as a positive conditional, `if (process.platform !== 'win32') expect(…)`, and
**must not** become a skip: `ctx.skip` throws, so a skip at the tail discards a result
that genuinely held and reports the case as uncovered where it was not. Both shapes are
the same edit to make, and neither leaves an `if (process.platform …) return;` behind.

That last part is enforced rather than asked: `tools/portability-gate`'s
`platform-guard-early-return` (rule 6, spec files only) fails `pnpm lint` on the shape.
Three older suites predated it — `settings.test.ts`, `fingerprint.test.ts` and
`plugin-sdk`'s `config.test.ts` — and were exempt through `GRANDFATHERED_PLATFORM_GUARDS`
while their six guards were owed. All six are converted, so **that map is now empty and
nothing is exempt**: a guard in any spec file is reported. The mechanism stays, because it
is what lets a rule of this kind land on a tree that already carries its shape, and an
allowance is a ratchet in both directions — exceeding it is a violation, falling below it is
`platform-guard-stale-allowance`, so converting a guard means lowering the number in the
same commit and a file that reaches zero leaves the map. The ratchet is on the COUNT,
though, not on which guards make it up: converting one and adding another in the same
commit holds the number and passes both rules, so the gate cannot tell that pairing from an
honest conversion. That is the reason to keep the map empty rather than to re-open it —
with no entry at all, every guard in every spec file is reported and the pairing has
nowhere to hide.

### Testing a web-ui Server Action

A Server Action runs against the real store like any other test here, but four setup
steps are required before the first one will run at all, and **missing any of them
produces a failure that looks nothing like its cause**. `web-ui/test/actions/exceptions.test.ts`
is the worked example.

1. **Redirect the home dir** by mocking `node:os` and overriding `homedir()`. The action
   resolves `~/.aka` from it, and `n/no-process-env` rules out an env override, so this is
   the only seam. Set it through a `vi.hoisted()` box so `beforeEach` can point it at a
   fresh `mkdtempSync` dir per test.
2. **Alias `server-only` to an empty module** in `web-ui/vitest.config.ts` — `app/lib/db.ts`
   imports it, and the real package throws at import time outside a React Server bundler.
   Already wired; a new suite needs no change.
3. **Mock `next/cache`** — a mutating action calls `revalidatePath()`, which needs a Next
   render context that does not exist under vitest.
4. **Close and drop the memoised DB handle** on `globalThis` (`app/lib/db.ts` keeps it at
   `__akaDb` across requests and HMR reloads) in both `beforeEach` and `afterEach`, or the
   next test reads the **previous** test's temp store. Reset it again mid-test after any
   direct write through a second handle, so the action reopens and sees it.

Seed whatever snapshot the action reads before calling it — anything scanning a value needs
`installed_packs` populated via `recordInventory(bundledDetections())`, because the action
scans against the **DB snapshot**, not the engine's process-global registry.
`@akasecurity/plugin-sdk` is a **dev-only** dependency of `web-ui` for exactly this, which
is not a runtime package-wall crossing.

**A Server Action's parameter types are a claim its runtime never checks.** The arguments
arrive as JSON over an HTTP POST, so a caller may post a number, a null, or an object
carrying a hostile `toString` wherever the signature says `string`. Such a value reaching a
`.trim()`, a `.slice()`, a template literal or a SQL bind parameter throws — and a **thrown
Server Action rejects**, so the browser gets a framework error page instead of the
recoverable `{ ok: false, error }` the action was written to return. That is the same
failure the store-failure guards in `actions.ts` exist to prevent, reached by another door,
which is why a fail-open `catch` around the store is not enough on its own.

The exceptions surface closes it by parsing the **whole input** — the shapes live in
`@akasecurity/schema` (`src/zod/exception-action.ts`) and every mutating action there
`parseActionInput`s its payload before touching a field. Three rules make it hold:

- **Parse the whole input, not each field.** A payload that is not an object at all fails
  before any field is read, and no per-field check can express that case.
- **The refusal names the schema's KEY, never the payload.** A field arriving as the wrong
  type is still a live credential, so nothing derived from it — including a Zod issue
  message — may reach the message describing its rejection.
- **Widening in a test alias is per-PARAMETER, not per-action.** An alias that widens
  `confirmation` to `unknown` and leaves `reason` at `string` covers one field while reading
  as covering the action; that is exactly how `reason` went unwidened, and unguarded, across
  three actions at once. Widen the alias's whole input to `unknown`.

Assert three things per case, in this order: it does not throw, it names the field, and it
echoes no raw value (`expectNoEchoOf`). The middle one is not decoration — `expectNoEchoOf`
catches an absent error but not an empty one, and every `not.toContain` passes on `''`, so
requiring the message to say something specific is what stops the echo check going vacuous.
Keep a positive control that a well-formed payload still succeeds, or an action rewritten to
refuse everything satisfies all three.

Every other `'use server'` file under `web-ui/app` is **not** guarded this way yet — eight
files, 20 exported actions at the time of writing — so do not read the above as a property
the dashboard has. The set is deliberately **not** listed here: a partial list reads as
exhaustive and stops an audit at the files it names, and a full one goes stale the first
time an action file lands. Derive it from the **directive**, which is not the same as
grepping for the words — `app/lib/dropped-rules.ts` mentions them in a comment and exports
a sync function, so it matches the text and is not a Server Action.

### Testing a web-ui page

An async Server Component is a plain async function that returns an element, so a route's
own data work — which store reads it issues, and which one it hands to which consumer — is
testable by **calling the page and reading the props it hands down**. No renderer, no DOM,
no jsdom. `element.props` carries them; assert `element.type` alongside, so a page that
starts returning a wrapper fails naming that rather than reading every prop as `undefined`.
`web-ui/test/pages/exceptions-page.test.ts` is the worked example. The four steps above
apply unchanged (a read-only page needs no `next/cache` mock).

This is not the browser tier — nothing mounts, no event fires, no client component runs.
It covers the seam between the store and the props, which is where a route's derivations
live and where nothing else can see them.

**The one thing that makes it work is a config line.** `web-ui/tsconfig.json` sets
`"jsx": "preserve"` because Next's own compiler consumes untransformed JSX, and vitest
inherits it — so the page's JSX survives into the output and the parser that reads it next
rejects it. `oxc.jsx` in `web-ui/vitest.config.ts` overrides that for the test run only. It
is already wired; a new suite needs no change, but a parse error on a `.tsx` import is what
its absence looks like, and it names neither JSX nor the tsconfig.

A route that issues **more than one read of the same table** is the case worth writing a
page test for at all: a count derived from the wrong one of two windows is still a number,
so it typechecks, lints, and satisfies every assertion aimed at the pieces. Make the fixture
straddle the two windows and **pin the straddle itself** — with rows that all fall inside
both, the assertions pass whichever read the page used, and the suite proves nothing.

An at-rest leak scan must read **every file in the data dir**, not `aka.db` plus a
hardcoded `-wal`/`-shm` pair. This is not a corner case: a migration carrying pre-cutover
history forward leaves an `aka.db.pre-drop.<ts>.<rand>.bak` — a byte-for-byte copy of the
pre-migration store, as large again as `aka.db` itself — in that directory, so a name-list
reader misses more of the store than a `-wal` pair ever covered. **Which of those copies
exist is a property of the store's HISTORY, not a list anyone can write here**, which is the
argument for walking the directory and is why it survived that copy ceasing to be
unconditional: the pre-drop snapshot is taken only where the drop would destroy rows
(`legacyDropWouldDestroyRows` in `packages/persistence/src/migrations.ts`), so a store this
same open created writes none — it used to write one on **every** first open, half the bytes
in a new data dir, protecting a freshly-built schema that held nothing. On top of that SQLite writes an
`aka.db-journal` instead of the WAL pair wherever WAL silently no-ops (see `dbSidecars` in
`packages/persistence/src/paths.ts`), and the foreign-lineage reset leaves its own `.bak`.
`web-ui/test/helpers/store-bytes.ts` is that reader; import it rather than re-rolling one.
Bind one call and assert against it — the positive control and the absence check must
describe the same bytes, not two independent reads. Two rules keep it honest, because an
empty read contains no secret and so passes every `not.toContain` vacuously: keep the
**positive control** — assert a value that **is** expected on disk before asserting the raw
is absent — and **never swallow a failed read**. Only a sibling's `ENOENT` is tolerable (an
atomic write's `.tmp` vanishing mid-scan); a permission denial or a Windows sharing
violation on `aka.db` must throw.

Assert a raw value is absent from an **error** run by run, not whole. `not.toContain(value)`
stays green if a branch echoes a _truncated_ value, which is still a live credential's
prefix. `expectNoEchoOf` is the **required form for every raw-value absence assertion that is
newly written or newly touched** in a package carrying the helper — `cli/test/helpers/no-echo.ts`,
`plugins/claude-code/test/helpers/no-echo.ts`, `plugins/codex/test/helpers/no-echo.ts`,
`plugins/antigravity/test/helpers/no-echo.ts`, `web-ui/test/helpers/no-echo.ts`,
`packages/setup-wizard/test/helpers/no-echo.ts`,
`packages/persistence/test/helpers/no-echo.ts` and
`packages/remote/test/helpers/no-echo.ts`. A plain
`not.toContain(rawValue)` in a new or edited assertion is a defect, not a style choice, and
editing a file means its in-class assertions come along rather than being left beside converted
ones.

**The same rule holds in PRODUCT code, and one module is where it is enforced.**
`packages/plugin-sdk/src/raw-egress.ts` is the boundary every raw value crosses on its way out
of the triage path — its callers route a model's `reasoning` and `notes`, a downstream error
message bound for the parent command's stderr, the serialized plan document, a masked context
window and a file path through it. `assertRawFree` and `maskContextSlice` reject **run by run**
against `RAW_RUN_LEN`, for the reason the paragraph above gives: `text.includes(raw)` matches
only if the ENTIRE value survives, so a truncated echo passes it, and truncating is exactly what
a model does to a long token it quotes back. A partial echo there does not merely print —
`planTriageWriteback` writes `reasoning` into a suppression grant's `justification`, so it lands
at rest. `RAW_RUN_LEN` is the same number the `no-echo` helpers use, but they sit across a
package wall and are INDEPENDENT copies: neither pins the other, and each is held by its own
suite. The product side's pin is DERIVED (it measures the shortest run the boundary really
refuses, and carries a calibration case so a constant cannot stand in for a measurement), so it
goes red on the first character of drift in either direction.

`safeMaskedMatch` is the deliberate exception and must stay whole-value. It verifies a preview
built to REVEAL a fragment on purpose, so a surviving run is the feature rather than the leak:
`maskMatch`'s email branch discloses the whole domain, which is a run far past the window, and
tightening it in step with its two siblings would collapse every email preview to `'***'`. The
reason is stated at the function and pinned by a case that fails if somebody tightens it.

**Older assertions are a backlog, not a clean tree.** `plugins/claude-code` still carries around
twenty whole-value raw-value assertions in files this convention has not reached —
`history/`, `journey/`, `remediation/`, `render.test.ts`, `triage/plan-file.test.ts`,
`hooks/pointer-substitution.test.ts` and `backfill.test.ts` among them. `plugins/codex` carries
the same backlog in its counterparts of those files — the helper reached its hook and
remediation-render assertions, not `history/`, `triage/judge.test.ts` or
`remediation/redact.test.ts`. Do not read the rule above as a claim that either package is
clean. Two shapes are genuinely **exempt** and stay
whole-value: an assertion on a masked preview (`writeback.test.ts` on `maskedValue`,
`triage/surfaced-secrets.test.ts` on `maskedToken`), because that fragment is revealed on
purpose; and the deliberate **control** assertions inside each `no-echo.test.ts`, which exist
to show the whole-value form would have passed.

**Share it inside a package, copy it across a wall — and a copy takes the suite with it.**
All eight packages import a `test/helpers/no-echo.ts` with its own tests in `no-echo.test.ts`:
each case drives the helper with an output that leaks a run, and asserts both that the helper
refuses it **and** that the whole-value form it replaced would have passed. That second half is
what shows the assertion is _stronger_ rather than merely also-red, and it is why raising the
run length or emptying the loop cannot go unnoticed — widening `ECHO_RUN` leaves every
**caller** green, so the helper's own suite is the only thing that goes red. `web-ui` is the
worked example of that failure: its copy was inline with no suite, and all 86 of its action
tests passed with `ECHO_RUN` set to 64. A package wall blocks the import, not the pattern, so a
further package copies **both** files — including the `expect(value).toBeDefined()` guard,
without which an `undefined` message satisfies the loop vacuously.

**A masked-preview control calls the product's mask, never a hand-rolled one.** A locally built
literal asserts that a string the test constructed lacks a run of another string the test
constructed — true by construction, and it stays true however `maskMatch` changes. Each
`no-echo.test.ts` that has a masked-preview case calls `maskMatch` itself
(`@akasecurity/plugin-sdk` re-exports it, so the plugin crosses no package wall), which is what
makes widening its generic branch go red where the reason is written down.

`packages/persistence` and `packages/remote` are the two copies with **no** masked-preview
case, and neither is an omission to fix: neither package has a masking surface. For
`persistence` there is a second reason — `@akasecurity/detections`, which owns `maskMatch`,
depends ON it, so importing it even as a dev dependency would make a cycle out of a test
fixture. Its fixture is a generated base64 vault key instead, which is what that package
actually has to keep out of an error; `remote`'s is the userinfo of a refused endpoint, which
is what that package has to keep out of a `RemoteEndpointRefused` message.

**Capture the error outside the `catch`.** This shape passes while the function under test
stops throwing entirely:

```ts
try {
  parse(input);
  throw new Error('expected parse to throw'); // caught by its own catch, below
} catch (err) {
  expect((err as Error).message).not.toContain(RAW); // asserts on THAT message
}
```

The guard error carries no secret, so the absence check is satisfied by the test's own
throw. Use `errorFrom(() => parse(input))` (the plugin's helper) or the CLI's
`.then(() => undefined, (e) => e as Error)`, then assert what the error **says** before
asserting what it **omits** — a never-thrown error arrives as `undefined` and `toBeDefined()`
catches it. Naming the expected refusal is also the positive control: without it a case
proves only that _some_ error said nothing, not that the guarded branch was the one reached.

**Never point it at bytes that can come back empty.** Every `not.toContain` passes on `''`,
and `toBeDefined()` does not catch that: it catches a never-thrown error arriving as
`undefined`, while a Prompter's `output()` returns `string` and is never undefined, so the
guard is **inert on stdout**. Where a path prints nothing at all, assert that —
`expect(io.output()).toBe('')` — which goes red on anything printed there, raw or not.
Where it does print, pin a **positive control** on the same bytes first.

This applies to a **raw value**, in an **error** and on whatever further surface a suite
binds below. At-rest and grant-shape assertions stay whole-value, because `maskMatch`
deliberately keeps a fragment visible and that fragment is stored on purpose; and an
assertion that some non-secret string is absent — an internal error-class name, say — is a
different property that `expectNoEchoOf` does not express. Drive the window with a
**high-entropy** fixture: against an English-phrase literal an eight-character window
collides with ordinary output text instead of catching a leak. High-entropy does **not**
mean credential-shaped — this repo is public, and a fixture that looks like a real secret
does not belong in it. Where a suite needs both (`cli/test/commands/exception-reveal.test.ts`
tokenizes a value the engine never scans), a random-looking string that matches no rule
satisfies them together; where the value must match a rule, take it from that rule's own
`examples` so no secret-shaped literal is written by hand.

The CLI's `exception` suites bind it **wider than an error** — to their **stdout**
assertions too, because a CLI's terminal output is where a leaked value gets scrolled,
pasted into a bug report and captured by CI logs. That is safe rather than fragile **for
the values those suites use**: what they print of a blocked **generic** secret is
`maskMatch`'s first-and-last preview (`A******E`) — two characters, sitting in two runs of
**one**. The RUN is the quantity that matters, not the count: the window slides over
**contiguous** slices, so two characters that are never adjacent cannot fill one of any
width. **It does not generalize to every value `maskMatch` handles.** The
email branch reveals the first local character plus the **whole domain**
(`user@example.com` → `u***@example.com`), and a single-character local part returns the
input unchanged — both fill the window on purpose. A surface printing a pii/email preview is
out of scope for the stdout half, not a leak it found; `no-echo.test.ts` pins both branches
so that boundary is written down rather than re-derived.

**Two different things are pinned there, and only one of them is the margin.** The
acceptance case (`refuses(maskMatch(VALUE), VALUE)` is `false`) says the preview is safe
**today**; it cannot say how much room is left, because it fires only once the preview holds
a contiguous run of `ECHO_RUN` characters. So widening the generic branch to anything from
two revealed characters up to seven leaves it green — and that band is precisely where a
usability change lands ("show enough to tell two blocked secrets apart"). In that band the
only red is `packages/detections/test/mask.test.ts`, whose failure reads as _"you changed the
mask, update the expectation"_ and gives no signal that a downstream safety argument moved.
The **margin** is therefore pinned by its own case in `cli/test/helpers/no-echo.test.ts`,
which derives the longest revealed run from `maskMatch` and holds it at one: that goes red on
the **first** character of widening, where the reasoning is written down. Widening is a
deliberate act to be argued against the window — not a reason to loosen the callers back, and
not something raising `ECHO_RUN` can buy back, since the same file owns both numbers.

`exception.test.ts`'s masked-preview group is **derived from `maskMatch`** for the same reason
— the seeded ledger `maskedValue`, the grant-shape assertions over it, the stdout positive
control, and the two approve **selectors** that stand in for a value a user types. A
hand-written `'A******E'` on both sides of one of those is true by construction and moves with
nothing, which is the rule two paragraphs above, applied to a group rather than to a single
control. **That is a claim about that group, not about every preview in the package**: a
preview the CALLER supplies is a different thing, and `exception-reveal.test.ts`'s **vault**
seeds assert against its own `MASKED` constant on purpose, because a vault pointer's preview
is chosen at tokenize time rather than produced by `maskMatch`. **The line is drawn per ROW,
not per file.** The same file's `recordBlocked` seeds are `blocked_detections` rows, and
`packages/plugin-sdk/src/runtime.ts` masks the finding with `maskMatch` before recording one
— the only writer of that table — so those derive from `maskMatch` like any other product
mask. Derive from `maskMatch` where the product masks; derive from the raw value where the
caller does, and decide that per table rather than by which suite you are in.
`packages/detections/test/mask.test.ts` keeps its exact-string cases: they are the right pin
for the mask's own contract, and deliberately not a pin on the CLI's margin.

**Bind it per assertion, never per file.** `cli/test/commands/vault.test.ts` is the worked
example: its forged-pointer refusal pins absence like any other, but `aka vault show`'s
success path prints the raw value **on purpose** and asserts `output().startsWith(RAW)`, so
a run-by-run rule over that case would assert the opposite of the command's contract. Exempt
the case, not the file — a file-level exemption also disarms the refusal paths in it, which
are the ones a leak actually travels through.

Assert against the value **that call supplied**, never a module constant the case does not
send. A case driven with an inert literal and asserted against a shared `VALUE` cannot fail
however the branch is worded, so it stays green while the branch echoes the live value it
was handed. Name each case's own inputs. This is the same family as the two rules above: an
absence assertion is only worth its green if it could have gone red — so before trusting a
new one, break the property it claims to guard, re-run, and treat a still-green suite as a
defect in the test.
