# Gate migration matrix

Cross-reference: this matrix orders the gate moves ruled by `docs/plans/2026-08-16-arc-ci.md`; it does not change that plan or the conductor's authority.

status: ANALYSIS

## Decision summary

Inventory unit: one independently invocable command or one dynamic suite runner. A runner that discovers many files is one gate here, but its discovery boundary is named. "Runtime" is a measured or explicitly configured wall limit only; where the repository states neither, it is `unknown`. Counts are by matrix row, not by test case: **ACTIONS-READY 9, NEEDS-CACHE 10, NEEDS-SERVICE 8, STAYS-LOCAL 5 (32 total).** (2026-08-16 revision: row 5 reclassified ACTIONS-READY→NEEDS-SERVICE on live-host evidence; row 7 split into 7a/7b, 7b classified NEEDS-SERVICE; row 9 reclassified ACTIONS-READY→NEEDS-CACHE, moved behind S2 — see row notes.)

The first advisory gate is already ruled: collector tests. The **single next candidate after collector-tests is controller tests** (`cd controller && bun test --timeout 20000`). It has the same Bun/JUnit-compatible, no-browser, no-fleet shape, exercises a separate service boundary, and does not make S2 cache readiness a prerequisite for the second useful check.

## Classification key

| Class | Decision rule |
|---|---|
| ACTIONS-READY | Hermetic repository fixture or static check; declared toolchain is sufficient on an unprivileged ARC pod today. |
| NEEDS-CACHE | Logically portable, but pnpm/node_modules, Python environment, browser download, or image/package materialization makes S2 the honest readiness boundary. |
| NEEDS-SERVICE | Requires a designed browser/display, Podman/nft/k3s, loopback service pair, or reachable fleet host. |
| STAYS-LOCAL | Reads, installs, restarts, or proves the owner's live laptop/runtime/state. It remains after Actions becomes verifier. |

## Migration matrix

| # | Gate / exact command | Invoked from | Runtime stated | Needs | Hermetic | Class / reason |
|---:|---|---|---|---|---|---|
| 1 | `cd collector && bun test --timeout 20000` (the S1 workflow may invoke the equivalent package script) | `collector/package.json:6-9`; the current aggregate invokes workspace tests via `tools/ci/workspace-runner.mjs:9,119-127` | Per-test timeout 20s; suite wall runtime unknown | Bun; repository files; no required network/live service found | Yes | **ACTIONS-READY** — ruled S1 seed |
| 2 | `cd controller && bun test --timeout 20000` | `controller/package.json:6-9`; aggregate invocation `tools/ci/workspace-runner.mjs:9,119-127` | Per-test timeout 20s; suite wall runtime unknown | Bun; repository files | Yes | **ACTIONS-READY** — next after collector |
| 3 | `cd spine && bun test --timeout 20000` | `spine/package.json:6-8`; aggregate invocation `tools/ci/workspace-runner.mjs:9,119-127` | Per-test timeout 20s; suite wall runtime unknown | Bun; repository files | Yes | **ACTIONS-READY** |
| 4 | `bun test modules/workstation/claude/hooks/test --reporter=junit --reporter-outfile=<results>/raw/bun-hooks.junit.xml` | `tools/ci/constants.mjs:61-66`; constructed/executed at `tools/ci/run-gate.mjs:326-337` | unknown | Bun; writable results directory | Yes | **ACTIONS-READY** |
| 5 | `bash modules/workstation/claude/tests/containment-invariants.test.sh` and `bash modules/workstation/claude/tests/confine-scope-escape.test.sh` | exact argv registry `tools/ci/constants.mjs:13-28`; loop `tools/ci/run-gate.mjs:305-324` | unknown | Bash, standard Unix tools, fixture filesystem, plus live host state | No — verified 2026-08-16: `containment-invariants.test.sh` reads the real installed `agent.slice`/`build.slice` systemd drop-ins and their live `MemoryHigh`/`MemoryMax` ceiling readback (`declared-build-slice-memoryhigh/memorymax-90-ceiling.conf`), not a fixture; it flaked FAIL→PASS across two consecutive runs on the same host with no code change, tracking transient host slice state | **NEEDS-SERVICE** (reclassified from ACTIONS-READY) — requires the installed systemd slice/drop-in surface an unprivileged ARC pod does not carry; do not migrate until a runner-image equivalent of that slice state exists |
| 6 | `python3 modules/workstation/claude/tests/agent-reaper-enforce.test.py` | `tools/ci/constants.mjs:29-34`; loop `tools/ci/run-gate.mjs:305-324` | unknown | Python 3; `/proc` behavior is mocked/fixture-driven in the test | Yes | **ACTIONS-READY** |
| 7a | Fixture-isolated workstation commands: `bash modules/workstation/claude/tests/{ca-launcher,shim-drift-check,od-worktree-registry,shim-bare-name,sessionstart-context-cap}.test.sh` | exact argv registry `tools/ci/constants.mjs:7-12,35-59`; loop `tools/ci/run-gate.mjs:305-324` | unknown | Bash, Git and standard Unix tools; isolated HOME/temp fixtures | Yes; no live owner paths are required by these registered fixture suites | **ACTIONS-READY** |
| 7b | Session/process/browser-guard-bound workstation commands split out of row 7: `bash modules/workstation/claude/tests/{agent-session-ledger,agent-session-passthrough,human-session-fallback,kill-guard,headless-guard-scope}.test.sh` | exact argv registry `tools/ci/constants.mjs:7-12,35-59`; loop `tools/ci/run-gate.mjs:305-324` | unknown | Real or simulated live agent session/process tree (`agent-session-ledger`, `agent-session-passthrough`, `kill-guard`), the human-session tmux/session-name path (`human-session-fallback`), and an installed headless-browser guard (`headless-guard-scope`) | No — each asserts against live session/process/guard-installation state, not a pure repository fixture | **NEEDS-SERVICE** (split out of row 7's former ACTIONS-READY blanket classification) — confirm per-suite hermeticity individually before migrating any of these; do not assume the row-7 fixture claim covers them |
| 8 | Factory envelope gates: `artifacts_exist`, `files_non_empty`, `json_parses`, `diff_matches_claims`, `verdict_consistent` (Python calls, not shell commands) | definitions `modules/harness/factory/adw_modules/gates.py:27-119`; applied after agent output at `modules/harness/factory/adw_modules/agents.py:260-277` | unknown | Python, candidate filesystem; Git only for deletion claims | Yes relative to the candidate checkout | **ACTIONS-READY** |
| 9 | Docs lane: `gitleaks detect --no-git --source <changed-path> --no-banner --log-level error --exit-code 9`; `<candidate>/node_modules/.bin/markdownlint-cli2 --config <generated-config> <changed-md...>`; built-in frontmatter schema check | constructed by conductor at `modules/workstation/claude/workflows/lib/finish-branch.sh:675-784`; selected at `modules/workstation/claude/workflows/lib/finish-branch.sh:1105-1119` | unknown | gitleaks, installed markdownlint packages, Git candidate | Yes for a candidate tree, but only once dependencies are actually present | **NEEDS-CACHE** (reclassified from ACTIONS-READY) — verified 2026-08-16: `node_modules/.bin/markdownlint-cli2` is NOT reliably present in a fresh/offload worktree (observed absent, causing 11 cascading docs-lane test failures behind one missing-binary gap); the row's own "already installed by the candidate dependency step" assumption does not hold without S2's cache/toolchain-image guarantee — move behind S2 alongside row 10 |
| 10 | `pnpm run typecheck` → `pnpm -r typecheck` | root script `package.json:6-8`; aggregate gate executes it at `tools/ci/run-gate.mjs:285-292`; Factory config `modules/harness/factory/sssf.config.yaml:49-52` | Factory limit 900s; typical runtime unknown | Node 22, pnpm 11.5.2, complete node_modules/@platform-modules resolution | Yes after install | **NEEDS-CACHE** — move after S2 pnpm store/toolchain image |
| 11 | `pnpm -r test` (workspace scripts: web/deck-ui Vitest, collector/controller/spine Bun, buildbox shell runner) | `tools/ci/workspace-runner.mjs:9,119-127`; package expansion is filesystem-derived at `tools/ci/inventory.mjs:48-78` | unknown | Node, pnpm, Bun, node_modules; buildbox subrunner has a separately classified remote member | Mixed: package tests are hermetic; one buildbox member is service-bound | **NEEDS-CACHE** for the portable workspace phase; split row 21 before migration rather than carrying its remote skip semantics |
| 12 | `FT_FROM_HOOK=1 FT_HINTS=1 ft pnpm --filter web test` | owner-specified canonical hook form; underlying script `apps/web/package.json:6-11`; UI gate doctrine `AGENTS.md:109` | unknown | `ft` wrapper, Node/pnpm store, jsdom dependencies | Yes after install | **NEEDS-CACHE** |
| 13 | `FT_FROM_HOOK=1 FT_HINTS=1 ft pnpm --filter web typecheck` | owner-specified canonical hook form; `apps/web/package.json:6-11`; `AGENTS.md:109` | unknown | Node/pnpm store, Astro compiler and package gateway | Yes after install | **NEEDS-CACHE** |
| 14 | `FT_FROM_HOOK=1 FT_HINTS=1 ft pnpm --filter web build` | owner-specified canonical hook form; `apps/web/package.json:6-11`; deploy also builds at `packaging/deploy-local.sh:508-514` | unknown | Node/pnpm store, Astro build, package gateway; writable isolated output | Yes if output is per-run | **NEEDS-CACHE** |
| 15 | `FT_FROM_HOOK=1 FT_HINTS=1 ft pnpm --filter @overdeck/deck-ui test` | owner-specified canonical hook form; `packages/deck-ui/package.json:11-13`; `AGENTS.md:109` | unknown | Node/pnpm store, Vitest/jsdom | Yes after install | **NEEDS-CACHE** |
| 16 | `FT_FROM_HOOK=1 FT_HINTS=1 ft pnpm --filter @overdeck/deck-ui typecheck` | owner-specified canonical hook form; `packages/deck-ui/package.json:11-13`; `AGENTS.md:109` | unknown | Node/pnpm store, TypeScript | Yes after install | **NEEDS-CACHE** |
| 17 | Factory deterministic quality gates: `pnpm run test`; `pnpm run typecheck`; `pnpm --filter web build` | exact config `modules/harness/factory/sssf.config.yaml:39-56`; subprocess construction `modules/harness/factory/adw_modules/quality.py:119-166,288-323` | limits: 1800s / 900s / 900s; typical runtimes unknown | Same pnpm/Bun/Node dependencies as aggregate gates; candidate checkout | Yes after install; commands are config-driven and missing config fails | **NEEDS-CACHE** |
| 18 | Factory producer regression suite: `python3 -m pytest modules/harness/factory/tests/ -q` | mandatory command `modules/harness/CLAUDE.md:42-46` | unknown | Python 3 + pytest environment; tests use mocked transport except bounded process integrations | Yes with an immutable restored venv | **NEEDS-CACHE** — use S2 lockfile-hash venv restore |
| 19 | Full browser phase: `bash scripts/ci-with-test-display.sh pnpm exec playwright test --reporter=list,json` | argv construction `tools/ci/playwright-gate.mjs:3-18`; execution `tools/ci/run-gate.mjs:388-423` | workflow job limit 60m at `.github/workflows/pr-gate.yml:20-21`; phase typical runtime unknown | Chromium, Playwright system libraries, virtual display wrapper; fixture web server from Playwright config | Yes only once runner image/display contract is defined | **NEEDS-SERVICE** |
| 20 | Targeted browser pairs: `e2e-remote --server "<server-cmd>" --wait-port <port> -- <Playwright/client argv...>` | tool contract `modules/workstation/claude/bin/e2e-remote:11-33`; remote-only dispatch `modules/workstation/claude/bin/e2e-remote:99-123,153-163`; registry example `modules/workstation/claude/bin/e2e-remote.card.json:3-10` | server wait default 240s; client runtime unknown | Browser/display plus a colocated loopback dev server; currently requires buildbox registry/local-gate/SSH transport | Pair-hermetic on one host, not transport-hermetic | **NEEDS-SERVICE** — replace host selection with an ARC runner image/service design before retiring e2e-remote |
| 21 | `bash modules/buildbox/test/run-all.sh`; local members via `node tools/ci/buildbox-suite-runner.mjs workspace ...`; `node-runtime.smoke.sh` via SSH | discovery/classification `modules/buildbox/test/run-all.sh:14-31`; execution `modules/buildbox/test/run-all.sh:45-80`; aggregate package entry `modules/buildbox/package.json:3-6` | unknown | Local fixtures need Node/Bash; remote smoke needs reachable registered buildbox + SSH | Mixed | **NEEDS-SERVICE** — split local members to Actions first; retain remote smoke until a service/container equivalence exists |
| 22 | `bash tests/os/run-all.sh` (discovers `tests/os/*.test.sh` and `modules/*/test/*.test.sh`) | discovery and invocation `tests/os/run-all.sh:1-23,39-53`; aggregate call `tools/ci/run-gate.mjs:339-385` | unknown | Broad Linux command surface; individual suites include systemd/podman/network namespace behavior | Mixed; runner is intentionally heterogeneous | **NEEDS-SERVICE** — shard by declared capability rather than migrate the opaque aggregate |
| 23 | `bash modules/sandbox/image/test/deny-gate.test.sh` | command is the executable suite itself; implementation and fake-nft entrypoint path exercised at `modules/sandbox/image/test/deny-gate.test.sh:1-18,42-117` | unknown | Bash, jq, Git; unit path fakes nft | Yes | **ACTIONS-READY** — move the unit gate now; installed image proof remains row 31 |
| 24 | `bash modules/sandbox/registry-gateway/test-gateway.sh` | server/install/tamper/concurrency proof `modules/sandbox/registry-gateway/test-gateway.sh:15-19,40-84` | each concurrent install limited 60s; typical total unknown | loopback Node registry service, npm/pnpm, curl; 50 isolated stores by default | Yes within one pod, but needs service/process and heavy package stores | **NEEDS-SERVICE** |
| 25 | Full slopgate commit scan (exact installed hook command is outside this repository); UI requirement is “slopgate green” | requirement `AGENTS.md:27`; configured installed hook paths `modules/workstation/codex/hooks.json:7,36,45`; measured hook behavior `modules/workstation/codex/docs/2026-07-18-codex-hooks-cpu-refactor.md:20-22` | commit-hook non-commit overhead 113.3ms; edit-hook ineligible 108.2ms and eligible scan capped at 5s; full scan runtime unknown | External `/home/user/Projects/slopgate` checkout/binary, project config/baseline, staged index/session attribution | No; exact full command is **unknown** from this repo | **STAYS-LOCAL** until slopgate publishes a versioned CI CLI contract; commit hook itself protects owner workflow |
| 26 | `bash packaging/test-deploy-local.sh` | fixture invokes deploy script at `packaging/test-deploy-local.sh:351-365`; assertions begin `packaging/test-deploy-local.sh:367-466` | unknown | Bash, Git, fake HOME/PATH/systemctl/curl; writable external temp root | Yes; owner services and network are replaced with test doubles | **ACTIONS-READY** — migrate the contract fixture, not `deploy-local.sh` itself |
| 27 | Installed deploy build/preflight: `pnpm install --prefer-offline --silent`; `pnpm --filter @overdeck/actions-gateway build`; `pnpm --filter web build`; `pnpm --filter web deploy --prod --legacy --prefer-offline <stage>`; `bash packaging/web-deps-verify.sh <release>`; `bash packaging/web-preflight.sh <release> <pages>` | `packaging/deploy-local.sh:366-371,431-452,486-528` | unknown | Deploy clone, owner package cache, release dirs, local collector URL for preflight | No; produces and validates the owner’s installable release | **STAYS-LOCAL** — Actions may pre-verify equivalent builds, but installed release staging remains local |
| 28 | Installed service probes: `node packaging/test-actions-gateway-r0.mjs --endpoint http://127.0.0.1:31401 --config <owner-config>` and `cd collector && OVERDECK_DEPLOY_DIR=<deploy> bun scripts/brief-canary.ts` | `packaging/deploy-local.sh:579-590,630-635` | bounded readiness attempts exist but typical runtime and effective bound values are unknown | Live user systemd services, loopback endpoint, owner config/secret, deployed assets | No | **STAYS-LOCAL** — proves the installed runtime and authenticated owner configuration |
| 29 | Fleet/image proofs: `bash modules/workstation/claude/bin/sandbox-provision --check --all`; `bash modules/sandbox/test/deny-gate-proofs.sh <host>` for each buildbox | deploy parity `packaging/deploy-local.sh:637-648`; SSH sandbox proof `modules/sandbox/test/deny-gate-proofs.sh:1-12,29-46` | unknown | Buildbox registry, SSH keys, reachable fleet, Podman-built images | No | **STAYS-LOCAL** — validates real fleet state, not a candidate tree |
| 30 | Deploy installation/restart chain: `deckctl sync apply ...`, installer scripts, `systemctl --user ...`, optional `deck-sudo` root refreshes, and live shim drift check | install/restart sequence `packaging/deploy-local.sh:531-628`; root/live refreshes `packaging/deploy-local.sh:650-677`; dry-run order oracle `packaging/test-deploy-local.sh:461-466` | unknown | Owner HOME, user systemd, optional root systemd, live services, secrets/config and deploy clone | No | **STAYS-LOCAL** — mutates owner state and is the installed proof Actions must never impersonate |
| 31 | Live sandbox proof family: `bash modules/sandbox/host/test/egress-enforcement.test.sh`; `bash modules/sandbox/test/{agent-cli-proofs,ceiling-proofs,containment-proofs,denial-proofs,deny-gate-proofs,e2e-offload-proof}.sh <host>` | installed egress call `modules/sandbox/host/test/egress-enforcement.test.sh:1-35`; SSH/sandbox-run construction `modules/sandbox/test/agent-cli-proofs.sh:1-18`, `modules/sandbox/test/ceiling-proofs.sh:1-18`, `modules/sandbox/test/containment-proofs.sh:1-18`, `modules/sandbox/test/denial-proofs.sh:1-15`, `modules/sandbox/test/deny-gate-proofs.sh:1-12`, `modules/sandbox/test/e2e-offload-proof.sh:1-29` | egress curl max 20s allowed / 6s denied; CLI version probe 10s each; remaining typical runtimes unknown | Installed sandbox-run, Podman/cgroups/nft egress, outbound network, SSH/rsync, fleet host; e2e proof also needs pnpm and browser pair | No on current transport; each proves a live installed host | **NEEDS-SERVICE** — migrate only after a runner-image/Podman/network-policy service design; retain named-host acceptance until parity |

## Conductor and aggregate construction facts

- The land queue has two scheduling classes, `docs` and fail-closed `full`; malformed or absent class data becomes `full` (`modules/workstation/claude/workflows/lib/finish-branch.sh:1304-1317`), and docs tickets are served before full tickets (`modules/workstation/claude/workflows/lib/finish-branch.sh:1394-1418`). This is scheduling priority, not merge authority delegation.
- A full candidate always runs the touched-package typecheck floor and then the project `testcmd` through `local-gate --remote-only`; typecheck is limited to 600s and the test command to 1800s (`modules/workstation/claude/workflows/lib/finish-branch.sh:130-136,1121-1143`). The exact Overdeck `testcmd` is frozen data in generated `ship.sh`; when sniffed from this root it is `pnpm run typecheck && pnpm run test` because both scripts exist (`modules/workstation/claude/workflows/lib/ship-init.sh:75-84`).
- The current GitHub aggregate installs dependencies and Chromium, then invokes exactly `pnpm run ci:gate` (`.github/workflows/pr-gate.yml:31-53`). `ci:gate` performs workflow-contract and inventory checks, typecheck, workspace tests, required workstation fixtures, hook tests, OS/module discovery, and Playwright (`tools/ci/run-gate.mjs:250-423`). It is an inventory source, not the desired migration unit: moving that monolith first would preserve the substrate coupling ARC is meant to remove.
- Factory agent-phase gates are claim validators, then permissions enforcement (`modules/harness/factory/adw_modules/agents.py:260-305`). Factory quality commands are target-config data, execute from the target root with the operator environment, and record actual durations (`modules/harness/factory/adw_modules/quality.py:78-104,119-166,229-242`). For Overdeck the exact configured commands are rows 10, 17 and 18; for another target they are **unknown** until its resolved `.factory/sssf.config.yaml` is read.

## Recommended migration order

1. **Collector tests (S1).** Smallest already-ratified hermetic advisory check; establishes check identity, cancellation, receipts, and board projection without cache/browser/fleet ambiguity.
2. **Controller tests — the single next candidate.** Same toolchain and isolation profile as collector, so it validates multi-check orchestration before S2 introduces a second variable.
3. **Spine tests and Bun hook tests.** Complete the Bun-only set; prove parallel independent checks and fail-fast/check projection semantics.
4. **Hermetic workstation fixtures, Factory envelope gates, deny-gate unit tests, and the deploy fixture.** They add Bash/Python runners without package-cache or service-container design. This must precede broad OS migration so fixture failures are not confused with missing host capabilities.
5. **S2 cache architecture, then deck-ui test/typecheck.** Deck-ui is the smallest pnpm/jsdom consumer and therefore the cleanest measurement of cold versus restored pnpm state and registry-gateway resolution.
6. **Web test/typecheck, then web build; root typecheck last within this group.** Increasing dependency/build-output breadth makes cache misses and package-gateway faults attributable. Builds use per-run outputs; no shared `dist`.
7. **Factory pytest with immutable venv restore, then Factory configured quality commands.** First prove the Factory machinery independently; only then let it call the newly migrated project checks. Factory remains an orchestrator/consumer, not a second verifier implementation.
8. **Split `modules/buildbox/test/run-all.sh` and `tests/os/run-all.sh` by capability.** Move their local hermetic members; keep SSH/systemd/k3s/Podman members explicit. Do not preserve “not run” as green in Actions.
9. **Playwright with a pinned runner image + display/service contract, then targeted e2e pairs.** Full fixture Playwright precedes dev-server pairs because it proves browser/image/reporting alone before adding server readiness and artifact return.
10. **Sandbox image and registry/egress service proofs.** These require Podman/nft/network-policy or service-pair designs and should follow generic browser/service-container parity, not pioneer it.
11. **Never migrate installed deploy/fleet/owner-state probes.** Actions can supply candidate verdicts consumed by the conductor; `deploy-local.sh`, authenticated loopback smoke, systemd installs/restarts, fleet image parity, deny proofs on named hosts, and slopgate’s owner commit hook remain local evidence.

The dependency is deliberate: deterministic toolchain-only checks establish the Checks API and board contract; S2 then makes dependency-heavy checks stable; service-bound checks follow only after runner-image/cancellation/log parity; owner-state proofs never cross that boundary. Branch protection remains last per the ruling.

This matrix's row-by-row migration order is sequenced WITHIN `docs/plans/2026-08-16-arc-ci.md`'s S1-S4 slices — it orders work inside a slice, it never reorders or substitutes for the slices themselves.

## Known unknowns and follow-ups

- No repository source states typical wall runtime for most rows. Configured timeouts are not presented as typical runtimes.
- The exact full slopgate CLI argv is outside this checkout. The installed hook paths and partial hook overhead are known; a CI migration requires slopgate to expose a versioned, repository-owned invocation first.
- `tests/os/run-all.sh` and the buildbox runner discover files dynamically. Before migration, generate a capability manifest per discovered suite (toolchain / systemd / network namespace / Podman / fleet) and fail inventory drift; do not infer capability from filename.
- `FT_FROM_HOOK=1 FT_HINTS=1 ft ...` is the current laptop/offload wrapper form supplied by the owner. In ARC the underlying pnpm argv is the check contract; `ft` itself is substrate and should not be copied into runner pods.
- `packaging/test-deploy-local.sh` is the portable contract fixture; `packaging/deploy-local.sh` and its installed probes are distinct STAYS-LOCAL rows. A future test must not collapse that boundary.
