UAT-1 Acceptance Loop — the defined procedure

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" until python3 /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.

The disciplines — acceptance is one of seven, not the whole job

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.**

DisciplineThe questionNotes
Acceptance / blackboxdoes it do what the story promised?the six stages below
Device coveragedoes it work on the phone a diner actually holds?real Playwright descriptors only — 143 available. Never an invented viewport.
Browser enginesdoes 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 / vulnerabilitydo the boundaries hold?authz, session handling, token scope, unauthenticated endpoints, cross-tenant leakage, headers
Injectionwhat happens when a user types something hostile?XSS / HTML / SQL-shaped input through every field and every URL param
Usability / a11ycan a person actually finish?contrast, tap targets, focus order, error recovery, dead ends. When I cannot find the control, that IS the finding.
Resiliencedoes it survive real conditions?offline, slow network, back gesture, refresh mid-flow, double-submit

Security testing — the boundaries that keep it legitimate

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.


Stage 1 — Check the canary board

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:

  1. Use the Lambda URL, never 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.
  2. Record the batch id and 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.
  3. 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.


Stage 2 — Write the ledger row

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:

FieldMeaning
numstable W-###, never renumbered — it is the anchor the operator discusses
board_*the claim + its provenance (Stage 1)
verdictthe taxonomy below
findingwhat I saw, in a diner's words, with the numbers
blockerwhy it stopped, if it stopped
device / browserthe exact Playwright device descriptor + engine driven
steps_to_recreatenumbered, so anyone can repeat it without me
witness_run etc.the capture (Stage 5)
jirathe ticket (Stage 6)
deferred_codeD1-D5, when not walkable
Known schema gap (2026-09-05). device, browser and steps_to_recreate are listed above but are not fields in ledger.json and no renderer reads them — today that content lives inside finding / walk / couldnt_check. Either add the fields and teach the renderers, or stop mandating them. Until then, put the exact device descriptor and engine in finding so the row still carries it.

The verdict taxonomy

VerdictMeaning
HONEST_GREENboard says pass, a person can do it
FALSE_GREENboard says pass, a person cannot — the whole point of this audit
HONEST_REDboard says fail, and it really is broken
FALSE_REDboard says fail, but a person can do it fine
PARTIAL_GREENwalked far enough to trust part of it, not all
SPINE_OKthe plumbing answers, the human surface was not reachable
INCOMPLETEnot yet driven — a to-do, never a result
DEFERREDnot walkable by me; needs a D code
UNCOVEREDthe 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.

Deferred codes

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)


Stage 3 — Publish: the .html and the artifact move TOGETHER

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:

Serving check must return 200 for /, /ledger.html, /partials.html on port 8125, bound 0.0.0.0.


The standing credential set — BINDING for every walkthrough

**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() keyWhoScope
canaryOwnerownerall 17 canary dev restaurants
canaryMosaicWaiterwaiterCanary Mosaic — dev only (count verified = 1)
canaryWaiterSharedwaiter7 fixtures, when a walk must cross them
canaryMosaicDinerdinerdiner pool — the first confirmed one
adminApiportalportal.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.

Tables — use the reserved block, nothing else

Reserved for UAT: 1009010099. 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.)

Three standing cautions

  1. Every account is a real live account. Orders placed, menus edited and tables occupied are really written. Nothing is a sandbox.
  2. No destructive owner actions on shared fixtures. The owner holds all 17 and the scheduled fleet runs against them continuously; deleting a menu on the wrong one reds the board.
  3. The portal password is NOT a dev-scoped secret. EMP-1 verified the identical string is live in dev, staging AND prod, and it mints a 7-day token with authority over every /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.

What this pack does NOT unblock — do not re-ask

A note that will otherwise re-mislead: EMP-1 verified GET /owner/restaurants returns
17 for the owner. That is the switcher list endpoint. My own STR-1207 finding is that
GET /owner/restaurants/<rid>/menus, /all-items and /menus/overlap-summary return 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.

Stage 4 — Drive it with Playwright

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.


Stage 4a — The sentinel rule: never report an absence my instrument caused

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:

  1. Enumerate in the TARGET state, not the entry state. A recon of the landing page cannot describe a page with a full cart — the cart controls do not exist yet. Enumerating the wrong state is a guess wearing a lab coat.
  2. When everything comes back negative, run a control. Three canary logins really were dead, but I only knew because the probe read the form back (email exact, password 43/43 chars) and Cognito answered 400. Without that, "refused" and "I never typed it" look identical.

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.

Stage 5 — The Witness Room

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 roomWhy
HONEST_GREENthe board and I agree. Nobody needs to watch agreement.
SPINE_OKthe plumbing answered; no human surface was ever reached.
D1 no-screenevery step is a direct API call. There is nothing to film.
D5 harnessmy 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.

Ad-hoc requests — a third axis, orthogonal to the two above (binding 2026-09-05)

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 roomOwes
FALSE_GREEN HONEST_RED FALSE_RED PARTIAL_GREENvideo + trace + ticket
D2 provisioning · D3 won't-mutate · D4 needs-assignmentvideo + trace + ticket — a real person hits a real dead end, and that dead end is filmable
INCOMPLETE, not deferrednothing 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.

Stage 6 — Jira

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.

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.


The closeout — mine to run, unasked

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.