# QA

> fomo-qa

- Skill: `atomchung/qa` (Agent Skill, multi-file: 14 files)
- Install (CLI): `npx skillmds@latest add atomchung/qa`
- Raw SKILL.md: https://api.skillmd.com/api/skills/atomchung/qa/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: atomchung (https://skillmd.com/u/atomchung)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/atomchung/qa

---


# fomo-qa

This makes "prepare a clean, consistent fomo-kernel dogfood environment" a procedure. **It is a QA tool for development and maintenance**, answering "I changed the engine — walking through as a real user would, is the experience right?", and it guarantees every run tests **the same thing**: the latest `origin/main`.

**This is the mandatory standard path for every fomo-kernel dogfood (v1, fixed 2026-07-20)** — every dogfood starts here. Do not improvise an environment on the spot.

**Cross-client contract source (since 2026-07-21)**: `docs/qa-runbook.md` in the `kol_collector/fomo-kernel` repository (PR #275) defines what counts as a compliant QA run — **seven** gates (version gate / isolated root / receipt throughout / verdict+verify / archived manifest / privacy lint / **findings disposition**). A session missing any of them is not a QA run and its conclusions may not be cited. The seventh was added on 2026-07-27 (#417): a session passing the first six while leaving behind no replayable asset is exactly what this loop actually looked like for a year. That `docs/qa-runbook.md`, together with the repo-root `AGENTS.md`, **must stay independent of this skill and of any personal registry** — fomo-kernel is a public product a stranger clones on any machine, and cannot assume they have this skill. When the two disagree, the runbook wins.

**What this skill is**: the runbook's seven gates automated into a repeatable procedure plus the `qa_env.sh` tool, for ting's own maintenance of fomo-kernel. Since 2026-07-21 it is reachable through `ai-harness`'s discovery registry, so Claude, Codex and Antigravity all call the same canonical content under the same name `fomo-qa` (see `ai-harness/inventory/fomo-qa.json`). **It is not for anyone else** — an external user has no such skill and needs none; they follow the public `docs/qa-runbook.md` above.

> Why it exists: a 2026-07-19 audit found 17 of 18 worktrees behind main (the worst by 28 commits). Dogfood had been running on development worktrees each pinned to its own base — testing some past slice of the product, with no way to learn afterwards which slice. "It runs" is not "it tested the latest version". This skill blocks that mechanically (issue #250).

> **Its place in the eval system**: this is also the execution procedure that `docs/eval-design.md`'s evidence level **4 (human review)** and observation surface 1 (content-free interaction receipts) had always lacked. A walkthrough ends by producing a `ux_receipt` owner verdict, turning the layer eval currently marks "pending owner dogfood" into a machine-readable annotation that accumulates. **Since 2026-07-27 it produces one more thing**: step 6 converts every miss into a replayable episode under `evals/episodes/`, so a dogfood produces a permanent regression asset rather than a one-off observation — that is what "what we get is stable enough to keep" means.

## When to use it

- The user says `/fomo-qa`, "dogfood fomo-kernel", "跑一次 fomo QA", "走一次復盤驗收", "準備乾淨的測試環境"
- After changing the engine, `SKILL.md`, or card rendering, to judge the experience from the user's side
- To confirm a change really runs on the **latest** version and that the card really comes out

**Not** for reviewing a real user's trades — that is the product skill `fomo-kernel`. This skill only makes the acceptance environment clean and consistent; the walkthrough itself still follows the product's `SKILL.md`.

## Coverage (v1, fixed — do not claim beyond it)

This round verifies **L1: environment consistency + engine CLI contract + agent walkthrough behavior**. Completing `/fomo-qa` means "the engine and agent behavior were verified on the latest version". It does **not** mean "the user's experience was verified on every client". The following are **known follow-ups, out of this procedure**; do not claim the experience is green after a run (that is precisely the false-pass trap in [#230](https://github.com/atomchung/fomo-kernel/issues/230)):

- **L2 card visuals** (next round): the card HTML is never actually rendered in a browser and compared by screenshot (today even `test_card_html.py` makes string assertions and takes zero screenshots). Visual consistency, layout and dark mode can only be eyeballed.
- **L3 interaction delivery** (partly an inherent ceiling): "the option buttons really appeared and the user could really click them" is a client-layer fact (Claude native options vs typing by hand in Codex) and cannot be verified headlessly. Here we verify only the contract layer — the card text was pasted into the conversation, the questions were presented — which `tools/ux_receipt.py` can carry. The native-interaction half on Codex is #230's ceiling and rests on the owner's manual verdict.
- **A general HTML interaction document + the fastest plain-text completion experience**: next round.

## Hard isolation guardrails (read first, non-negotiable)

1. **Never touch the real records**: `~/Side_project/investment_note/` holds ting's real investment records. Only the "real trades" data source reads **one** CSV there, read-only; nothing else is read or written.
2. **Coach state is isolated to a dogfood-only root, and the real one is not reachable**: dogfood always uses a separate `~/.trade-coach-dogfood`, fully apart from the `~/.trade-coach` you use with the real product. Clear it with `qa_env.sh reset` (which backs up first and fail-closed refuses to touch the real root or investment_note). The real `~/.trade-coach` is managed only by `reset-fomo-coach.sh`; the dogfood procedure never touches it. Never hand-write an `rm` against any coach root. **`TRADE_COACH_HOME` routes writers only** — it never stopped anything reading the real root, and in #557 an agent that judged the isolated root unfamiliar and empty read the real ledger on its own initiative. Step 0's `isolate` closes that path by replacing `HOME`, and is a gate rather than a request. A dogfood root that looks empty is a fresh dogfood root; never go looking for a fuller one, and never compose an absolute path to the real one — that is the one hole this lane cannot close.
3. **Work only in the dogfood worktree**: every engine command runs inside the detached worktree created by `qa_env.sh up`. `qa_env.sh` is itself fail-closed and only operates on a worktree whose path contains `dogfood`, so a slip cannot discard another session's uncommitted work.
4. **Do not change product code**: QA reads, it does not edit. If the walkthrough finds a bug, write it down and open an issue; do not fix it in the dogfood worktree (it is detached and exists to be tested, not developed).
5. **Public text passes the privacy lint first (bought by the #274 incident)**: the repository is public, and real tickers, specific amounts, or `TICKER#date#seq` position ids **must never** appear in an issue, PR, comment or commit message — text channels count, not just files. If this QA session used real trade data, run every draft destined for GitHub through the lint first (from `skills/fomo-kernel/` inside the dogfood worktree):

   ```bash
   python3 tools/privacy_lint.py --against ~/Side_project/investment_note/trades/fomo/trades.csv /tmp/draft.md
   ```

   Only exit 0 may be posted. On a hit, rewrite as a de-identified description ("N individual stocks", "concentration is high") and re-scan until clean. The output is masked, so the lint result itself is safe to show. Real values stay local (memory, local notes).

`qa_env.sh` lives in this skill's directory. Every `qa_env.sh` path below is written for Claude (`~/.claude/skills/fomo-qa/qa_env.sh`); on Codex use `~/.agents/skills/fomo-qa/qa_env.sh`, and on Antigravity `~/.gemini/config/skills/fomo-qa/qa_env.sh`. All three are symlinks to the same canonical `qa_env.sh` with identical content — pure bash, independent of the client calling it.

**Cross-client execution gaps** (being in the discovery registry only guarantees the skill is found, not that every step runs correctly; see the `ai-harness` task record):
- `qa_env.sh`, `tools/ux_receipt.py`, `docs/qa-runbook.md`'s seven gates, and the Step 0–6 skeleton run as-is on all three clients, with no rewriting.
- If Step 4 presents questions through Claude's native option tool (for example `AskUserQuestion`), that is a Claude-only capability. Codex and Antigravity have no equivalent and must degrade to fixed-format plain-text options, recorded in `ux_receipt.py` as `plain_text` mode, not `native_options`.
- The rendering-pipeline test mentioned in walkthrough rule 1's "try the widget once" (for example some Artifact-style publishing tool on Claude) is a Claude-only MCP tool. Codex and Antigravity must test with whatever equivalent rendering they have, or — having none — record `widget_attempt_failed` and degrade to Markdown. Do not copy the Claude-side tool name.
- `qa_env.sh`'s assumptions about the current working directory and worktree have not been tested against Codex's or Antigravity's own working-directory models. On a first run there, use `status` (read-only) to confirm the behavior matches expectations before going further.

## The fixed procedure

### Step 0 — Isolate this shell, then the version gate (read-only)

`qa_env.sh` **refuses every command** until the shell it runs in has the account's own coach root out of reach (#557), so this line comes before everything else — including `status` — and has to be repeated in every later shell of the campaign:

```bash
eval "$(~/.claude/skills/fomo-qa/qa_env.sh isolate)"
```

It exports the dogfood `TRADE_COACH_HOME` **and** replaces `HOME` with a throwaway directory, so that `~/.trade-coach` — the path `review.py`, `coach.py`, `tools/ux_receipt.py` and any improvised shell command all compose by default — names nothing. Every value in the block is resolved against the account's real home *before* the override, which is why the repo, the dogfood worktree, the receipt archive, `git`'s configuration and Python's user-installed packages keep working after it. Export any `FOMO_DOGFOOD_*` overrides for a concurrent session **before** this line, while `$HOME` is still the account's own.

It is a bounded guarantee, and reporting it as more than that is the failure it was written against: an absolute path typed on purpose still reaches the real root, and only running the campaign in a container would change that. Do not type one.

```bash
~/.claude/skills/fomo-qa/qa_env.sh status
```

At a glance: the latest `origin/main` sha, how far behind the dogfood worktree is, and whether the **dogfood coach state** (the isolated root, not the real `~/.trade-coach`) is a clean new user. **If it is behind, do not go on** — update via Step 1 first. Report `main@<sha>` to the user explicitly: that is the version this QA tested.

`status` also reports one extra line, **this skill's own freshness** — checking the checkout actually running `qa_env.sh` (wherever the symlink points, which is not necessarily the dogfood worktree) against `origin/main`. It is report-only: it never fetches and never blocks `status`, and when behind it prints a copy-pasteable fix.

### Step 1 — Clean worktree (pinned to the latest main)

```bash
~/.claude/skills/fomo-qa/qa_env.sh up
```

Creates (or refreshes) `~/Side_project/kol_collector/fomo-kernel-dogfood` at `--detach origin/main`. This worktree is dedicated to QA and is never used for development. The working directory for every later command:

```bash
cd ~/Side_project/kol_collector/fomo-kernel-dogfood/skills/fomo-kernel
```

### Step 2 — Open the campaign (once per conversation, not once per route)

Steps 0–3 are the **campaign** setup: one worktree, one isolated root, one client/model/effort identity, one acceptance campaign. They happen **once**. Everything after them is a **route run** — one `first_review`, one `refresh`, one `weekly_review` — and a conversation may contain several, each with its own receipt and its own archived `run_id`.

Three identities that are deliberately not one-to-one (#544):

| | What it is | How many |
|---|---|---|
| **Campaign** | this conversation, this worktree, this isolated root | one |
| **Route run** | one product command lifecycle: `prepare → preview → finalize`, or a `refresh` | as many as the session walks |
| **Receipt** | one route-specific, append-only evidence trace | exactly one per route run |

**Never merge route runs into one receipt.** A `first_review` owes two cards and a cash anchor, a `refresh` owes a card-free change surface and no cards at all — one mixed trace could satisfy neither verifier. The reusable unit is the campaign, not the receipt.

**Step 0's `isolate` already routed the whole toolchain into the dogfood-only coach root** — `review.py`, `coach.py` and `tools/ux_receipt.py` **all three** honor `TRADE_COACH_HOME` (ux_receipt since the #269 fix, merged in PR #275), so that one export keeps `prepare`/`preview`/`finalize`/`data-status` and the receipt consistent throughout. If this is a new shell, re-run it before anything else:

```bash
eval "$(~/.claude/skills/fomo-qa/qa_env.sh isolate)"
```

- **Simulate a brand-new user** (the default; runs first-review):

  ```bash
  ~/.claude/skills/fomo-qa/qa_env.sh reset   # back up, then clear the dogfood root to a fresh new user
  ```

- **Simulate a returning user** (runs weekly-review / due-revisit): do **not** reset. Keep the dogfood coach state left by a previous campaign and go straight to Step 3. **Lesson from 2026-07-20: a freshly reset session can never test memory continuity or problem-ledger continuity** (memory is `not_applicable`, and an empty prior problem ledger will not "catch" you). Verifying "did last time's problem follow up?" requires a book that already has a finalized review behind it — do not mistake its absence for a fix working.

Confirm with the user which one to simulate; default to "brand-new user" when unsure. This is a **campaign-level** choice, made once. It is not re-asked before each route run: a campaign that opened fresh and then finalized a `first_review` **is** a returning user for everything that follows, without a reset and without leaving the conversation. That is the cheapest way to reach the returning-user routes, and since #544 it is the documented one.

**This isolation must survive into every later shell** — if commands each start a new shell, re-run the `isolate` line in every one, for every route run in the campaign. `qa_env.sh` refuses when it has not been; the engine and receipt commands do not, because they are product commands a real user runs against their own root, so a shell that quietly lost it writes the dogfood run into the real book. Establish it first, every time.

### Step 3 — Choose a data source (one of three, standardized)

| Data source | Path used in commands | Fits |
|---|---|---|
| **Real trades** (read-only) | `~/Side_project/investment_note/trades/fomo/trades.csv` | True acceptance: it asks about ting's own motives, which exposes the most |
| **Mock persona** | `mock/<persona>.csv` inside the worktree (see `mock/SAMPLES.md`, e.g. `sample_ai_holder`, `sample_tw_mixed`) | Fast, zero privacy risk, reproducible |
| **Test-drive** | `--test-drive` (no CSV) | Demonstration only; `persist:false`, zero writes, isolated root |

The real trade file currently holds roughly 1,125 rows across 76 symbols, mixing Taiwanese and US markets and mixed date formats — good stress-test material.

### Step 4 — Walk through (follow the product's fixed lifecycle; do not rewrite it)

After `cd .../fomo-kernel-dogfood/skills/fomo-kernel` (and confirming Step 2's `export TRADE_COACH_HOME` is live in this shell, so `prepare`/`preview`/`finalize` all land in the isolated dogfood root), follow the **product** `SKILL.md`'s fixed lifecycle. Summary — for detail and edge cases the product's `SKILL.md` / `flows/*` / `references/*` are always authoritative:

```bash
# 1. prepare —— read review_plan.flow_path to decide which flow to follow
python3 engine/review.py prepare <CSV or --test-drive> --language zh-TW
#    for test-drive, note review_plan.state_root; every later preview/finalize/resume needs --root <state_root>

# 2. agent work —— declare host capability, make the qualitative judgments, ask every required
#    question in question_queue, build an inferred thesis for uncovered positions, and write the
#    "no numbers" narrative (answers.json / narrative.json must pass their schemas)

# 3. preview —— validate, then render the private / public previews
python3 engine/review.py preview --session-id <ID> --answers /tmp/answers.json --narrative /tmp/narrative.json

# 4. embed the full card preview → ask the user to pick a rule / write their own / skip

# 5. finalize —— atomically commit the canonical bundle
python3 engine/review.py finalize --session-id <ID> --answers /tmp/answers.json --narrative /tmp/narrative.json
```

**The UX receipt runs through the whole walkthrough (mandatory — this is what connects QA to eval)**: a walkthrough is not "the engine finished". Every user-visible step goes into the receipt (the product's `tools/ux_receipt.py`). That content-free receipt — only session id, capabilities, pass/fail, no trade content — is the machine-readable annotation feeding eval's evidence level 4.

Below is a **complete, directly copyable `first_review` trace**. The order is a contract, not a suggestion: `verify` hard-checks which events must appear, exactly how many times, and which precedes which. Get it wrong and the whole session is void with no way to repair it (the trace is append-only). Record each line **immediately after the user actually sees that thing**; never save it all for the end.

```bash
# qa-trace: first_review
# 0) Declare host capability right after prepare. --adapter must match the capability
#    set you declare (plain_text / native_options / validated_widget); start adds the
#    universal plain_text and markdown_inline fallbacks itself — do not pass them again.
python3 tools/ux_receipt.py start --session-id <ID> --client claude --route first_review \
  --adapter validated_widget --question-mode native_options --card-mode widget

# 1) Cash anchor (#357): exactly once on first_review / weekly_review, and where it goes
#    follows the outcome. `found_in_source` is read out of the statement during prepare, so
#    it must come before the first question and the first card — recording it later is
#    judged out of order. When the source carries no balance the plan says so
#    (`input.cash_anchor.status: "absent"`) and the question is asked at the card beat
#    instead; the weekly trace below shows that shape. There is no outcome for "did not
#    ask": a run where the user was never offered it records nothing, and `verify` fails.
python3 tools/ux_receipt.py event --session-id <ID> --event cash_anchor_checked \
  --cash-outcome found_in_source

# 2) One row per question asked. Question text never enters the trace: a question from a
#    validated dynamic surface records "source + sha256 of the presented text" instead, and
#    the two must appear together (this replaces the removed --question-id).
python3 tools/ux_receipt.py event --session-id <ID> --event question_presented \
  --mode native_options --surface-source validated_dynamic --surface-digest <64-hex-digest>

# 3) Record this the instant the user answers the last required question, before running
#    preview — it is #236's measurement start for "answered → card".
python3 tools/ux_receipt.py event --session-id <ID> --event answers_received

# 4) Cards are always "artifact first, presented second", and both rows need --stage
python3 tools/ux_receipt.py event --session-id <ID> --event artifact_generated \
  --stage preview --artifact-path <preview-card.html>
python3 tools/ux_receipt.py event --session-id <ID> --event card_presented \
  --stage preview --mode widget

# 5) Record when the "pick a rule / write your own / skip" choice is shown.
#    --grounding-check-file is required (#293)
python3 tools/ux_receipt.py event --session-id <ID> --event rule_choice_presented \
  --mode native_options --grounding-check-file <grounding-check.json>

# 6) The final card after finalize — again artifact first, then presented
python3 tools/ux_receipt.py event --session-id <ID> --event artifact_generated \
  --stage final --artifact-path <final-card.html>
python3 tools/ux_receipt.py event --session-id <ID> --event card_presented \
  --stage final --mode widget
```

> That leading `# qa-trace: <route>` line is not decoration. `qa/tests/test_skill_commands.py` uses it to **actually replay** this file's commands into a trace and send it through `verify` — so an example here that drifts from the CLI or from the event order goes red at commit time, rather than halfway through the next QA run. When adding a block containing `ux_receipt.py` commands, carry this marker; the test blocks a fence that lacks it.

`--grounding-check-file` points at a **transient JSON that never enters the trace** (same nature as `--question-surfaces`: keep it in `/tmp`, out of the repository). The tool performs the verbatim-containment comparison itself and writes only the boolean result and a hash into the receipt; the raw text never lands:

```json
{
  "candidates": [
    {"id": "candidate_0", "grounding": "the engine's own candidate_rules[].grounding sentence"},
    {"id": "candidate_1"}
  ],
  "presented_text": "the exact block of text you showed the user"
}
```

A candidate with no `grounding` omits the field entirely (like `candidate_1`) — **do not invent a sentence to fill it**. That is precisely the half #293 cannot catch and only a human can hold.

**The `weekly_review` route carries one extra opener, and `verify` enforces it** (the trace above is `first_review`; do not copy the opener into it). When `prepare` selects `weekly_review`, show the user the rule agreed last time **before the first question and the first card**. Two rows differ from the trace above — that opener, and the cash anchor. This trace deliberately shows the other cash shape: a source with no balance row, so the plan came back `absent`, and the balance was asked for in the same message as a preview card. Where the outcome row lands from there depends on the outcome (#663), because only one of the two recomputes anything:

- `declined` — nothing recomputes, so the card that asked the question is also the settled one, and the row sits **after** it, exactly as before.
- `provided` — always triggers `add-cash` and a recompute, so the card the user actually used to choose or skip the rule is the one rendered *afterward*, never the one that asked. The trace's one `stage=preview` artifact/`card_presented` pair is reserved for that later, settled card, so the row sits **before** it: record `cash_anchor_checked --cash-outcome provided` when the user answers, run `add-cash` and rerun `preview` on the session it returns — keeping THIS session id for the whole trace, since a receipt records one conversation, not one engine session — and only then record the deferred `artifact_generated`/`card_presented` pair for the card that recompute produced. The first, pre-cash card was real and the user did see it; it simply gets no artifact/presentation row of its own, because it is an intermediate interaction, not the accepted decision artifact. A pair recorded before this row would receipt that superseded card instead, which `verify` now refuses by name.

This trace shows `provided`. Everything else — questions, answers received, both card stages, rule choice — is copied verbatim, with the preview pair moved to follow the cash row instead of preceding it:

```bash
# qa-trace: weekly_review
python3 tools/ux_receipt.py start --session-id <ID> --client claude --route weekly_review \
  --adapter validated_widget --question-mode native_options --card-mode widget
# Opener: exactly one row. One more or one fewer both fail. If last time was a skip with no
# rule set, use --memory-kind prior_skip instead.
python3 tools/ux_receipt.py event --session-id <ID> --event memory_presented \
  --memory-kind prior_commitment
# If the plan also returned exit_reason / due_revisit, record one row each (same
# --memory-kind flag; these do not count as the opener):
#   python3 tools/ux_receipt.py event --session-id <ID> --event memory_presented --memory-kind due_revisit
python3 tools/ux_receipt.py event --session-id <ID> --event question_presented --mode native_options
python3 tools/ux_receipt.py event --session-id <ID> --event answers_received
# The FIRST preview (holdings-only) renders and is shown here, in one message with the
# rule choice and the cash question — a real, user-visible step. #663: it is an
# intermediate interaction, not the accepted decision artifact, so it gets no
# artifact_generated/card_presented row of its own; only the cash answer is recorded now.
# The user gave a balance, so run `review.py add-cash --session-id <ID> --cash <json>` and
# rerun `preview` on the session it returns before recording the next two rows below.
python3 tools/ux_receipt.py event --session-id <ID> --event cash_anchor_checked \
  --cash-outcome provided
# The settled card — the one add-cash's rerun preview rendered — is the pair this trace
# records, and the rule choice is shown alongside it.
python3 tools/ux_receipt.py event --session-id <ID> --event artifact_generated \
  --stage preview --artifact-path <preview-card.html>
python3 tools/ux_receipt.py event --session-id <ID> --event card_presented --stage preview --mode widget
python3 tools/ux_receipt.py event --session-id <ID> --event rule_choice_presented \
  --mode native_options --grounding-check-file <grounding-check.json>
python3 tools/ux_receipt.py event --session-id <ID> --event artifact_generated \
  --stage final --artifact-path <final-card.html>
python3 tools/ux_receipt.py event --session-id <ID> --event card_presented --stage final --mode widget
```

The wrap-up has the same shape as Step 5, except **`--memory` must be `pass` or `fail`**: a weekly session does not accept `not_applicable`, and `verify --require-owner-verdict` refuses it — memory continuity is the entire reason this route exists, so it may not be waived as inapplicable. This block is written out in full rather than pointing back at Step 5 precisely because that difference is the part copying would miss, and it only bites at the moment of archiving:

```bash
# qa-trace: weekly_review
python3 tools/ux_receipt.py event --session-id <ID> --event findings_recorded \
  --finding episode:EP-0NN
python3 tools/ux_receipt.py event --session-id <ID> --event owner_verdict \
  --controls pass --card pass --memory pass
python3 tools/ux_receipt.py verify --session-id <ID> \
  --require-recorded-owner-verdict --require-owner-verdict --require-timing-integrity --require-findings
```

**That opener is memory continuity itself** — was the rule agreed last time actually brought back and reconciled? For `route == "weekly_review"`, `ux_receipt.py` hard-checks two things: exactly one opener, positioned before the first `question_presented` / `card_presented`. Wrong order still fails, because a row backfilled afterwards cannot prove the user saw it at the time.

### The `snapshot_review` route: a declared book, no trade history

Selected when the user has a position table or screenshot and no transaction history. Transcribe it into the envelope in `references/data-contract.md` (`/tmp/fomo-kernel-positions.json`, never inside the repository), then walk `flows/snapshot-review.md`:

```bash
python3 engine/review.py prepare --route snapshot_review \
  --snapshot-json /tmp/fomo-kernel-positions.json --language en
python3 engine/review.py preview  --session-id <ID> --answers /tmp/answers.json --narrative /tmp/narrative.json
python3 engine/review.py finalize --session-id <ID> --answers /tmp/answers.json --narrative /tmp/narrative.json
```

Three differences from `first_review` decide the trace, and all three were read off a real run rather than assumed:

- **No cash anchor row.** The route's contract does not carry the #357 pre-flight, because the snapshot envelope declares `cash` inline (`references/ux-receipt.md` says so, and `verify` demands `cash_anchor_checked` only on `first_review` / `weekly_review`). Recording one here proves nothing that the envelope did not already state.
- **No question rows.** The observed plan came back with `question_queue: []` and `card_plan.question_policy` of `{"min": 0, "max": 0, "route": "snapshot_review"}` — the budget is structurally zero, because a snapshot holds no action history to ask about. Do not manufacture a question or a dynamic surface to fill the gap; the flow says so too.
- **The rule choice is still reached.** `preview` returned `candidate_rules` with a `grounding` sentence on each candidate, so `--grounding-check-file` is required here exactly as on `first_review`. This is the route's one real control, and it is what `--controls` judges.

Everything else matches `first_review`: both card stages, artifact before card, `--memory not_applicable` (a snapshot review has no prior period to carry, and unlike `weekly_review` this route accepts that value).

```bash
# qa-trace: snapshot_review
# 0) Declare capability the moment `prepare` returns. The trace's session id is
#    the plan's own session_id.
python3 tools/ux_receipt.py start --session-id <ID> --client claude --route snapshot_review \
  --adapter validated_widget --question-mode native_options --card-mode widget

# 1) No cash_anchor_checked and no question_presented on this route — see above.
#    The latency marker still belongs here, immediately before `preview`: it is
#    what makes the wait until the card appears measurable (#236), and on a route
#    that asks nothing it times the authored thesis_updates/narrative going in.
python3 tools/ux_receipt.py event --session-id <ID> --event answers_received

# 2) The preview card, artifact first. The path is preview's own
#    `private_card_html_path`.
python3 tools/ux_receipt.py event --session-id <ID> --event artifact_generated \
  --stage preview --artifact-path <preview-card.html>
python3 tools/ux_receipt.py event --session-id <ID> --event card_presented \
  --stage preview --mode widget

# 3) The rule choice, with its #293 grounding evidence.
python3 tools/ux_receipt.py event --session-id <ID> --event rule_choice_presented \
  --mode native_options --grounding-check-file <grounding-check.json>

# 4) The final card after `finalize`. The path is its `private_card_html`.
python3 tools/ux_receipt.py event --session-id <ID> --event artifact_generated \
  --stage final --artifact-path <final-card.html>
python3 tools/ux_receipt.py event --session-id <ID> --event card_presented \
  --stage final --mode widget

# 5) Wrap up: findings first, verdict last.
python3 tools/ux_receipt.py event --session-id <ID> --event findings_recorded \
  --finding episode:EP-0NN
#   this run genuinely found nothing (a declaration, not an omission):
#   python3 tools/ux_receipt.py event --session-id <ID> --event findings_recorded --no-findings
python3 tools/ux_receipt.py event --session-id <ID> --event owner_verdict \
  --controls pass --card pass --memory not_applicable
python3 tools/ux_receipt.py verify --session-id <ID> \
  --require-recorded-owner-verdict --require-owner-verdict --require-timing-integrity --require-findings
```

### The `refresh` route: recording the book comes before reviewing it

Once a book exists, a newer holdings view is **not** a review. `prepare --route snapshot_review` refuses any declaration the book-update lane would raise a confirmation for, and names the lane (#530). Its `{"status": "error"}` payload carries this message, observed verbatim on exit code 2:

```text
this holdings view has changes only you can settle before the recorded book can
catch up; run `review.py refresh --snapshot-json ...` first, then review it
```

So the real journey is composed — record, then review — and it produces **two receipts on two routes**, never one. Walk `flows/book-refresh.md` first:

```bash
# step 1: read-only. Writes nothing; returns the frozen diff, a summary, and
# pending_confirmations, keyed by a refresh_id.
python3 engine/review.py refresh --snapshot-json /tmp/fomo-kernel-positions.json

# step 3: adopt, passing that refresh_id back verbatim.
python3 engine/review.py refresh --snapshot-json /tmp/fomo-kernel-positions.json \
  --answers /tmp/refresh-answers.json
```

Then come back to the review with the same declaration; the observed rerun of `prepare --route snapshot_review` succeeded and carried `engine_state.snapshot_reconciliation` with `status: reconciled`, because the refresh had already brought the book up to date.

What makes this route's trace different:

- **A refresh creates no session**, so the trace is keyed by the engine's own `refresh_id` (`references/ux-receipt.md`). Declare capabilities only after step 1 has returned one.
- **No card events at all.** `verify` refuses `artifact_generated`, `card_presented`, `widget_attempt_failed` and `rule_choice_presented` on this route — a card delivery that structurally cannot have happened. What the trace owes instead is a **change surface**.
- **The question row depends on what the engine raised, and so does the verdict.** Step 1 returns `status: pending_confirmation` with a non-empty `pending_confirmations` (the observed run raised a disappearance and an appearance), or `status: ready` with `pending_confirmations: []` when only small, cash, market or currency differences moved. The first shape presents **one** question covering every raised item — never one per ticker — and its verdict carries `--controls pass|fail`; the second presents no question and its verdict must carry `--controls not_applicable`.
- **That `--controls` choice is not recoverable.** Recording `--controls pass` on a refresh that raised nothing is accepted at write time and then rejected by `verify` with `owner controls verdict must be not_applicable on a refresh trace`. The trace is append-only, so the run is void and has to be walked again. Decide it from step 1's `pending_confirmations`, before the verdict, not after.

```bash
# qa-trace: refresh
# 0) Session id = the refresh_id returned by step 1 of the flow above. This lane
#    renders no card, so `widget` has nothing to declare and `--adapter
#    validated_widget` would be a claim about a surface that does not exist here;
#    `native_options` is the honest ceiling on a host with real controls, and a
#    host without them declares `--adapter plain_text` and records the question
#    below as `--mode plain_text` (the form references/ux-receipt.md shows).
python3 tools/ux_receipt.py start --session-id <refresh_id> --client claude --route refresh \
  --adapter native_options --question-mode native_options

# 1) The engine's difference, as you narrated it. change_presented carries only
#    its kind: the diff itself holds tickers and share counts, and no trace ever
#    holds those.
python3 tools/ux_receipt.py event --session-id <refresh_id> --event change_presented \
  --change-kind diff

# 2) Only when step 1 came back `pending_confirmation`: the one question covering
#    every raised item. Omit this row entirely on a `ready` plan, and read the
#    --controls note above before the verdict.
python3 tools/ux_receipt.py event --session-id <refresh_id> --event question_presented \
  --mode native_options

# 3) After the adopt call returns: what was recorded, or that nothing was.
python3 tools/ux_receipt.py event --session-id <refresh_id> --event change_presented \
  --change-kind result

# 4) Wrap up. --card not_applicable is required rather than optional: it is the
#    positive claim that no card was owed. --change is the load-bearing axis —
#    did what the receipt showed match what actually happened to the book.
python3 tools/ux_receipt.py event --session-id <refresh_id> --event findings_recorded \
  --finding episode:EP-0NN
python3 tools/ux_receipt.py event --session-id <refresh_id> --event owner_verdict \
  --controls pass --card not_applicable --memory not_applicable --change pass
python3 tools/ux_receipt.py verify --session-id <refresh_id> \
  --require-recorded-owner-verdict --require-owner-verdict --require-timing-integrity --require-findings
```

Archive the two receipts separately. The refresh and the review that follows it are one journey and one `--case-id`, so the second archive is `--state-mode continued --parent-run-id <the first run_id>` — a fresh review archived beside a refresh it actually continued would lose exactly the lineage #520 added.

**Three walkthrough rules (from the 2026-07-20 owner_live audit correction; breaking any one voids that QA session)**:
1. **Declare capability honestly, and try the widget once per session — with the right tool**: the single walkthrough deviation on 2026-07-20 was under-declaring `card_modes` with zero widget attempts. #249's rich HTML card was generated, but the owner saw flat Markdown throughout (the main reason card=fail). A graphical surface must declare `widget`: try the widget first, and on failure record `widget_attempt_failed` before degrading to Markdown — do not let "the artifact was green" stand in for "it was delivered" again.

   **New lesson, 2026-07-21 (see the #230 comments)**: trying the widget does not mean grabbing whatever tool sounds like a renderer. Generic chart/dashboard visualization tools (some MCP `show_widget`/`visualize` tools, for instance) usually carry their own design system and will normalize or strip a large third-party `<style>` block. That is not "the host cannot render rich HTML" — it is the tool behaving as designed, and feeding it the card is using the wrong tool. To verify widget delivery, pick a pipeline that **preserves the supplied `<style>` and HTML as-is without design-system normalization** (for example Claude Code's Artifact-style publishing tools: a page of their own, with no external design system applied). Do not treat a tool as equivalent to what `references/card-delivery.md` calls "graphical surface: render a widget from the engine HTML artifact" merely because its name sounds like "widget". This was mis-diagnosed once — "picked the wrong tool" was reported as "the host has no rendering capability", posted to GitHub as a wrong diagnosis, and corrected only afterwards. Confirm the tool's contract (does it preserve the original CSS?) before concluding.

   **This tool-selection detail belongs here and must not be promoted into fomo-kernel's `docs/qa-runbook.md`**: that document ships with the public product for any stranger who clones it, and deliberately uses host-agnostic language naming no tool ("if the host can render rich content, try it; on failure degrade to canonical Markdown"). That is correct, because an external user's client has none of these Claude MCP tools and a hardcoded tool name means nothing to them. The operational knowledge of "how to tell whether a tool will normalize your CSS" is a Claude-specific MCP-ecosystem detail and belongs only in this skill (`fomo-qa`, for ting's own maintenance of fomo-kernel, not a public contract). Do not let it slip across while editing the runbook.
2. **`--language` follows the conversation language**: a Chinese conversation always uses `--language zh-TW` (stated in the product `SKILL.md`'s Language section; forcing `en` in the 2026-07-20 mock session caused the mixed-language output in #262).
3. **Measure "answered → card"**: the timestamp gap from `answers_received` to the preview `card_presented` is the machine wait in seconds, and it must be reported at wrap-up (#236's re-measurement instrument; `tools/ux_receipt.py --help` is authoritative for events and timestamps).

The complete event sequence lives in the product's `references/interaction-delivery.md` (**arguments per `tools/ux_receipt.py --help`** — the docs drift occasionally; `start --required-question`, for instance, no longer exists in the code). **What fomo-qa changes is promoting this step from "t

…(truncated)
