<purpose>
Visual proof-of-work verification with structured evidence capture. Creates verification.json
that tracks every outcome with action → evidence → verdict. Produces HTML proof report.

This workflow is adversarial — it assumes things are broken until proven working.
Every claim requires evidence. Every screenshot follows an interaction.

v0.9.0 — **outcome-walker**: iterate `spec.md ## User Outcomes` parent bullets
one at a time, reload ONLY that outcome card into working memory, drive the
stated bullet AND each inferred child, fail-loop on red, advance on green.

OUTPUT FORMAT: verification.json (NOT VERIFICATION.md). JSON is the source of truth.
</purpose>

<context_profile>
@packages/framework/context/installed/verification.md
</context_profile>

<template>
@packages/framework/templates/proof-verification.md
</template>

<process>

<step name="load_config" priority="first">
## 1. Load Config and Context

Read the merged Aperant config for verification preferences (AUDIT-001 — the per-device `verification.open_report` field lives in `.aperant/config.local.json`; the rest in shared `.aperant/config.json`). Use the framework's merged loader so local overrides win:
```bash
node .aperant/deps/node_modules/@aperant/framework/bin/apt-tools.mjs config read . 2>/dev/null || echo "{}"
```
If `apt-tools config read` is unavailable, fall back to overlaying the two files in JS:
```bash
node -e "const fs=require('fs'); const s=(p)=>{try{return JSON.parse(fs.readFileSync(p,'utf-8'))}catch{return {}}}; function merge(a,b){if(a===null||typeof a!=='object'||Array.isArray(a))return b;if(b===null||typeof b!=='object'||Array.isArray(b))return b;const out={...a};for(const k of Object.keys(b))out[k]=(k in a)?merge(a[k],b[k]):b[k];return out}; const merged=merge(s('.aperant/config.json'),s('.aperant/config.local.json')); console.log(JSON.stringify(merged))"
```
Do NOT read `.aperant/config.json` directly — that misses per-device overrides like `verification.open_report` and `multi_model.cross_verification.tools`.

Read project constitution:
```bash
cat AGENTS.md 2>/dev/null || cat CLAUDE.md 2>/dev/null || echo "No constitution"
```

Read spec and plan:
```bash
cat spec.md 2>/dev/null
cat implementation_plan.json 2>/dev/null
```

Generate run ID and create proof directory. v0.8.6 — when the spec
carries `## User Outcomes` with cross-surface tags (e.g.
`[electron+web]`), proof artifacts MUST land under PER-SURFACE
subdirectories so reviewers can tell at a glance which surface failed.
Set up the path template here:

```bash
RUN_ID="$(date +%H%M%S)"
APP_NAME=$(node -e "try{console.log(JSON.parse(require('fs').readFileSync('package.json','utf8')).name)}catch{console.log('app')}" 2>/dev/null)
PROOF_DIR=".aperant/proof/${APP_NAME}/$(date +%Y%m%d)/${RUN_ID}"
# Per-surface screenshot path template — Step 7c populates ${surface}
# from the per-outcome resolver dispatch:
#   ${PROOF_DIR}/${surface}/screenshots/${outcome-id}.png
mkdir -p "${PROOF_DIR}"
```

Sub-directories under `${PROOF_DIR}/${surface}/screenshots/` are
created lazily in Step 7c when the workflow runner takes the first
screenshot for that surface.
</step>

<step name="pre_check">
## 2. Pre-Check + Driver Selection + Outcome Loader

If `implementation_plan.json` exists, verify all subtasks are completed.
If any are pending: report and exit.

### 2a. Load `## User Outcomes` from spec.md (HARD REQUIREMENT — v0.9.0)

`## User Outcomes` is the verify-proof input contract. Parse the
section via `parseUserOutcomes`:

```js
import { parseUserOutcomes } from '.aperant/deps/node_modules/@aperant/framework/src/cli/coverage-check/user-outcomes.mjs'
const parsed = parseUserOutcomes(readFileSync('spec.md', 'utf-8'))
```

If `parsed.body_kind !== 'outcomes'` AND `parsed.body_kind !== 'empty-with-note'`,
**ABORT** with a clear error: `"spec.md missing ## User Outcomes — verify-proof
requires the outcome contract. Run /apt:plan to author it."`

**Do NOT fall back to feature-registry-derived tests.** The legacy
"discover features from the registry" path is removed (A4 escape hatch).
The feature registry is informational only — it does NOT seed test
generation. Every test entry must trace back to a parent or inferred
outcome from `## User Outcomes`.

For each parent outcome, `parsed.outcomes[i]` carries:

- `id` — positional ID (`O1`, `O2`, ...)
- `surface` — closed set `{electron, web, cli, api, electron+web}`
- `text` — outcome prose (the parent bullet)
- `inferred` — string[] of indented child bullets (the planning LLM's
  derived sub-checks; empty `[]` when the author wrote none)
- `requires` — optional dependency on a prior outcome's ID

### 2b. Resolve the verify-proof driver

The workflow is **runtime-agnostic** — it refers to IDL verbs
(`driver.click(target)`, `driver.screenshot(path)`), never to concrete
MCP tool names. The driver layer (`packages/framework/drivers/*/`)
maps verbs to transports. Auto-detect the runtime and select a driver:

```bash
# Detect the project's runtime capabilities + select a driver
node .aperant/deps/node_modules/@aperant/framework/bin/apt-tools.mjs detect-runtime .
# → {status:"ok", runtime:{is_electron, is_tauri, is_nextjs, ...}, selected_driver_id:"..."}
```

The resolver picks the highest-scoring driver per ID-02 (capability_coverage
> compatibility > stability > priority > locality). Adopters override
auto-detect via `.aperant/config.json:verification.runtimes[]`:

```json
{ "verification": { "runtimes": ["electron", "cli"] } }
```

If no driver satisfies a REQUIRED capability, the resolver throws
`unsatisfied_required_capability` — fail-closed, NOT a silent
`blocked_by_platform` skip (per ID-02).

**v0.8.6 — per-outcome dispatch.** The workflow runner picks a driver
PER OUTCOME SURFACE via `resolveDriversForOutcomes()` instead of a single
per-run driver pick. Each outcome's `[surface]` tag maps to the
runtime-capability flag a driver must carry; `[electron+web]`
outcomes dispatch to TWO drivers (one per surface) sharing the same
`outcome_id`. The legacy `apt-tools detect-runtime .` call above
still works for the per-run "what driver would be picked?" preview
— it answers the OLD question. For the new per-outcome question,
the workflow runner imports `resolveDriversForOutcomes` directly
from the resolver module in Step 7c.

### 2c. Probe optional evidence-capture tools

```bash
# CDP video capture (optional)
apt-proof-video --version  # if available, CDP video is on
```

Record the selected driver + auxiliary tools in verification.json:
`meta.selected_driver: {driverId, version, manifest_sha256, trust_tier,
sandbox_profile}` and `meta.fallback_attempts: [{driver_id, reason}]`
(see ST11's schema extension in `templates/proof-verification.md`).
</step>

<step name="load_deferred_tools">
## 2.5. Load Deferred Driver Tools (Claude Code / OpenCode hosts)

> **Precondition.** When `--parallel` is requested, the sandbox gate at apt-verify-proof/SKILL.md Step 2c runs BEFORE this step. An unverified sandbox exits with `{status:"error", code:"E_SANDBOX_UNVERIFIED", verdict:"needs_human", reason:"sandbox_unverified"}` without loading any MCP driver tools or entering the outcome-walker loop. See `docs/verify-proof-sandbox-patterns.md` for setup.

On Claude Code and OpenCode hosts, the MCP driver tools listed in
`drivers/<surface>/manifest.json :: transport.mcp_server_id` are
**deferred** — they exist in the host's MCP server registry but their
schemas are NOT loaded by default. Before driving ANY UI-surface outcome
you MUST load them via `ToolSearch(query='select:...')`.

**Worked example.** For the bundled `browser` driver (whose manifest
field `transport.mcp_server_id = "puppeteer"` exposes
`mcp__puppeteer__*` tools):

```
ToolSearch(query='select:mcp__puppeteer__browser_navigate,mcp__puppeteer__browser_click,mcp__puppeteer__browser_screenshot,mcp__puppeteer__browser_type')
```

For the bundled `electron` driver (whose manifest field
`transport.mcp_server_id = "electron"` exposes `mcp__electron__*` tools):

```
ToolSearch(query='select:mcp__electron__app_launch,mcp__electron__app_click,mcp__electron__app_screenshot,mcp__electron__app_type')
```

After the `ToolSearch` call, list the now-loaded tool names back to
confirm they appear in your tool surface. If any required driver tool
fails to load, emit `result: "needs_human_with_transport"` on every
outcome targeting that surface and proceed — do NOT fall back to
file-evidence (the legacy "commit hash as proof" pattern is rejected
by the proof_gate).

The manifest field is `transport.mcp_server_id` (and
`transport.skill_id` where applicable). The names
`mcp_transport.tool_prefix` and `transport.tool_prefix` are NOT real
manifest fields — do not search for them.

This step is a no-op on host CLIs whose entire MCP surface is loaded
eagerly (e.g. some Codex builds). Run it unconditionally; if the
selected tools are already in the surface, `ToolSearch` is idempotent.
</step>

<step name="automated_checks">
## 3. Run Automated Checks (HARD STOP gate — no "enforced at commit" dodge)

Run each of `pnpm lint`, `pnpm test`, `pnpm typecheck` (or the
configured equivalents) IN THIS RUN and capture the full terminal output.

**Closed set for status.** Each check's `status` MUST be one of
`{pass, fail}`. The legacy string `"enforced_at_commit"` is no longer
valid — if you find yourself about to write it, STOP. You must actually
execute the check in this run.

```bash
# Tests
pnpm test 2>&1 | tee /tmp/proof-tests.log
```

```bash
# Type check
pnpm typecheck 2>&1 | tee /tmp/proof-typecheck.log
```

```bash
# Lint
pnpm lint 2>&1 | tee /tmp/proof-lint.log
```

Record results in `verification.json :: automated_checks` with full
output. Each entry shape:

```json
{
  "automated_checks": {
    "tests":     { "status": "pass | fail", "output": "...", "count": 42 },
    "typecheck": { "status": "pass | fail", "output": "..." },
    "lint":      { "status": "pass | fail", "output": "..." }
  }
}
```

**Hard STOP gate.** If any of the three checks has a `status` outside
`{pass, fail}` (e.g. `enforced_at_commit`, `skipped`, `unknown`), the
run aborts immediately with `automation_gate: fail` and overall
verdict `rejected`. The renderer / mapper rejects any input
verification.json carrying a non-conforming status.

**Verdict roll-up.** `automation_gate = pass` iff ALL THREE of tests,
typecheck, lint are `pass`. Any single `fail` → `automation_gate: fail`
→ overall verdict `rejected` regardless of `proof_gate`.
</step>

<step name="execute_outcomes">
## 4. Execute Outcomes (outcome-walker loop)

**This step replaces the legacy "Deep Feature Discovery" + "Generate
Tests" + "Execute Tests" sequence.** The workflow runner iterates the
parent outcomes from Step 2a (`parsed.outcomes`) ONE AT A TIME, in
document order. For each parent outcome, drive the stated bullet AND
each inferred child, capture surface-appropriate evidence per child,
fix-loop on red, advance on green.

### 4a. Reload ONLY the current outcome card

Before driving outcome `Oi`, reload ONLY that outcome's card into
working memory:

```
const outcome = parsed.outcomes[i]
// outcome = { id, surface, text, inferred[], requires? }
```

Do NOT keep the prior outcome's evidence, screenshots, or driver state
in active memory — this defends against context bloat on long runs
(30+ outcomes). Persist progress to `verification.json :: tests[]`
between outcomes; that file is the durable source of truth.

### 4b. Drive the stated bullet (IDL-verb dispatch, MUST-invoke directives)

Use the per-outcome driver from `resolveDriversForOutcomes()`:

```js
import { resolveDriversForOutcomes } from '.aperant/deps/node_modules/@aperant/framework/src/cli/verify-proof/resolver.mjs'

const entries = resolveDriversForOutcomes({
  drivers, runtime,
  outcomes: parsed.outcomes,
})
// → [{outcome_id: "O1", surface: "electron", driver: {...}}, ...]
// For [electron+web] outcomes, two entries share outcome_id.
```

For each `entry` whose `outcome_id` matches `outcome.id`:

1. Compose an action sequence from `outcome.text` (the parent prose
   describes the user-facing capability — translate into IDL verbs):
   - If it says "type X" → `driver.type({ target, text: "X" })`
   - If it says "click X" → `driver.click({ target })`
   - If it says "navigate to X" → `driver.navigate({ url: "X" })`
   - If it says "press key X" → `driver.key({ key: "X" })`
2. Invoke IDL verbs on `entry.driver`.
3. Capture surface-appropriate evidence per Step 4d.
4. Record the test entry in `verification.json :: tests[]` with
   `outcome_id = outcome.id` and `surface = entry.surface`.

**Transport dispatch directive (MUST honor — TD-08).** When `driver.transport.skill_id` is set AND the host CLI exposes the Skill tool AND the outcome surface is `web`, the workflow runner MUST invoke `Skill({skill: driver.transport.skill_id, args: ...})` (passing the IDL verb args) and MUST record `transport: "skill"` on the resulting test entry in verification.json. When the host CLI does NOT expose the Skill tool, fall back to the MCP transport via the existing puppeteer MCP server and record `transport: "mcp"`. The driver's `chooseTransport({has_skill_tool})` helper computes the choice; the workflow runner is the site that knows `has_skill_tool`. This directive is LLM-honored (the v1 verify-proof runner is the host LLM following this workflow as prose); a text-level grep guard (TD-08) pins the `MUST` language so future edits cannot silently soften it to `SHOULD`.

### 4c. Drive each inferred child

For each `child` in `outcome.inferred`:

1. Compose an action sequence from the child prose (these are the
   sub-checks the planning LLM derived from the parent — e.g. "X-button
   closes", "outside-click closes", "Esc closes + returns focus").
2. Invoke IDL verbs.
3. Capture evidence per Step 4d.
4. Record a SEPARATE test entry in `verification.json :: tests[]`
   carrying:
   - `outcome_id` = `outcome.id` (same as parent)
   - `inferred_index` = index of the child in `outcome.inferred[]`
   - `surface` = `entry.surface`
   - the standard `action / expected / actual / evidence / result` fields.

### 4d. Capture surface-appropriate evidence (fail-closed)

Surface evidence requirements:

| Surface | Acceptable evidence |
|---|---|
| `electron`, `web`, `electron+web` | Screenshot from `driver.screenshot()` under `${PROOF_DIR}/${surface}/screenshots/...` |
| `cli` | Captured command output (stdout/stderr) under `${PROOF_DIR}/cli/output/...` |
| `api` | HTTP request/response capture under `${PROOF_DIR}/api/captures/...` |

For UI-surface outcomes, take a screenshot AFTER each action completes.
Screenshots land under PER-SURFACE subdirectories — the per-outcome
driver returned by `resolveDriversForOutcomes()` carries the surface:

```js
// Pick a driver per outcome surface (NOT per run). `entries[]` is
// the resolver's per-(outcome, surface) dispatch — for an
// [electron+web] outcome you get TWO entries sharing outcome_id
// and you loop both, capturing one screenshot per surface.
for (const entry of entries) {
  await entry.driver.screenshot({
    target: { kind: "css", value: "body" },
    output_path: `${PROOF_DIR}/${entry.surface}/screenshots/${outcome.id}.png`
  })
}
```

Single-surface outcomes (`[electron]`, `[web]`, `[cli]`, `[api]`)
produce one screenshot under `${PROOF_DIR}/${surface}/screenshots/`;
cross-surface (`[electron+web]`) produces two — one per surface tree —
with both test entries in `verification.json :: tests[]` carrying the
same `outcome_id` but distinct `surface` and `evidence` fields.

Example test entry shape (electron surface, MCP transport):

```json
{
  "id": "TEST-01",
  "outcome_id": "O3",
  "surface": "electron",
  "transport": "mcp",
  "action": "Press Cmd+T",
  "expected": "Grid reflows to 2+1 layout",
  "evidence": "electron/screenshots/O3.png",
  "result": "pass"
}
```

**`file-evidence: <commit-hash>` is NEVER sufficient.** The proof_gate
rejects that pattern for ALL surfaces — UI surfaces require screenshots,
CLI requires actual command output, API requires actual HTTP traces.
A commit hash is not proof a feature works.

**If a UI-surface outcome cannot capture screenshot evidence because
the host lacks the MCP driver transport (deferred tools didn't load,
puppeteer/electron-mcp-server unavailable):** write
`result: "needs_human_with_transport"` on that test entry, include
a `to_unblock` field explaining what the human needs to install /
load, and proceed. Do NOT fall back to file-evidence.

### 4e. Analyze evidence (adversarial)

For each evidence artifact, write the `analysis` field with what you
**see**, not what you **expect to see**. Be adversarial:

- UI: Are there rendering artifacts? Stray characters? Broken layout?
  Does the actual screenshot match the outcome's stated expectation?
- CLI: Did the command actually emit the expected output, or just exit 0?
- API: Did the response payload match the expected shape, or just 200?

"The terminal shows a cursor" is NOT analysis. "The terminal shows a
blinking cursor at position 0,0 with no shell prompt — the PTY session
is not connected" IS analysis.

### 4f. Fail-loop on red, advance on green

After processing the parent + all inferred children for outcome `Oi`:

- If ALL test entries for `Oi` have `result ∈ {pass}` → advance to `Oi+1`.
- If ANY test entry has `result: "fail"` → fix the code, RE-RUN ONLY
  THE FAILING ENTRIES for `Oi`, capture fresh evidence, write back to
  `verification.json`. Loop up to `verification.max_iterations` times
  (default 3 from config). After the iteration cap, leave `Oi` with
  `result: "fail"` on the unresolved entries and continue — overall
  verdict will be `rejected`.
- If ANY test entry has `result: "needs_human_with_transport"` →
  advance to `Oi+1` (the human will install the transport and re-run).
  Overall verdict will be `needs_human`.

### 4g. Write verification.json after each outcome

Persist `verification.json :: tests[]` to disk after EVERY outcome
completes (success, fail, or needs_human_with_transport). This ensures
progress survives session interruption — `parsed.outcomes[i].id`
becomes the resume point.

### 4h. Skip-reason taxonomy (legacy compatibility)

When an outcome cannot be driven because the selected driver lacks an
**OPTIONAL** IDL verb (e.g. `assert_network` not implemented), record
the test entry with `result: "skip"` and `reason: "unsatisfied_optional_capability"`.
This is the deliberate, loud skip path — log it in `skipped[]` so
coverage loss is auditable.

> **Legacy alias.** `blocked_by_platform` (pre-driver-architecture) is
> read by the schema reader as an alias for `unsatisfied_optional_capability`
> and emits a deprecation warning. New writes MUST use the new reason.

> **Fail-closed, not a skip:** if a REQUIRED IDL capability has no
> implementing driver in the resolver chain, the resolver throws
> `unsatisfied_required_capability` (per ID-02). This aborts the run —
> it does NOT silently land in `skipped[]`.

**Gate:** Do not proceed to Step 5 until every parent outcome has been
processed (success, fail, or needs_human_with_transport) AND
`verification.json :: tests[]` carries entries for every parent +
inferred-child combination.
</step>

<step name="start_video">
## 5. Start Video Capture (optional)

If CDP capture is available, start recording BEFORE executing outcomes:

```bash
apt-proof-video capture --port 9222 --duration {estimated_total_seconds} --fps 2 --output ${PROOF_DIR}
```

Run this in the background. The video will capture your ACTUAL outcome
walker session — the same interactions that produce screenshots. This
ensures video and screenshots are from the SAME session.

**Important:** Estimate duration generously. Better to have extra video
than to miss outcomes.
- ~15 seconds per outcome (parent + inferred children) is a reasonable estimate
- Add 30 seconds for navigation overhead

**Note:** Captured frames are auto-stitched into an MP4 file by the tool.

If CDP is NOT available, note it and proceed without video.

(In practice this step is interleaved with Step 4 — kick off the
capture before the first outcome and stop it after the last.)
</step>

<step name="score_dimensions">
## 6. Score QA Dimensions (informational rationale)

Based on the evidence collected (not assumed), score each dimension.
Write scores to verification.json `dimensions` object.

**v0.9.0 — dimensions are INFORMATIONAL rationale only.** The verdict
no longer derives from per-dimension minimums; it derives from the
two-gate envelope (Step 7).

```json
{
  "dimensions": {
    "completeness": { "score": 7, "rationale": "All 12 outcomes processed; 2 needed transport, 10 driven." },
    "correctness":  { "score": 8, "rationale": "All driven outcomes matched expected; no regressions." },
    "code_quality": { "score": 9, "rationale": "Zero lint errors, zero type errors." },
    "test_coverage":{ "score": 6, "rationale": "Unit tests cover core logic; integration tests for the new outcome walker live in the framework package." }
  }
}
```

**Scoring rules** (all 1-10):

- **Completeness**: count outcomes processed vs. authored (`tests` per
  parent + inferred / total expected).
- **Correctness**: per-outcome verdict roll-up — any blocker = max 3,
  any major = max 5.
- **Code Quality**: lint + typecheck results.
- **Test Coverage**: unit + integration tests landed for the feature.

Dimensions surface in the HTML report as rationale chips alongside the
two gate badges. They do NOT change the verdict.
</step>

<step name="derive_verdict">
## 7. Derive Two-Gate Verdict

The verdict is the closed set `{approved, rejected, needs_human}`.
**`approved_with_notes` is DELETED — do not emit it.**

### 7a. Compute `automation_gate`

```
automation_gate = (
  automated_checks.tests.status === 'pass' AND
  automated_checks.typecheck.status === 'pass' AND
  automated_checks.lint.status === 'pass'
) ? 'pass' : 'fail'
```

### 7b. Compute `proof_gate`

For each test entry in `verification.json :: tests[]`:

- UI-surface (`electron`, `web`, `electron+web`) with
  `evidence` matching `^file-evidence:` → that test's effective result
  is `needs_human_with_transport` regardless of the written `result`.
- Any test whose effective result is `fail` → contributes to
  `proof_gate: fail`.
- Any test whose effective result is `needs_human_with_transport` →
  contributes to overall verdict `needs_human` (does NOT fail `proof_gate`
  on its own — the gate "passes" with the caveat that a human needs to
  install the transport).

```
proof_gate = (
  no test has effective result 'fail' AND
  every outcome has at least one evidence entry per surface
) ? 'pass' : 'fail'
```

### 7c. Derive overall verdict

```
if (automation_gate === 'fail') return 'rejected'
if (any test has effective result 'needs_human_with_transport') return 'needs_human'
if (proof_gate === 'fail') return 'rejected'
return 'approved'
```

Write `verification.json :: meta.verdict` and `verification.json :: gates`:

```json
{
  "meta": { "verdict": "approved | rejected | needs_human" },
  "gates": {
    "automation_gate": "pass | fail",
    "proof_gate": "pass | fail"
  }
}
```
</step>

<step name="multi_model" optional="true">
## 8. Multi-Model Verification (if enabled in config)

Only run if `.aperant/config.json` has `multi_model.enabled: true`.

For details on how to invoke each model, read `references/multi-model-verification.md`.
For setup instructions, read `references/multi-model-setup.md`.

Record each model's verdict in verification.json `model_verdicts` array.
</step>

<step name="generate_report">
## 9. Generate HTML Proof Report

Use the `apt-proof-video report` command to generate the HTML report from verification.json:

```bash
apt-proof-video report --proof-dir "${PROOF_DIR}" --title "{descriptive title}"
```

The report command:
1. Reads verification.json as the primary data source
2. Recursively scans for screenshots in subdirectories
3. Generates one card per `outcome_id` with stated prose + inferred
   children + per-child evidence
4. Renders two gate badges (`automation_gate`, `proof_gate`) plus
   the overall verdict badge in the header
5. Shows automated checks with expandable output
6. Shows QA dimension rationale chips
7. Shows gaps with severity badges
8. Embeds video player (frames are auto-stitched to MP4)

Also create convenience symlink inside .aperant/ (NOT at project root):
```bash
ln -sf "${PROOF_DIR}/report.html" .aperant/proof-report.html
```
</step>

<step name="open_report">
## 10. Open Report

Read `verification.open_report` from the MERGED config (per-device override lives in `.aperant/config.local.json` per AUDIT-001; do not read raw `.aperant/config.json`). If true (default):
```bash
open "${PROOF_DIR}/report.html"
```
</step>

<step name="write_artifacts">
## 11. Write Artifacts

Write the following files:
- `${PROOF_DIR}/verification.json` — the structured verification record (source of truth)
- `${PROOF_DIR}/report.html` — self-contained HTML report (generated by apt-proof-video)
- `${PROOF_DIR}/report.md` — markdown summary for PRs (generated by apt-proof-video)
- `${PROOF_DIR}/qa_signoff.json` — QA verdict (inside proof dir, not project root)

**Do NOT write VERIFICATION.md.** The JSON file is the only source of truth.

Commit:
```bash
apt-tools commit "qa: proof verification {verdict}" --files ${PROOF_DIR}/verification.json ${PROOF_DIR}/report.html ${PROOF_DIR}/report.md ${PROOF_DIR}/qa_signoff.json
```
</step>

<step name="report_output">
## 12. Final Output

```
Proof Verification: {VERDICT}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Outcome: {one-line summary}

Gates:
  automation_gate: {pass/fail}
  proof_gate:      {pass/fail}

Checks:
  Tests:     {pass/fail} ({count} tests)
  Types:     {pass/fail}
  Lint:      {pass/fail}

Outcomes:
  Total:     {n} parent outcomes (+ {m} inferred children)
  Approved:  {n}
  Rejected:  {n}
  Needs Human: {n} (transport unavailable)

Proof: {PROOF_DIR}/
  verification.json  (structured outcome record)
  report.html        (opened in browser)
  report.md          (for PR embedding)

{If rejected:} Fix gaps and re-run /apt:verify-proof
{If needs_human:} Install the missing driver transports and re-run
{If approved:} Next: /apt:review (optional code review)
```
</step>

</process>

<gates>
## Workflow Gates

These gates MUST be respected — do not skip them:

1. **Pre-check gate** (Step 2): All subtasks must be completed before verification starts.
2. **User Outcomes gate** (Step 2a): spec.md MUST carry `## User Outcomes` with `body_kind ∈ {outcomes, empty-with-note}`. No fallback to feature-registry-derived tests.
3. **Deferred-tool-load gate** (Step 2.5): On Claude Code / OpenCode hosts, MCP driver tools listed under `drivers/<surface>/manifest.json :: transport.mcp_server_id` MUST be loaded via `ToolSearch(query='select:...')` BEFORE driving any UI-surface outcome.
4. **Automation gate** (Step 3): `automated_checks.{tests,typecheck,lint}.status ∈ {pass, fail}` (closed set). No `"enforced_at_commit"`. Any single `fail` → `automation_gate: fail`.
5. **Outcome-walker gate** (Step 4): every parent outcome AND every inferred child has its own test entry in `verification.json :: tests[]` with evidence appropriate to its surface OR `result: "needs_human_with_transport"`. File-evidence (`"file-evidence: ..."`) is NEVER sufficient for any surface.
6. **Proof gate** (Step 7b): every outcome has surface-appropriate evidence. UI outcomes carrying `evidence: "file-evidence: ..."` are rejected as `needs_human_with_transport`.
7. **Verdict gate** (Step 7c): the verdict is exactly one of `{approved, rejected, needs_human}`. The legacy `approved_with_notes` middle band is DELETED — emitting it is a hard error.
8. **Output format gate** (Step 11): Write verification.json, NEVER VERIFICATION.md.
</gates>
</process>
</output>
