One journey = one pass through six stages. **A stage skipped is a stage that must be declared skipped in the ledger row.** Nothing here is optional-by-silence.
The question this loop answers, every time:
The board says PASSED. Can a person actually do it?
Serving gate (added 2026-09-05). The room is served from/workspace/uat/witness—serve/is a symlink to it. Nothing is "published" untilpython3 /workspace/uat/linkcheck.py
exits 0: it probes the LIVE SERVER for every page, permalink, report and trace. For two days the
room rendered to a directory the server did not serve, so/was a raw file listing and every/w/W-###/permalink quoted in Jira was a 404. Never confirm a deliverable by the path you wrote
it to — confirm it by the path the operator uses.
I test the deployed product as an outsider, capture evidence a person can look at, and put it in Jira where another agent or a human can act on it. **A finding that does not reach Jira has not closed the loop.**
| Discipline | The question | Notes |
|---|---|---|
| Acceptance / blackbox | does it do what the story promised? | the six stages below |
| Device coverage | does it work on the phone a diner actually holds? | real Playwright descriptors only — 143 available. Never an invented viewport. |
| Browser engines | does it break on one engine and not another? | chromium 145, firefox 146, webkit 26 — all installed and launching here. WebKit is real iOS Safari, so "iPhone geometry on chromium" is a harness limitation, never a container one. |
| Security / vulnerability | do the boundaries hold? | authz, session handling, token scope, unauthenticated endpoints, cross-tenant leakage, headers |
| Injection | what happens when a user types something hostile? | XSS / HTML / SQL-shaped input through every field and every URL param |
| Usability / a11y | can a person actually finish? | contrast, tap targets, focus order, error recovery, dead ends. When I cannot find the control, that IS the finding. |
| Resilience | does it survive real conditions? | offline, slow network, back gesture, refresh mid-flow, double-submit |
Authorised: dev only, the operator's own product, non-destructive. That is an ordinary security engagement and squarely in scope.
Never: prod · DoS or load generation · destructive payloads (DROP, mass delete) · exfiltrating real customer data · touching another tenant's data beyond proving the boundary holds or leaks · persisting a live XSS payload where a real user would hit it.
Prove it with the smallest demonstration, screenshot it, ticket it. Never publish a working exploit chain — describe the class and the reproduction.
The board is the CLAIM under test. It is never evidence.
bash /workspace/uat/check-board.sh
That fetches, unauthenticated:
GET https://<canary-runs lambda url>/admin/canary-runs/summary?environment=dev&limit=20
and writes runs/board/live_<UTC>.json plus a diff against the ledger.
Three rules, each of which has already cost a wrong finding:
portal.internal. The portal is static S3 + CloudFront: every unknown path returns the SPA shell at a deceptive HTTP 200 with x-cache: Error from cloudfront. The probe looks successful and yields nothing.started_at into the row. A board status without the batch that produced it cannot be re-checked later, and the board moves under us.skipped === true is not a pass. The board renders soft-skips as green. Any journey with skipped true is a candidate false green before it is driven — flag it, don't inherit it.Provenance recorded on the row: board_status · board_batch_id · board_started_at · board_checked_at · board_skipped.
The ledger (runs/wave0/ledger.json) is the single source of truth. Do not quote a row count here — it drifted to 95 while this line still said 92. The count is whatever the file says; sync-all.sh step 7 prints it. Every other surface is rendered from it — so a fact that isn't in a row does not exist.
Each row must carry, before it can be called finished:
| Field | Meaning |
|---|---|
num | stable W-###, never renumbered — it is the anchor the operator discusses |
board_* | the claim + its provenance (Stage 1) |
verdict | the taxonomy below |
finding | what I saw, in a diner's words, with the numbers |
blocker | why it stopped, if it stopped |
device / browser | the exact Playwright device descriptor + engine driven |
steps_to_recreate | numbered, so anyone can repeat it without me |
witness_run etc. | the capture (Stage 5) |
jira | the ticket (Stage 6) |
deferred_code | D1-D5, when not walkable |
Known schema gap (2026-09-05).device,browserandsteps_to_recreateare listed above but are not fields inledger.jsonand no renderer reads them — today that content lives insidefinding/walk/couldnt_check. Either add the fields and teach the renderers, or stop mandating them. Until then, put the exact device descriptor and engine infindingso the row still carries it.
| Verdict | Meaning |
|---|---|
HONEST_GREEN | board says pass, a person can do it |
FALSE_GREEN | board says pass, a person cannot — the whole point of this audit |
HONEST_RED | board says fail, and it really is broken |
FALSE_RED | board says fail, but a person can do it fine |
PARTIAL_GREEN | walked far enough to trust part of it, not all |
SPINE_OK | the plumbing answers, the human surface was not reachable |
INCOMPLETE | not yet driven — a to-do, never a result |
DEFERRED | not walkable by me; needs a D code |
UNCOVERED | the surface is real and reachable, but no canary journey covers it at all — found by gate-1 sweeps, not by auditing a board claim. Owes the full evidence chain like any other in-room verdict. |
D1 no screen exists · D2 blocked on provisioning · D3 would require a mutation I will not make · D4 needs a table/config assignment that is the operator's call · D5 harness unresolved (my problem, not the product's)
One report, two places. Divergence is a defect in me, not a cosmetic issue.
bash /workspace/uat/sync-all.sh
renders the ledger, rebuilds the Witness Room, regenerates the partials closeout, copies both HTML files to /exchange/output/artifact/ under a UTC stamp, supersedes the older copies, and re-points the served symlinks.
Then, in the same breath and never later:
action: read on its url) — publishing to an artifact this session has not read is refused, and rightly.Serving check must return 200 for /, /ledger.html, /partials.html on port 8125, bound 0.0.0.0.
**HARD RULE (operator, 2026-09-05): the EMP-1 dev canary credential pack is what walkthroughs use from now on.** It is one coherent set — a restaurant, its owner, its waiter, a diner who walks into it — so owner / waiter / diner all point at the same board and anything seen on that board came from my own session. Nothing else is a valid basis for a Witness Room walk.
Source: /exchange/agent-req/Reply_QRATE-UAT-1_dev-canary-credentials_20260905T171741Z.md Values live ONLY in ~/.uat/accounts.json (mode 600) and are loaded at runtime via account(name) in lib-witness.mjs. Never hardcode one in a spec: specs and journals reach /exchange and the served room.
account() key | Who | Scope |
|---|---|---|
canaryOwner | owner | all 17 canary dev restaurants |
canaryMosaicWaiter | waiter | Canary Mosaic — dev only (count verified = 1) |
canaryWaiterShared | waiter | 7 fixtures, when a walk must cross them |
canaryMosaicDiner | diner | diner pool — the first confirmed one |
adminApi | portal | portal.internal.qrate-ai.com, incl. /system-health |
The house restaurant is Canary Mosaic — dev, 28ae37ab-706c-4e20-83d7-52f5d2dff747, layout mosaic_v2. It is the only fixture with a waiter account of its own.
Reserved for UAT: 10090–10099. Ten tables no scheduled canary touches.
Never use 9915 · 9920 · 9921 · 9928 · 9950 · 9960 · 9970 — the canary schedules occupy them, and sharing one produces cross-run contention and a false red for both of us. Everything below 10090 is either in use or reachable by a scheduled run, so the old habit of picking a free-looking 99xx is now wrong. (Walks driven before this rule landed — W-097 on table 9994 — were not wrong at the time; they simply predate the block.)
/admin/* route. Point it only at portal.internal.qrate-ai.com. Do not try it elsewhere, not even to confirm the claim. Assume rotation; re-request rather than cache.D2 fixtures (dietary-safety, includes, owner-menu, spice-mod, groupings, grouping-dietary, food-subcategories, drink-subcategories). None appear in the owner'sA note that will otherwise re-mislead: EMP-1 verifiedGET /owner/restaurantsreturns
17 for the owner. That is the switcher list endpoint. My own STR-1207 finding is thatGET /owner/restaurants/<rid>/menus,/all-itemsand/menus/overlap-summaryreturn 403
for five of them. Different endpoints — no contradiction, and STR-1207 still stands.
Confirm a 200 per restaurant before planning an owner-side walk on it.
The acceptance surface rule binds: name the surface, or the claim is inadmissible. A passing test suite is never the surface. The deployed product driven in a browser is.
devices['iPhone 14 Pro'] — never an invented viewport. The row records the descriptor and the engine, plus the caveat that chromium-with-iPhone- geometry is not real iOS WebKit.video: 'on', screenshot: 'on', trace: 'on'. It cannot be reconstructed afterwards.witness-run.sh): a second playwright test invocation wipes outputDir and destroys the previous run's traces.playwright test — --list included — while a report is being served.Binding, added 2026-09-07 after eight of them in one session.
An absence created by my probe is indistinguishable from an absence in the product. Care does not separate them — I was being careful every time. Only a control does.
Before any step can report that something is missing, it must name a SENTINEL: an element definitely present in the state I believe I am in. Sentinel not found → the probe is blind → the result is a HARNESS FAULT and is reported as one, never as a product finding. Only when the sentinel resolves does "not found" become evidence.
const r = await absence(page, {
lookingFor: 'button:has-text("Mark Served")',
sentinel: '[data-testid^="bill-item-"]', // the Bill tab definitely has these
label: "per-item serve control",
});
if (r.harness) return `HARNESS FAULT — ${r.why}`;
if (r.absent) return `${r.label} genuinely absent (sentinel ${r.sentinelCount}x)`;
absence() and mustSee() live in wizard/lib.mjs and witness/lib-witness.mjs. mustSee() throws with the testids actually on screen, so a walk cannot continue against a page it never reached.
Two corollaries, same origin:
This is a gate must be proven able to go RED applied to observation instead of assertion. Full case list: memory feedback_an_absence_needs_a_sentinel_or_it_is_my_instrument.
The room is for journeys where a person's experience is in question. It is not an archive of everything that ran. Two exclusions, and they are what keep it worth opening:
| Out of the room | Why |
|---|---|
HONEST_GREEN | the board and I agree. Nobody needs to watch agreement. |
SPINE_OK | the plumbing answered; no human surface was ever reached. |
D1 no-screen | every step is a direct API call. There is nothing to film. |
D5 harness | my tooling was wrong. Dressing that up as a product finding is dishonest. |
Excluded rows keep their evidence in the ledger. Out of the room is not out of the record.
The two exclusions above are about the verdict: nothing to film, nothing to learn. There is a separate, independent reason a row belongs in the room — **the operator asked to see it** — and that reason does not care what the verdict says.
Default is ad-hoc. Whenever the operator names a specific journey/W-number for me to walk or re-drive — as opposed to a systematic sweep, a scheduled batch, or a persona-retro pass over many rows — the resulting row is flagged adhoc_requested when its verdict is written, with no need to be asked twice. update-row.py does this by default; pass --sweep to a systematic/batch call to opt back OUT (clears the flag instead of setting it). If I am hand-patching ledger.json directly, set "adhoc_requested": true and a short "adhoc_note" explaining why it was asked for.
An ad-hoc row gets its own section in the room ("Ad-hoc requests"), separate from the main grid (findings) and Blocked (nothing to show) — it is neither. It carries a visible "Ad-hoc request" badge so it never reads as a finding. Its permalink (/w/W-###/) resolves
into the room instead of redirecting to the ledger, same as any other room row, and it gets the full run detail (video/trace per run, newest first) exactly like a room row that got there by verdict.
An ad-hoc-flagged row whose verdict is ALSO dirty (a real finding) stays in the main grid, not the ad-hoc section — the flag only pulls a clean row OUT of exclusion; it never pulls a genuine finding out of where findings are read. It still owes the full evidence chain per the table below, same as any other dirty-verdict row.
After flagging, regenerate + re-gate + publish the same as any ledger change: `python3 mk-serve-index.py && python3 ledger-render.py && bash publish-reports.sh && python3 linkcheck.py` — the membership gate and linkcheck gate must both still pass.
Everything that remains owes the full chain — video · trace · explanation · Jira:
| In the room | Owes |
|---|---|
FALSE_GREEN HONEST_RED FALSE_RED PARTIAL_GREEN | video + trace + ticket |
D2 provisioning · D3 won't-mutate · D4 needs-assignment | video + trace + ticket — a real person hits a real dead end, and that dead end is filmable |
INCOMPLETE, not deferred | nothing yet. A queued to-do is not a claim. |
There is no lane-evidence tier in the room. Subagent screenshots are real evidence and they stay in the ledger, but they are not a walk and they do not satisfy this gate.
capture-policy.py fails loudly on any row that owes and cannot pay. It checks the
disk, not the ledger field: a path in a row is a claim like any other.
Jira is the only channel to EMP-1. My memory isn't shared; a verdict that stays in this container is a verdict nobody acted on.
FALSE_GREEN and HONEST_RED gets its own ticket. No batching.https://ssaks-cl2.us.to/w/<W-###>/
That permalink is stable by design. The underlying artefacts live under a run stamp (witness_<UTC>/<journey>/patron/video.webm), so a re-drive would 404 any link written into a ticket last week. /w/W-###/ always resolves to the newest walk, with every prior run listed under it. Never paste a run-stamped path into Jira. NEVER attach files to Jira — post the URL. Policy, not a workaround: the room serves the artefact, the ticket points at it, and there is exactly one copy to keep current.
::UAT:: QRATE-UAT-1, carries the agent id as <Agent_Name:QRATE-UAT-1> (the comment path deletes angle brackets — in plain text, in backticks, and inside a code block), and closes with the surface driven.Needs Re-work is my rejection, expressed in the workflow.At the end of every wave, without being prompted:
bash /workspace/uat/sync-all.sh
then the two things it cannot do itself: the Jira write, and the artifact publish. The script prints them as an unchecked list precisely so a half-finished closeout is visible.