Skynet — Task specs (S1, executable from the text alone) — S1-rev
For: Agon, Sead, Sven (CTO), Vince — lanes: A = Agon, B = Vince, C = Sead
Standard per card: Context · Read first (paths) · Deliverables (files, endpoints, tables, routes by name) · Interfaces (consumes / exposes) · Steps · Acceptance (a command or artifact someone else can verify) · Out of scope · Waits on / Unblocks.
Repo conventions used below: monorepo Rescale-Media/skynet; apps/api = the static-ads FastAPI backend (today static-ads-automation/app/backend + app/pipeline), apps/web = its Vite/React frontend (today app/frontend), packages/contracts, services/scraper|transcriber|agent-runtime|workos, infra/, db/migrations/unified/<schema>/NNN_*.sql, docs/. Until A1a lands, paths below map 1:1 to the source repos named in each card. Tests: pytest apps/api/tests, pnpm --filter <pkg> test, pnpm vitest in workos. Sizes: S ≤ ½ d · M ≈ 1 d · L ≈ 2 d.
Lane C — Sead (Data spine & Bridge)
| Wave | Key | Card | Size | Waits on | Unblocks |
|---|---|---|---|---|---|
| W0 | S0a | Scrubbed-import prep for workos | S | — | A1a (A) |
| W1 | S1a | Core auth + workspaces + contract | M | — | A3 (A), A4 (A), A6 (A), M1 (V), M2 (V), S13 (S), S1b (S), S3 (S), S4 (S), S4b (S), S5a (S), S8 (S), S8a (S), V1 (V), V13 (V), V2a (V), V4 (V), X1 (A) |
| W1 | S1b | Invite/magic link/reset + user import | M | S1a (S) | M3 (A) |
| W6 | S1c | The deployed stack's request-origin posture: one mutation boundary, one CORS allowlist | M | S1a (S), X1 (A) | — |
| W6 | S1d | A migrated account's first contact: the mail it receives, and what its old password does | L | S1b (S), M3 (A) | — |
| W6 | S1e | Self-service password change | M | S1b (S), S1d (S) | — |
| W2 | S3 | Creative-code service | M | S1a (S), A1a (A) | S13 (S), S5a (S), S7 (S), S8 (S), S8a (S), V13 (V), V7 (V) |
| W2 | S4 | Lineage + outbox (minimal) | S | S1a (S) | S13 (S), S7 (S), V10 (V), V13 (V), V7 (V) |
| W2 | S4b | Bindings/spend-guard contract artifact | S | S1a (S) | V13 (V), V4 (V) |
| W3 | S5a | Ecomprofits read layer | M | S1a (S), S3 (S) | S7 (S) |
| W3 | S8a | Workos bridge endpoints: ad-name assembly + attach export package to the Launch card | M | S1a (S), S3 (S) | S10 (S) |
| W4 | S10 | Creative-code loop E2E with one real ad | M | V7 (V), S8a (S), S7 (S), V27 (V) | S11 (S) |
| W4 | S7 | Readback job v0 | M | S5a (S), S3 (S), S4 (S) | S10 (S), S13 (S), V10 (V) |
| W4 | S7b | Command-line entry points for the readback and the launch-package reconcile | S | S7 (S), V27 (V) | S10 (S) |
| W5 | M5 | Retention / archive plan | S | X5 (A) | — |
| W5 | S12 | Migration dry run (production PG) + rollback scripts | M | V3 (V), A6 (A) | S11 (S) |
| W6 | S12b | The restore re-runs 006's instructions backfill | S | S12 (S), V29 (V), V8b (V) | — |
| W5 | S13 | Cross-tenant isolation: the proofs, and Production scoped at its resolvers | M | S1a (S), S3 (S), S4 (S), S7 (S), V4 (V), V8 (V) | — |
| S4 | S0b | Pre-pilot discovery (per-brand launch process) | M | — | S6 (S) |
| S4 | S11 | Pilot SOP + walkthrough → G3 | S | S6 (S), S8 (S), V9 (V), S10 (S), X5 (A), S12 (S) | — |
| S4 | S6 | Pre-pilot discovery write-up (G2) | S | S0b (S) | S11 (S), S8 (S) |
| S4 | S8 | S8b · Launch-board API + ClickUp mirror (S4) | L | S1a (S), S3 (S), S6 (S), A1a (A) | S11 (S), V9 (V) |
S0a · Scrubbed-import prep for workos · S · chore/s/s0a-scrub-workos (with Sven)
Context. The rescale-workos repo has live credentials committed on purpose (co-founder handoff). The monorepo import (A1a) must not carry one of them. Decision already taken: no rotation now; credentials are discontinued when workos leaves Railway (S17, S4).
Read first. rescale-workos/CLAUDE.md lines 40–82 (## Access and credentials: ClickUp, AI/comms, Google, Postgres, Railway); rescale-workos/.gitignore lines 1–11 (says credentials are committed; secrets/ is NOT ignored); rescale-workos/secrets/ (≈110 tracked files — SESSION-PRIMING.md, google-service-account.json, field-create.curl, automation-group.curl, apply-migration-00{2,3,4,6}.mjs, ecomprofits-role-test.mjs, reseed-globals-2026-07-09.ts, p*/w*-*.mjs probes); rescale-workos/service/.env.example (the env names, no values).
Deliverables. (1) docs/security/workos-credential-inventory.md in the old repo (private): one row per credential — name, where it lives today (CLAUDE.md line / secrets file / Railway variable), owner, discontinuation date S17. No values in the doc. (2) Under infra/secret-scan/, create both gitleaks.toml and patterns.txt. gitleaks.toml is the single config used by both the manual scan and the pre-commit hook; it must include the stock gitleaks rules plus the custom patterns from patterns.txt (one custom regex per known value: first 8 characters + length, never the value). (3) The scrubbed working tree: rescale-workos checkout with secrets/ removed, CLAUDE.md lines 40–82 replaced by one paragraph "Credentials live in Railway variables and the 1Password vault Rescale workos; the old repo stays private as the archive until S17", .gitignore gaining secrets/, *.pem, *.key kept. Hand the tree's path to Agon. (4) Pre-commit hook config (.pre-commit-config.yaml with gitleaks using the pattern file) ready for A1b.
Interfaces. Exposes the scrubbed tree + pattern file to A1a/A1b.
Steps. Inventory with Sven (30 min) → write patterns → gitleaks detect --source <scrubbed-tree> --config infra/secret-scan/gitleaks.toml → fix until zero hits → grep the tree for each first-8-chars → hand over.
Acceptance. Paste in the TechDesk task: (a) gitleaks detect output on the scrubbed tree = 0 findings; (b) a deliberate test commit containing one known value is rejected by the pre-commit hook (terminal output); (c) inventory doc link.
Out of scope. Rotating anything; Railway changes; the monorepo import itself (A1a).
S1a · Core auth + workspaces + contract · M · feat/s/s1a-core-auth
Context. One login for every surface. Core issues RS256 tokens; every other service only verifies. This is the first cross-lane blocker: V1, V2a, V6b, A3, A5-lite all consume it.
Read first. Lock doc §4.1 (the contract — claims, cookies, refresh rotation, tv logout propagation, service tokens, legacy mapping); static-ads app/backend/auth.py (today's create_token, get_current_user L61–67 reading Bearer or ?token=, token_version check L74–88); app/backend/main.py L75 (auth.router at /api/auth) and L78 (auth_dep); app/backend/db.py (get_pool, get_cursor); schema.sql users table.
Deliverables.
- Migration
db/migrations/unified/core/001_core.sql:core.workspaces(id uuid pk, slug unique, name, settings jsonb, created_at),core.users(id uuid pk, email citext unique, name, password_hash, token_version int default 0, is_active bool, created_at),core.permissions(key text pk, module, action, description),core.roles(id uuid pk, workspace_id uuid null, key, name, is_system bool default false; unique key where workspace_id is null; unique (workspace_id, key)),core.role_permissions(role_id, permission_key, pk both),core.workspace_members(workspace_id, user_id, pk both),core.workspace_member_roles(workspace_id, user_id, role_id, pk all three)— roles are data, a member may hold several, effective permissions = union of allows (no deny rules),core.refresh_tokens(id uuid pk, user_id, family_id uuid, token_hash, expires_at, revoked_at, replaced_by uuid),core.feature_flags(workspace_id, key, enabled bool, pk(workspace_id,key)),core.schema_migrations(version text pk, checksum text, applied_at). Seed: workspacerescale, admin user fromADMIN_EMAIL/ADMIN_PASSWORD; the six system rolesadmin, strategist, media_buyer, editor, researcher, viewerand the S1 permission catalogue:admin.workspace.manage, admin.members.manage, admin.settings.manage, admin.audit.read, intelligence.read, intelligence.write, intelligence.teardown.run, intelligence.research.promote, studio.read, studio.write, studio.approve, production.read, production.write, production.export, workspace.read, workspace.launch.write, performance.readwith the role → permission mapping inpackages/contracts/auth/README.md. - Module
apps/api/core/auth/:keys.py(loadAUTH_JWT_PRIVATE_KEY_PEM/PUBLIC,kid= sha256 prefix),tokens.py(issue_access(user, ws) → jwt,issue_refresh,rotate_refresh,revoke_family,issue_service(name, ws)),verify.py(verify_access_token(token) → Claims, checkskid,exp,typ,iss, andtvagainst a 60-s cached lookup),deps.py(current_user,require_permissions(*keys)— every route gate checks permissions, never role names;require_roles(*roles)exists only as a thin compatibility shim during the port),router.py. - Routes under
/api/auth:POST /login {email,password}→ setsrs_access(12 h) +rs_refresh(30 d, path/api/auth) cookies, HttpOnly/Secure/SameSite=Lax,Domain= exact host fromPUBLIC_HOST;POST /refresh(rotation; reuse of a revoked token revokes the family);POST /logout(revokes family, bumpstoken_version, clears cookies);GET /me→{id, email, name, ws, ws_slug, roles, permissions, feature_flags?}for the current session (roles= the member's role keys;permissions= the effective union resolved server-side with a short cache — the canonical source for UI gating; the JWT carries roles only, never permissions) — the canonical shape V2a/V6 use for the workspace switcher and route gating;GET /jwks;GET /session-token(returns the current access JWT as JSON for the static-ads bridge, same-origin only);GET /tv/:sub(internal, service token);POST /service-token(admin only). packages/contracts/auth/:access-claims.schema.json,service-claims.schema.json,samples/(valid, expired, wrong-kid, revoked-tv — signed with a test keypair committed undersamples/test-keys/),verify.py,verify.ts(jose),verify.node.cjs, andREADME.md(claim table + cookie table + legacy role mapping).
Interfaces. Exposes the cookie names, claims, JWKS, /session-token, /tv/:sub. Consumed by V1, V6b, A5-lite, A3, X1.
Steps. Migration → keys/tokens → routes → contracts package + samples → tests → README.
Acceptance. pytest apps/api/tests/core/test_auth.py green (login sets both cookies; refresh rotates; reuse revokes family; logout bumps tv; /me 401 after logout); python packages/contracts/auth/verify.py packages/contracts/auth/samples/*.jwt and node packages/contracts/auth/verify.node.cjs packages/contracts/auth/samples/*.jwt print the same 4 verdicts (valid / expired / wrong-kid / revoked-tv). Post both outputs.
Out of scope. Invite/magic link/reset (S1b); users UI (V11); RLS.
S1b · Invite / magic link / reset + user import · M · feat/s/s1b-users
Context. Existing users of static-ads, the scraper admin, and the console must sign in at the terminal go-live (X7; the S1 exit gate until 2026-09-19) without a new password ceremony run by hand.
Read first. S1a routes; static-ads schema.sql users (email, display_name, is_admin); scraper meta_ads.admin_users (email, role admin|user); console users table (RescaleOS/console/src/db/client.ts, roles in src/lib/roles.ts); lock doc §4.1 legacy role mapping.
Deliverables. (1) core.auth_tokens(id, user_id, kind ∈ invite|magic|reset, token_hash, expires_at, used_at); routes POST /api/auth/invite (admin), POST /api/auth/magic-link, POST /api/auth/reset/request, POST /api/auth/reset/confirm, GET /api/auth/accept?token= (validates the token and returns {email, kind, expires_at} only), POST /api/auth/accept {token, password} (sets the password for invite/reset, marks the token used, signs in, sets both cookies). (2) apps/api/core/scripts/import_users.py: reads the three user tables (connection strings via env), dedupes by lower(email), creates core.users (inactive until accepted) + workspace_members with roles: static-ads is_admin→admin; if INTELLIGENCE_OWNER_EMAIL is set and the email is in that allowlist (static-ads derives can_see_intelligence from env, auth.py L33 — it is not stored), also add researcher; scraper admin→admin, user→researcher; console editor→editor, creative_strategist→strategist (RescaleOS/console/src/lib/roles.ts L4); everyone → viewer. Writes docs/ops/user-import-<date>.csv (email, sources, roles, invite sent y/n). (3) Emails go through M3's mailer; until M3 lands, the script prints the accept URLs.
Interfaces. Consumes S1a; M3 for sending.
Steps. Table + routes → import script against dumps → dry-run report → tests.
Acceptance. pytest apps/api/tests/core/test_users.py green (invite → accept → login; expired token 410; reset flow); python apps/api/core/scripts/import_users.py --dry-run on the three real dumps prints N users, 0 duplicate emails, and the CSV; post the CSV summary (counts only).
Out of scope. Sending emails (M3); role editing UI (V11).
Amended 2026-09-19 (the owner's decision: production at the terminal go-live). The old users sign in at the terminal go-live (X7), not at the S1 exit gate. The staging import and the dry-run proof in the acceptance above stand as S1 work.
S1c · The deployed stack's request-origin posture: one mutation boundary, one CORS allowlist · M · W6 · feat/s/s1c-request-origin-posture (S1-rev)
Context. Two gaps X1 found, which are one decision. (1) enforce_same_origin (app/core/auth/deps.py) is a CALL inside the handlers that remembered it: every mutating route of the core surface calls it, and NOTHING under app/backend/routes/ or app/backend/intelligence_v2/routes/ does — 169 mutating routes in total — while POST /api/auth/service-token takes no Request parameter at all and so cannot examine an origin. docs/AUTH_SESSION.md § 4 states the limitation in writing and names the fix as one decision rather than a route-by-route sweep, "the kind of partial fix that leaves exactly one hole"; what holds meanwhile is real but narrower — SameSite=Lax cookies and the SPA's Bearer header. (2) CORS_ORIGINS (app/backend/main.py) defaults to two localhost origins with allow_credentials=True and appears in no deploy template, so a deployed stack's allowlist is inherited from dev rather than decided.
Read first. app/core/auth/deps.py (enforce_same_origin: hostname-only, PUBLIC_HOST or the request's host, the deliberate pass for a request with neither Origin nor Referer); app/backend/main.py (the middleware stack and its ordering comment, the CORS_ORIGINS read, the router mounts, the static mount and the SPA catch-all); every caller of enforce_same_origin (core auth, bindings, admin bindings, creative codes, lineage) and GET /api/auth/session-token, whose guard is NOT replaceable by a mutation middleware; apps/api/supervisord.conf (uvicorn runs with no --proxy-headers) and infra/caddy/Caddyfile (the api is proxied), which is why the comparison anchors on configuration, not on request.url; infra/scripts/env.sh (the stage checks for PUBLIC_HOST / PUBLIC_ORIGIN), infra/deploy/required-keys.txt, the three infra/env/*.env.example; infra/smoke/run.sh and infra/synthetic/check.sh (the headerless callers); e2e/auth-policy.spec.ts; docs/AUTH_SESSION.md § 4.
Deliverables.
- One app middleware over
POST,PUT,PATCHandDELETE, with no path special-casing, so a router added later cannot be forgotten: it covers the core, Production and Intelligence-v2 mounts alike. In source it goes BEFORECORSMiddlewareso CORS wraps the refusal (the file's own ordering comment: the last added is outermost), and the request logger stays outermost.OPTIONSis not in the set — preflight stays entirely CORS's. - The rule the middleware applies, in
deps.pybeside the existing helper so both share it: a request carrying neitherOriginnorRefererpasses (a non-browser caller; the smoke gate's two POSTs and every server-to-server call are exactly this); withPUBLIC_ORIGINset — every deployed stage,required-keys.txt— the header's scheme, hostname and effective port are compared against the parsedPUBLIC_ORIGIN, default ports normalised; with it unset (dev, tests) the comparison stays hostname-only againstPUBLIC_HOSTor the request's host; a header that does not parse as an origin is refused. The anchor is the configured origin, neverrequest.url: uvicorn runs without--proxy-headersbehind Caddy, so the app seeshttpand port 8000 while the browser senthttps— an exact origin built from the request would refuse every browser mutation on a deployed stack. Adding--proxy-headersis a separate trusted-proxy decision (A3's surface) and is NOT part of this card. - The redundant in-handler calls removed from the mutating handlers, including the shared redemption path — no handler can run earlier than middleware, and
POST /api/auth/loginimproves by it: today its rate-limit bucket increments BEFORE the origin check.GET /api/auth/session-tokenkeeps its own call: it hands the cookie token into the Bearer /?token=bridge and promises same-origin only, and a middleware scoped to mutations cannot cover a GET. - No exemptions. The smoke gate's login and service-token POSTs are headerless curl; the synthetic sidecar, the deploy probes, the
?token=media bridge and the SSE streams are GETs; Canva's callback is the SPA's own same-origin POST with its state cookie checked independently; the workos bridge is outbound from Skynet;POST /api/usagealready serves headerless service callers. A server caller that starts sending a foreignOriginSHOULD be refused. - The CORS allowlist as a decision: explicit
CORS_ORIGINSin all three templates (dev = the two local origins; staging =https://staging.app.rescale.media; prod =https://app.rescale.media), and — whenPUBLIC_HOSTis non-blank, the established deployed marker (dev and the test conftest leave it blank) — the api REFUSES TO START unlessPUBLIC_ORIGINparses andCORS_ORIGINSis exactly that one origin, raised wherecors_originsis read so the container never binds, the healthcheck fails and the deploy stops before its smoke gate. The same check inenv.shbeside itsPUBLIC_HOST/PUBLIC_ORIGINstage rules, as defence in depth. docs/decisions/ADR-013-request-origin-posture.md— the mutation boundary as middleware, the configured-origin comparison and why notrequest.url, the headerless pass, no route exemptions, the deployed CORS equality, and the login ordering the change fixes.docs/AUTH_SESSION.md§ 4 rewritten to state the guarantee the code now makes and to keep the session-token GET guard distinct.docs/ops/s1c-acceptance.md.
Acceptance. pytest apps/api/app/backend/tests green, including new cases: a foreign Origin on a Production route, on an Intelligence-v2 route and on POST /api/auth/service-token is 403; the same requests with no Origin and no Referer pass; a Referer from the right origin passes and from a foreign one is refused; a header that does not parse is refused; on a stack with PUBLIC_ORIGIN set, an Origin differing only in scheme or port is refused, and one differing only in a default port is accepted; GET /api/auth/session-token still refuses a foreign origin; every OPTIONS preflight still answers from CORSMiddleware. Boot: with PUBLIC_HOST set and CORS_ORIGINS blank, or not equal to PUBLIC_ORIGIN, importing the app raises; with them consistent it starts; with PUBLIC_HOST blank nothing is required. bash infra/scripts/env.sh staging refuses the same two shapes. e2e/auth-policy.spec.ts gains a Production-route foreign-origin case. The full api suite and ci green.
Out of scope. --proxy-headers / ProxyHeadersMiddleware and anything else in the trusted-proxy surface (A3); rate-limit changes beyond the ordering the middleware gives for free; the ?token= bridge's own design (X1's record); any CORS behaviour for a caller that is not the SPA.
Waits on / Unblocks. S1a (S), X1 (A) / an auth posture docs/AUTH_SESSION.md can state without a caveat.
S1d · A migrated account's first contact: the mail it receives, and what its old password does · L · W6 · feat/s/s1d-migrated-first-contact (S1-rev)
Context. Discovered by M6 (open item #44). app/core/scripts/import_users.py creates every legacy user inactive with no password and mints an invite token, and app/core/mail/templates.py renders "You have been invited" — to someone who has used the old tool for months. Their old password meanwhile answers the same generic 401 as a typo (_load_user_by_email filters is_active in SQL), so nothing tells them to look in their inbox; and after the invite's seven days they have no rail at all — /magic-link and /reset/request filter is_active too, a re-run of the import never re-mints, only admin POST /invite does, and the SPA has ONE control before V2b lands: the email/password form at /login (routes.tsx has /login only; LoginPage.tsx's footer is static). Three more facts shape the fix. Every static-ads user's real bcrypt sits in production.users.password_hash on the unified database after the restore (SSO-created rows carry a random sentinel), and the core router already reads that table by email. The two 202 routes mint INLINE and defer only the send, so docs/SECURITY.md's latency sentence overclaims. And the import cannot start inside the api image (REPO_ROOT = Path(__file__).resolve().parents[5] raises IndexError under /app/app/core/scripts/), while seed_core --import-legacy copies the old hashes into ACTIVE rows — the one path that makes an old password sign in. ADR-001 §5's anti-enumeration rule is what makes the login half a design question rather than a wording change.
Read first. app/core/auth/router.py (/login, /invite, /magic-link, /reset/request, _accept, _legacy_user_id); app/core/auth/auth_tokens.py (_TTL, mint's same-kind revoke, preview); app/core/auth/delivery.py; app/core/mail/templates.py and mailer.py; app/core/scripts/import_users.py and seed_core.py; app/backend/auth.py (_legacy_user_for_claims); app/core/db.py and app/backend/db.py (the two pools); db/migrations/unified/core/002_auth_tokens.sql (the kind CHECK); production/001_baseline.sql (the legacy users table); apps/api/Dockerfile (where the scripts live in the image); tests/core/test_users.py, test_mail.py, test_auth.py, test_migrate.py, conftest.py; ADR-001 §1, §2, §5; docs/SECURITY.md § Auth links and logs; docs/comms/creative.md; docs/ops/m6-acceptance.md, v1-acceptance.md, s1b-acceptance.md, m3-acceptance.md.
Deliverables.
- A fourth token kind,
migrate.core/012widens the CHECK;_TTLgives it the invite's seven days (the link is credential-equivalent and the recipient did not ask for it; the person away for longer is served by the rails below, not by a longer credential);templates.KINDSgains it with its own copy — subject "Your account has moved", a lead that says the account moved without its password, on purpose, button "Set a password", no "If you did not ask for it" sentence, no tool named;_acceptactivates on it exactly as oninvite;GET /acceptreturns the kind so V2b's page can word itself. - The onboarding family.
inviteandmigrateareauth_tokens.ONBOARDING_KINDS:mint_onboarding(user_id, kind)supersedes every pending token of BOTH kinds (one live onboarding link per account), the genericmintkeeps its same-kind contract for magic and reset and refuses an onboarding kind, and the account's onboarding kind is the newest row of the family in any state (ORDER BY created_at DESC, id DESC) — the token history is the durable marker, no column oncore.users.POST /invitemints that kind when it ismigrateandinviteotherwise, and reports it askind. /loginanswers every miss the same on the wire, and a pending owner's old password re-sends the mail. The lookup is every row for the address; a sign-in still needs an active row with a stored hash that matches. Every miss does the same work before its 401 — one legacy read ofproduction.usersbylower(email)(best effort; a failure counts as no hash) and exactly one bcrypt: the account's own hash, else the legacy hash, else a module-level dummy of the same cost — and answers the same status, headers and bytes. When the candidate is PENDING (is_active = FALSE AND password_hash IS NULL) and the LEGACY hash matched, the onboarding mail is re-sent AFTER the response: the old password is proof of ownership for a re-delivery to the account's own inbox, never a credential. The guarantee is the wire response; latency is not promised./magic-linkand/reset/requestserve a pending account and become latency-silent. The decision is the handler's (an active account gets its magic link or reset, a pending one its onboarding mail again, an unknown address nothing); the MINT and the send run in the background task for every case, so the routes do the same work before the 202 anddocs/SECURITY.md's sentence becomes true.- The task. Its own cursor; one of
AUTH_TASK_SLOTS(default 2) held for its whole run by a NON-BLOCKING acquire — every slot busy → dropped and logged, the request already answered; every exception caught and logged by type name only; an invalidAUTH_TASK_SLOTSrefuses at import. The re-send itself isauth_tokens.renew_onboarding: one transaction under a per-account advisory lock — the row still pending, the account's kind, the cooldown (AUTH_RESEND_COOLDOWN_SECONDS, default 3600, on the database's clock; inside the window nothing is minted and the inbox link stays valid;0disables), then the family superseded and a fresh token of that kind. The anonymous re-send is delivered withrelay_by_hand=False: in the OFF and DRY_RUN mailer modes its link is dropped, never logged; the import's and the admin's sends, and an active account's magic/reset links, keep M3's relay. - The import mints
migrateand runs in the container.REPO_ROOTdeleted;--report <path>REQUIRED (the runbook row bind-mounts a host directory); the stdout markermigrate <email> <url>; the CSV columnmail sent.seed_core --import-legacyretired withimport_legacy_users. - Docs.
docs/decisions/ADR-015-migrated-first-contact.md(amends ADR-001 §5);docs/SECURITY.md§ Auth links;docs/AUTH_SESSION.md§ 3;docs/comms/creative.md(the mail's name; a typed old password sends a fresh copy);docs/INTEGRATION_CHECKLIST.mdrow 13b;docs/GO_LIVE.md§ 3 step 9b anddocs/CUTOVER.md§ 3 row 9b (the import runs after the restore and after the DNS flip, before the comms; the exactcompose runline with the--reportmount);apps/api/CLAUDE.mdenv rows; the threeinfra/env/*.env.example;docs/ops/m3-acceptance.mdandm6-acceptance.mdpointers;docs/ops/s1d-acceptance.md; open item #44 closed.
Acceptance. pytest apps/api/app/backend/tests green, including tests/core/test_migrated_first_contact.py: login by an unknown email, a pending migrated row, a pending invited row, an active row with a wrong password and a pending row with the RIGHT old password → one status, identical headers and identical bytes; exactly one bcrypt and one legacy read per miss, and no miss reaches the session issuer; a legacy hash that is not bcrypt is a miss, not an error; the legacy pool unreachable changes nothing on the wire; the owner's old password → exactly one migrate re-send, after the response, never relayed, the earlier link 410 and the new one 200; a wrong old password, no legacy row, an active row with a legacy row, and no onboarding history → none; inside the cooldown nothing is sent and the earlier link still previews 200, after it the link rotates, the window restarts from the re-send, 0 disables, and the window reads the whole family; an expired newest token re-sends, and an invited account is re-sent invite; the re-send uses the account's canonical email; an admin re-invite of a migrated account answers kind: "migrate" and revokes the older link while an invited account stays invite; the two kinds supersede each other in both directions and mint refuses them; the account's kind is the newest row by created_at then id, None with no history; five concurrent triggers yield one token and one answer; a row no longer pending renews nothing; a task is dropped when every slot is busy and served when one frees; a failing delivery and a failing database call are logged by type name only, with no secret marker in any record, and free the slot; the anonymous re-send logs no link in the OFF and DRY_RUN modes while the operator's send still relays; AUTH_TASK_SLOTS must be a positive integer; the 202 routes re-send migrate for a pending row after the response, mint an active row's magic/reset in the task (no token row when the 202 is written) and answer one body for pending, unknown and active; an imported account's mail says it moved, previews kind == "migrate", accepts a password, signs in, and the link is single-use; core/012 admits the fourth kind and refuses a fifth; seed_core refuses --import-legacy; the import module imports from a path shallower than six parents and refuses to run without --report. The full api suite and ci green. Live half (pending #26, #27): checklist row 13b on staging — a staging import delivers "Your account has moved" to the sandbox inbox, and a login with that user's old password re-sends it.
Out of scope. Any /login page text, form or "resend" control (V2b, open item #27 — a generic notice on every failed login is a V2b hand-forward); an admin pending / resend control (V5); persisting the import's sources on core.users (nothing reads it, and the mail may not name the tool — ADR-001 §2); a per-account lockout; S12's restore semantics and a production/003 re-run for accounts created after the restore (a hand-forward); timing indistinguishability on the login miss.
Waits on / Unblocks. S1b (S), M3 (A) / the first impression every existing user gets at go-live; M6's comms pages can name the mail.
S3 · Creative-code service · M · contract-wave schema first · feat/s/s3-creative-codes
Context. The creative code is the wire that closes the loop: Production stamps it, the ad name carries it, ecomprofits parses it back. There is exactly one generator today, in workos. Skynet must produce byte-identical codes and must draw seq from the same counter, or codes collide.
Read first. Lock doc §4.2; workos service/src/modules/naming/creative-code.ts (buildCreativeCode(parts) L84, variantLetter, kindFromFormat, isTeamMade, teamMadeFromCode), sequence.ts (nextSequence(db, productNumber, codeType) L18 → select rescale_service.next_naming_seq($1,$2)), migrations/001_sequence_counter.sql; workos assemble.ts (assembleAdName, AD_REQUIRED L93–109) and __fixtures__/naming-conventions.ts (META_AD_V3); ecomprofits packages/features/naming-conventions/src/lib/parser.ts (parseNameWithRules(sourceName, rules) L18) and the live rule set for the Rescale Meta account (export it with select rules from naming_conventions where account_id='48810a93-baae-4bc3-b562-5cf8814f0edf' and integration_type='meta' and entity_level='ad' and is_active; and save it as packages/contracts/creative-code/ecomprofits-meta-rules.json).
Deliverables. (1) Migration db/migrations/unified/core/002_creative_codes.sql: core.creative_codes(code text, workspace_id uuid, product_number text, kind text check (kind in ('RC','TM')), team_made bool, seq int, variant text, asset_id uuid null, asset_key text null, asset_type text, session_id uuid null, issued_at timestamptz, issued_by uuid, naming_version text default 'v3', unique (workspace_id, code)). (2) apps/api/core/creative_codes/generator.py: a line-for-line port of buildCreativeCode + variantLetter + kindFromFormat + isTeamMade (same errors). (3) sequence.py: next_seq(product_number, kind) executing select rescale_service.next_naming_seq(%s,%s) on the ecomprofits connection with the rescale_service role (env ECOMPROFITS_WORKOS_URL), inside the same transaction as the insert — no local counter, ever. (4) Routes: POST /api/creative-codes {product_number, kind, team_made, variant?='A', asset_type, session_id?} → {code, seq}; GET /api/creative-codes/:code; POST /api/creative-codes/:code/iterate → next variant letter, same seq. (5) packages/contracts/creative-code/: schema.json, normalize.py/normalize.ts (normalize_creative_code: upper-case → RCi/TMi casing restored, \s*(hook|_HK)\s*(\d) → hook N, trim), fixtures live-samples.json (≥30 real nc_creative_code values from meta_ads_dashboard_view, including RCI31-A_HK 1), and cross-check.json (1,000 random CreativeCodeParts + the TS output). Add the helper script service/scripts/dump-creative-codes.ts in the workos repo in this card (it does not exist yet) to generate that fixture from service/src/modules/naming/creative-code.ts; after A1a the same file lives at services/workos/scripts/dump-creative-codes.ts.
Interfaces. Exposes the routes + the normaliser. Consumed by V7 (stamp), S7 (readback key), S8 (launch task field).
Steps. Contract wave: schema + fixtures merged first. Then generator port + cross-check test → sequence via ecomprofits → routes → parser round-trip test.
Acceptance. pytest apps/api/tests/core/test_creative_codes.py green: (a) 1,000/1,000 cross-check parts produce the TS string; (b) 20 issued codes placed into a v3 ad name (fixture META_AD_V3 layout) parse with the exported ecomprofits rules and creative_code round-trips; (c) two concurrent issues for the same product get distinct seq (test against a scratch rescale_service schema); (d) normaliser maps every live sample to canonical form. Post the test output.
Out of scope. Ad-name assembly (workos endpoint, S8); RescaleOS ad names (A12, S3); changing the grammar.
S4 · Lineage + outbox (minimal) · S · feat/s/s4-lineage-outbox
Context. The S1 exit gate must show the chain session → concept → asset → export → code → performance for one real ad. Only Production (V7) and readback (S7) write to it in S1.
Read first. Lock doc §4 (contract 3); technical plan §2.4.3.
Deliverables. Migration 003_lineage.sql: core.lineage_nodes(id uuid pk, workspace_id, type text check (type in ('session','concept','asset','export','creative_code','launch','performance','winner')), ref_id text, attrs jsonb, created_at), core.lineage_edges(from_id, to_id, relation text, pk(from_id,to_id)), core.events_outbox(id bigserial, workspace_id, type, payload jsonb, created_at, processed_at null). Module apps/api/core/lineage/: POST /api/lineage/nodes (batch: nodes + edges, idempotent on (workspace_id,type,ref_id)), GET /api/lineage/chain?type=creative_code&ref=<code> → ordered chain (both directions, depth ≤ 8), poller skeleton outbox.py (marks processed, no consumers yet). packages/contracts/lineage/schema.json + fixture chain.
Interfaces. Consumed by V7 (writer), S7 (writer), V10 (reader).
Acceptance. pytest apps/api/tests/core/test_lineage.py: insert the fixture chain → GET /chain?type=creative_code&ref=TMi2-A returns session→concept→asset→export→creative_code→performance in order; re-posting the same batch creates no duplicates.
Out of scope. Scraper/studio/workos producers, inbox, coverage store (S14/S15, S3).
S4b · Bindings / spend-guard contract artifact · S · feat/s/s4b-bindings-contract
Context. Vince builds V4 (tables + evaluation) and every sidecar will post usage. The contract must exist before the code so consumers build against fixtures.
Read first. Lock doc §4 contract 4; technical plan §2.4.4; V4 spec below (tables).
Deliverables. packages/contracts/bindings/: binding.schema.json (GET /api/bindings/:workflow response: {workflow_key, provider:{kind, base_url}, model:{slug, modality}, config:{…}, paused:bool, guard:{window_hours, warn_usd, pause_usd, spent_usd}}), usage-event.schema.json (POST /api/usage: {workspace_id, service, workflow_key, model, tokens_in, tokens_out, cost_usd, request_id}), spend-guard-states.md (ok → warn → paused → resumed, who can resume), fixtures (one per state), consumer tests: Python (apps/api), Node (services/scraper, services/agent-runtime — a 10-line client each that validates against the schema).
Acceptance. pnpm --filter contracts test green; the three consumer tests import the fixtures and pass.
Out of scope. The implementation (V4), the admin UI (V5).
S5a · Ecomprofits read layer · M · feat/s/s5a-ecomprofits-read
Context. Performance comes back from ecomprofits by creative code. If the columns or formulas are wrong, the "closed loop" is fake. All facts below are verified against the ecomprofits schema.
Read first. Lock doc §4.3 (contract); ecomprofits apps/web/supabase/schemas/27-meta-ads.sql (meta_ad_insights L383–484, meta_intraday_ad_insights L620, combined_meta_ad_insights L919, meta_ads.parsed_naming L211–244), 40-dashboard-views.sql (readonly grant loop L304–312, role list at L309; klaviyo loop L1514–1522, L1519), 39-ads-performance-unified.sql; packages/features/meta-ads-sync/src/lib/business-logic.ts L257–297 (calculateWinnerLoserStatus). For the readonly-role pattern, copy the existing claude_readonly grant style already present in 40-dashboard-views.sql and 27b-meta-ads-breakdowns.sql; do not look for a separate credentials doc.
Deliverables.
- Migration
db/migrations/unified/core/004_integration_credentials.sql:core.integration_credentials(workspace_id uuid not null, provider text not null, account_id text not null, credential_json jsonb not null, created_at timestamptz default now(), updated_at timestamptz default now(), primary key (workspace_id, provider)). For ecomprofits storeprovider='ecomprofits',account_id= the ecomprofits account id, andcredential_jsonkeysreadonly_urlandmeta_account_id. - Role
skynet_readonlyon ecomprofits Postgres (created out-of-band likeclaude_readonly; password to the vault) + ecomprofits PR: add'skynet_readonly'to therolname in (...)lists at40-dashboard-views.sqlL309 and L1519 and to27b-meta-ads-breakdowns.sqlloops; new declarative fileapps/web/supabase/schemas/44-skynet-read.sqlwith the view below +grant select … to skynet_readonly; migration viapnpm --filter web run supabase:db:diff -f skynet_read. - View
public.skynet_creative_daily(security_invoker): fromcombined_meta_ad_insights i join meta_ads a on (a.meta_account_id, a.ad_id) = (i.meta_account_id, i.ad_id); columnsaccount_id, ad_account_id, ad_id, date, creative_code_raw = a.parsed_naming->>'creative_code', product_number = a.parsed_naming->>'product_number', market = a.parsed_naming->>'market', spend_native = amount_spent, spend_eur = amount_spent_eur, impressions, reach, three_second = video_plays, thruplay = video_thruplay_watched, link_clicks, purchases, purchase_value_eur, cost_per_purchase_eur, cpp_meta = cpp, breakeven_roas, seven_day_spend_eur, seven_day_purchases, seven_day_purchase_value_eur, seven_day_roas, seven_day_eligible, winner_loser_status, creative_hit, updated_at. apps/api/core/readlayer/:client.py(connection fromcore.integration_credentialswhereprovider='ecomprofits':credential_json->>'readonly_url', scoping id =account_id),queries.py(creative_daily(account_id, codes[], date_from, date_to)— alwayswhere account_id = %s),metrics.py(hook_rate = three_second/impressions,hold_rate = thruplay/three_second,roas = purchase_value_eur/spend_eur,cost_per_purchase_eur = spend_eur/purchases;Noneon zero),normalizefrom S3 applied tocreative_code_raw→creative_code.packages/contracts/metrics.md(formulas, column provenance, currency rule: EUR reporting, native for display).
Interfaces. Consumed by S7 (readback), V10 (winners view).
Steps. Role + grant PR (ecomprofits, Sead owns) → view → client/queries → fixtures from a frozen sample (3 ads × 7 days, exported to apps/api/tests/fixtures/ecomprofits_sample.json) → staging read of real data.
Acceptance. pytest apps/api/tests/core/test_readlayer.py green on the fixture; PR description contains a table: 3 known ads × 1 day with hook/hold/ROAS/cost-per-purchase from this module beside the same numbers from the ecomprofits Meta dashboard (meta-ads.tsx holdPct, meta-master.tsx hookRate) — equal to 2 decimals; a query without account_id fails a unit test.
Out of scope. Winner/loser evaluation (S5b/S9, S3); Pinterest/Taboola; writes.
M3 · Email delivery · S · W6 · Agon · feat/a/m3-email (S1-rev)
Read first. ecomprofits packages/mailers (MAILER_PROVIDER ∈ nodemailer|resend, env EMAIL_*, RESEND_API_KEY); apps/api/app/core/auth/delivery.py (the seam), router.py (the three routes that mint links), scripts/import_users.py.
Deliverables. apps/api/app/core/mail/ (config.py, providers.py, templates.py, mailer.py) with the same provider switch — MAILER_PROVIDER ∈ nodemailer|resend, blank = off (the links are logged, relay by hand; the stack boots without a mailer); nodemailer over the standard library (EMAIL_HOST/PORT/USER/PASSWORD, EMAIL_TLS), resend over httpx (RESEND_API_KEY); sender EMAIL_SENDER, default app@rescale.media; templates invite/magic/reset (product-neutral, the expiry from the token TTLs); MAIL_DRY_RUN=1 renders, validates and logs instead of sending; MAIL_SANDBOX_TO = the staging sandbox (every message to one inbox, the intended recipient in the subject; env.sh + the deploy refuse a staging env with a provider and neither knob); the seam turns a provider failure into sent: false with a redacted reason and never logs the link; /magic-link and /reset/request send after their 202; S1b's invites (POST /api/auth/invite, the import script) go out through it; import_users.py's REPO_ROOT fixed. Env rows in apps/api/CLAUDE.md, the env examples, STAGING.md §2/§7, LOCAL_DEV.md, SECURITY.md, INTEGRATION_CHECKLIST.md row 13.
Acceptance. pytest apps/api/app/backend/tests/core/test_mail.py; on staging one imported user's invite is delivered to the staging sandbox inbox and its token is redeemable through the API (GET /api/auth/accept?token= 200). The browser click-through sign-in (screenshot) is V2b's.
Out of scope. The SPA pages the emailed link lands on (V2b); a branded subject line (waits for a shared brand source — ADR-001 §2 keeps the product name in apps/web/src/shell/brand.ts only).
S7 · Readback job v0 · M · feat/s/s7-readback
Context. Every 15 minutes, performance per creative code lands in Skynet and a lineage node is written — the last hop of the loop.
Read first. S5a module; S4 lineage API; S3 normaliser; lock doc §4.3 snapshot table.
Deliverables. Migration 005_performance_snapshots.sql: core.performance_snapshots(workspace_id, creative_code, ad_id, ad_account_id, date, spend_native, spend_eur, impressions, reach, three_second, thruplay, link_clicks, purchases, purchase_value_eur, cost_per_purchase_eur, hook_rate, hold_rate, roas, breakeven_roas, seven_day_spend_eur, seven_day_purchases, seven_day_purchase_value_eur, seven_day_roas, seven_day_eligible, winner_loser_status, creative_hit, source_synced_at, snapshot_at, unique (workspace_id, creative_code, ad_id, date)). Job apps/api/core/readback/job.py: for each workspace with ecomprofits credentials → codes issued in the active readback window (READBACK_WINDOW_DAYS, default 90) (core.creative_codes) → creative_daily(account_id, codes, last 14 days) → upsert snapshots → one performance lineage node per (code, date) linked to the creative_code node. Scheduler: apps/api/core/scheduler.py (APScheduler or a while loop in the existing scrape_worker process — reuse supervisord program), interval READBACK_INTERVAL_MIN=15; GET /api/performance?code= for V10.
Acceptance. pytest apps/api/tests/core/test_readback.py on the S5a fixture (upsert idempotent; lineage node created once); on staging a stamped test creative's metrics appear in core.performance_snapshots within one cycle — post the row and the chain JSON.
Out of scope. Winner evaluation, alerts.
S7b · Command-line entry points for the readback and the launch-package reconcile · S · W4 · feat/s/s7b-job-cli (S1-rev)
Context. Discovered by S10 (open item #32). Neither app/core/readback/job.py nor app/core/launch_packages/job.py has a command-line entry, an admin route or a __main__ — the reconcile is reconcile-only by design (V27) and the readback runs on the worker's cadence (S7). So docs/runbooks/LOOP_E2E.md § 1 and the "close A6" procedure run both jobs as $COMPOSE run --rm --no-deps --entrypoint python api -c 'from app.core.readback import job; print(job.run_once())': a dataclass repr on stdout, no exit code an operator can act on, nothing that can be captured into an acceptance record, and a different idiom from every other operator command in the tree (-m app.core.scripts.readlayer_parity, -m app.core.scripts.r2_repoint). Both by-hand runs are safe beside the worker already — the readback takes pg_try_advisory_xact_lock per workspace and the reconcile leases its rows — so this card adds an entry point, never a second execution model.
Read first. app/core/readback/job.py (run_once, Report, WorkspaceReport, the advisory lock, _log); app/core/launch_packages/job.py (run_once, Report.error = the workspace scan failed, WorkspaceReport.errors = a count of per-row failures such as a 409 attach refusal, _claim_due); app/core/scripts/readlayer_parity.py and app/core/scripts/r2_repoint.py (the operator-CLI convention: main(argv) -> int, argparse, --json, both renders on stdout, exit 1 for an unmet gate and 2 for unusable input); app/backend/worker.py (_readback_due, _launch_packages_due, main's logging.basicConfig and WORKER_LOG_LEVEL); docs/runbooks/LOOP_E2E.md § 1; docs/ops/s10-acceptance.md hand-forwards.
Deliverables.
app/core/scripts/run_readback.pyandapp/core/scripts/run_launch_packages.py—python -m app.core.scripts.run_readback [--json]and… .run_launch_packages [--json], one cycle of the owning job'srun_once(). The job modules stay import-only: no__main__in either, so no module can be loaded twice under its own name.app/core/scripts/_job_cli.py— the shared skeleton the two commands drive through a per-job adapter: argparse (--json,--log-leveldefaulting toWORKER_LOG_LEVELthenINFO),logging.basicConfigon stderr in the worker's format so the job's own per-workspace lines are visible while stdout carries the report only (both renders — a--jsonrun pipes to a file), adatetime/date/DecimalJSON encoder over the report dataclasses, and the outcome/exit mapping.- One outcome per run, in both renders (the human render's first line and the JSON's
outcome), in priority order:SCAN_FAILED→ exit 2 (the reconcile'sReport.error: the workspace scan itself failed);ERRORS→ exit 1 (a workspace'serror, or the reconcile'sworkspace.errors > 0);NO_ELIGIBLE_WORKSPACES→ exit 1 (the population is empty — the readback has nocore.integration_credentialsrow, the reconcile no workspace with an in-window code: a configuration fact, never evidence of a cycle);WORKED→ exit 0 (the readback wrote snapshots; the reconcile created a row or claimed one);NO_WORK→ exit 0 (eligible workspaces, nothing to do — the reason carried per workspace:codes=0, no matched snapshot, the advisory-lock skip, or no due row claimed because rows are attached, leased or in backoff). A healthy reconcile does nothing on most cycles, soNO_WORKmust stay exit 0;did_workis the adapter's decision, never inferred generically. Usage errors keep argparse's ownSystemExit(2); an exception out ofrun_onceis exit 2 with its line on stderr. - Docs.
LOOP_E2E.md§ 1's by-hand section becomes the two commands with the--jsoncapture, the outcome meanings, the exit codes and the rule that only aWORKEDreport with the pilot's counters is valid hop evidence (hops 2 and 5 keep their "run once (§ 1)" pointers);docs/ops/s10-acceptance.md's #32 hand-forward (its historical sections are left as they were true when written);docs/ops/s7b-acceptance.md; open item #32 closed.
Acceptance. pytest apps/api/app/backend/tests/test_job_cli.py green (no database): exit 0 for WORKED and for NO_WORK, 1 for a workspace error, 1 for the reconcile's workspace.errors > 0, 1 for NO_ELIGIBLE_WORKSPACES, 2 for the reconcile's Report.error, 2 for an exception out of run_once, SystemExit(2) for a bad flag; the --json render parses, carries the outcome and agrees with the human render's counters; stdout holds the report only, with every log line on stderr. Plus one case per job in the docker core tier (test_readback.py, test_launch_packages.py) calling the command's main([]) against the real database. The full api suite green.
Out of scope. The synthetic cycle's entry point (app/core/synthetic/checks.py — a different report, its own alert state and notifications; ALERTS.md § 5's drills are about the worker's cadence, and open item #32 names these two jobs only); any admin route or UI; a --workspace filter or any other change to run_once; a scheduler.
Waits on / Unblocks. S7 (S), V27 (V) / S10's hop-2 and hop-5 by-hand runs; the "close A6" readback leg.
S10 · Creative-code loop E2E with one real ad · M · W4 · chore/s/s10-e2e-loop (S1-rev)
Deliverables. docs/runbooks/LOOP_E2E.md with proofs at each hop: (1) Production export with a code (V7) → (2) the package on the real Launch card of the pilot creative — V27's reconcile evidence: the job's log line, the Launch comment with working links, the lineage chain carrying the launch node (S8a is the bridge's side; no manual bridge POST) → (3) the media buyer uploads the ad with the assembled ad name → (4) the ecomprofits read, two variants: select date, ad_id, nc_creative_code, amount_spent_eur, purchase_value_eur, winner_loser_status, is_final from meta_ads_dashboard_view where nc_creative_code = '<raw code as the ad name spells it>' and level = 'ad' and is_latest order by date desc limit 20; as metabase_readonly (the view's security_invoker chain is not granted to skynet_readonly), and the loop proof select date, ad_id, creative_code_raw, spend_eur, purchase_value_eur, winner_loser_status, is_final from skynet_creative_daily where account_id = '<tenant>' and creative_code_raw = '<raw code>' order by date desc limit 20; as skynet_readonly (the DSN Skynet reads with) → (5) GET /api/performance?code=<code> (the primary proof) + the core.performance_snapshots row (S7; core/008_performance_snapshots.sql) → (6) GET /api/lineage/chain?type=creative_code&ref=<code>. Mismatches fixed in the owning card the same day.
Acceptance. Runbook with all six proofs on staging with a real ad id; Agon signs the task.
S12 · Migration dry run (production PG) + rollback scripts · M · chore/s/s12-migration-drill
Context. Production's data (the baseline's 65 tables and one materialized view — the card said 63) moves from the in-container Postgres to unified PG at go-live. The drill proves it twice before it counts. Built (ADR-011): the old dump lands as a legacy public copy and is copied into production over the shared columns (never pg_restore --clean); the checksums hash ROW(<shared columns>)::text (not t::text); rollback.sh starts the old app on its own host and restores the archive dump only on request — nothing repoints DATABASE_URL (the old container is on another host).
Read first. V3 spec (baseline + init guard); static-ads docker/init-db.sh (init guard L38, re-runs schema.sql every boot L72–97); schema.sql; implementation PRD §5 (migrate step guarantees) and §6.
Deliverables. infra/migrate/production/: dump.sh (pg_dump -Fc from the old host container), restore.sh (into unified production schema on staging), checksums.py (introspects each table's real primary-key columns from pg_index/pg_attribute, then writes checksums.sql with deterministic per-table queries: count(*) for every table, and for the 20 largest tables an ordered row hash md5(string_agg(t::text, '|' order by <real pk columns>)); never a literal order by pk), verify.sh (old vs new checksum table → docs/runbooks/MIGRATION_PRODUCTION.md with timings), rollback.sh (repoint DATABASE_URL to the old container + restore pre-migrate dump), all idempotent. Run the full drill on staging twice.
Acceptance. Runbook with two drill runs, all checksums equal, timings; rollback drill restores the pre-migrate dump under 15 min. Post the checksum table.
Out of scope. The go-live run itself (X7, Agon + Sead).
S12b · The restore re-runs 006's instructions backfill · S · W6 · chore/s/s12b-restore-instructions-backfill (S1-rev)
Context. Open item #47, discovered by V8b. ADR-011's restore copies every paired table over the column intersection, so a column the old schema lacks — every workspace_id a migration added — lands NULL, and the migration's own backfill, applied once by the ledger, never runs again. The restore re-ran #22's three workspace backfills inline and 003's data move from its file (and, since V8b, 008's), but not 006's: after the go-live restore storyboard_llm.load_instruction (SELECT … WHERE workspace_id = %s AND step = %s) and the System routes find no workspace row and fall back to the shipped file — the operator's edited prompts sit in the table, invisible, until someone runs the UPDATE by hand. The drill seeded no instructions table, so nothing caught it. Lane S: S12's script, by the plan's W5 assignment — the plan's exception to the package map's infra/ → A. The outcome this card buys: the restored operator prompts are the workspace's on the day, and the next migration that adds a workspace column to a paired table cannot forget the restore silently.
Read first. infra/migrate/production/restore.sh (the copy transaction, #22's inline backfills, the 003 and 008 re-runs), db/migrations/unified/production/006_workspace_instructions.sql (the DDL the ledger applied once; the one data step), 004/005 (the same shape), infra/migrate/production/test.sh + test-old-schema.sql (the drill), ADR-011 §5, ADR-014, pipeline/storyboard_llm.py::load_instruction.
Deliverables. (1) restore.sh: one inline counted UPDATE for instructions (user_id = 1 AND workspace_id IS NULL → the slug's workspace) beside #22's three — inline, not \i 006, because 006's DDL must not run twice under session_replication_role = replica and its data step is one line; the transcript line gains instructions (006, user 1's) <n>, the JSON report's backfills gains instructions. (2) The drill: the old instructions table (the baseline's DDL) with two legacy users' rows; after the restore user 1's row carries the workspace and user 2's stays NULL (006's own rule), the transcript and the report say so, verify.sh compares instructions equal (the backfilled column is not a shared column); pairs and table counts move; a second restore with S12_WORKSPACE_SLUG=other proves every backfill follows the slug and none spells rescale. (3) apps/api/app/backend/tests/test_restore_backfills.py: a migration that ADDS workspace_id to a production table AND backfills it owes restore.sh an inline line for that table — the scan over production/*.sql and restore.sh, with a negative case and a pin that every inline update takes the slug. (4) ADR-011 §5 names the four inline backfills, the two file re-runs and the rule; MIGRATION_PRODUCTION.md (the restore row, the transcript line, §4); docs/ops/s12b-acceptance.md; open item #47 closed.
Acceptance. The S12 drill green with its new checks (the backfill, the NULL row, the transcript, the report, the comparator's instructions line, the other slug case); test_restore_backfills.py green; shellcheck clean; gitleaks clean. docs/ops/s12b-acceptance.md; open item #47 closed.
Out of scope. The unmigrated legacy rows (006's decision); M2b's re-point (its own runbook step); 008 (V8b).
Waits on / Unblocks. S12 (S), V29 (V), V8b (V) / the go-live restore's prompts.
S13 · Cross-tenant isolation: the proofs, and Production scoped at its resolvers · M · W5 · test/s/s13-isolation (S1-rev)
Context. The core routes prove workspace isolation already (docs/SECURITY.md, the core table). Production, the imported app, does not: its reads are keyed by a global product name or a bare session id, and /api/winners and /api/canva are authenticated but never bind the request's workspace. "User A on a B-owned id → 404/403" is therefore impossible for the Production routes the card names without giving Production a workspace boundary; S13 builds it at the resolvers and proves it. S1 has one workspace, so the boundary costs nothing today and is what S5's multi-tenant operation stands on; RLS stays S5's.
Read first. docs/SECURITY.md; tests/core/test_isolation.py + tests/core/conftest.py (two_workspaces); services/workspace_scope.py + auth.py::request_scope; main.py (router dependencies, the error handlers); db_service.get_product_id / create_product / list_products and the session lookups; storyboard_service.get_storyboard_session_detail / list_storyboard_sessions + routes/video_storyboard._session_or_404; routes/localize_video.py; routes/winners.py; routes/canva.py; production/004_sessions_workspace.sql; .github/workflows/ci.yml.
Deliverables. (1) production/005_products_workspace.sql: products.workspace_id and storyboard_saved_characters.workspace_id (nullable until S12's restore; backfilled to the default workspace like production/004; indexed) — open item #22 gains both UPDATEs. (2) The resolvers scope: get_product_id filters by the workspace (the explicit id a create path resolved, else the bound scope; a caller with none is refused, 403, never served the world); create_product stamps it and resurrects only within it (a name owned by another workspace → 409; the global UNIQUE (name) stands until S5); list_products / deleted / restore / the admin overview list the bound workspace; every session read and write carries sessions.workspace_id = <bound> — the product-session lists and details, Localize Video's, Storyboard's, the winners, the system session lists, the canva polishes — and the helpers keyed by a bare session id (progress + SSE, images, input, approvals, the stamp, the localize file routes, the three storyboard image routes) check the product–session pair AND the workspace first (require_session); the saved-character library is per workspace; the run-side product lookups use the same resolver; winners and canva routers bind request_scope; the Canva routes that resolve a session_image_id or a polish_id (/open, /return, /polish/{id}/…, /polished) resolve within the caller's workspace; has-creatives resolves the product first. A product or session with no workspace is invisible to everyone (fail closed, like a run). (3) tests/core/test_isolation.py, the Production half on two_workspaces: the ten routes the card names, the lists, a write, a ?token= image route, the SSE first event, the wrong-product URL, the 409 name collision, the library, the Canva /open and /return write proofs, and the no-workspace legacy user → 403; each test truncates the Production rows it made. (4) docs/SECURITY.md: the Production table, the "not scoped in S1" list (Intelligence v2, labs, scraped ads, camera moves), the CI wording; docs/decisions/ADR-009-production-workspace-boundary.md; docs/ops/s13-acceptance.md; the CLAUDE.md paragraph.
Acceptance. pytest apps/api/app/backend/tests/core/test_isolation.py green, covered by the api job inside the required ci workflow; 0 leaks in the two tables; the full suite green (the legacy tier binds TEST_WORKSPACE_ID suite-wide, so the resolver's filter costs it nothing).
Out of scope. RLS (S5); per-workspace product names beyond the 409 (S5); the Intelligence v2 / labs / explore-experiments id-keyed and case-insensitive product reads, the scraped-ads mappings (S2) and camera moves — listed in SECURITY.md.
Waits on / Unblocks. S1a, S3, S4, S7, V4, V8 / S5's tenancy work.
M5 · Retention / archive plan · S · W5 (S1-rev)
Deliverables. docs/ops/RETENTION.md: old static-ads host read-only for the agreed post-cutover retention window, archive point (final dump + R2 prefix manifest), deletion owner + trigger (Agon, after the S1 exit gate and the retention window); the standalone scraper and console are NOT in scope (they stay live until S2/S3); Skynet backup retention (20 days local, Storage Box per its policy), pre-deploy dump folder policy (alert at 20 GB, never auto-pruned).
Acceptance. Doc merged; Agon and Vince acknowledged.
Lane B — Vince (Production & Brain)
| Wave | Key | Card | Size | Waits on | Unblocks |
|---|---|---|---|---|---|
| W1 | V1 | SSO in production API/UI | S | S1a (S) | X1 (A) |
| W1 | V2a | Shell skeleton | S | S1a (S) | A6 (A), V6 (V) |
| W1 | V3 | Production on unified Postgres | M | A1a (A) | A2 (A), A6 (A), M1 (V), S12 (S), X3 (A), X5 (A) |
| W2 | V4 | Model bindings + spend guard (core) | L | S1a (S), S4b (S) | S13 (S), V5 (V), V8 (V) |
| W3 | M1 | Static-ads per-user settings/keys migration | S | S1a (S), V3 (V) | — |
| W3 | V6 | Shell with links to the standalone apps | S | V2a (V) | V10 (V), V9 (V), X2 (V) |
| W3 | V7 | Creative code on export + lineage writes | M | S3 (S), S4 (S) | S10 (S), V10 (V) |
| W6 | V10a | Production polish — creative code, lineage drawer, performance footer (V10 split) | M | V2c (V), V31 (V), V7b (V) | — |
| W6 | V10b | Production polish — states, navigation, defaults, parity (V10 split) | L | V2c (V), V6 (V), V7 (V) | S13 (S) |
| W6 | V34 | The Explore instructions ship with the image | M | the owner's export (#64) | the loop plan's Step 4 on a fresh stack |
| W4 | V8 | Gateway client in production | M | V4 (V) | — |
| W4 | V27 | Launch-package producer for the existing Launch card | M | V7 (V), S4 (S), S8a (S) | S10 (S) |
| W6 | V29 | Per-run settings and workspace instructions | L | V8 (V), S13 (S), M1 (V) | — |
| W6 | V8b | The default renderer is the workspace's binding | M | V8 (V), V4 (V), S12 (S) | — |
| W6 | V2c | Web foundations layer | L | V2a (V), V6 (V) | V2b (V), V5 (V), V31 (V), V33 (V), V10a (V), V10b (V) |
| W6 | V2b | Accept, magic-link and reset pages | M | S1b (S), M3 (A), V2c (V) | — |
| W6 | V31 | The creative page /creatives/:code | M | V2c (V), V7 (V), V27 (V), S7 (S) | V10a (V) |
| W6 | V33 | The status page /status | M | V2c (V), X3 (A) | — |
| W6 | V7b | Winner rows carry their creative codes | S | V7 (V), S3 (S), S13 (S) | V10a (V) |
| W5 | M2 | R2 prefix reconciliation (ADR + build_key) | M | S1a (S), S13 (S) | M2b (M) |
| W6 | M2b | R2 re-point job | M | M2 (M) | M5 (M) |
| W5 | V13 | Contract/consumer tests + Production module docs | M | S1a (S), S3 (S), S4 (S), S4b (S) | — |
| W6 | V5 | Minimal admin page: bindings + spend | M | V4 (V), V2c (V) | — |
| W5 | X2 | Upload/download through proxy | S | A3 (A), A6 (A), V6 (V) | — |
| S4 | V9 | Launch-board native UI | L | S8 (S), V6 (V) | S11 (S) |
Static-ads facts every V card relies on: backend entry app/backend/main.py (routers L75–122; auth_dep L78, admin_dep L92, intelligence_dep L93; SPA mount L170–180); auth app/backend/auth.py (create_token L48, get_current_user L61–88 reads Bearer or ?token=); DB app/backend/db.py (get_pool L26, get_cursor L58); frontend app/frontend/src/App.tsx (routes L56–138, RequireAuth L47), components/layout/{AppLayout,Sidebar}.tsx, lib/api.ts (getToken L6 → localStorage.auth_token, authHeaders L18, ?token= at 22 call sites incl. getProgressStreamUrl L1021), SSE consumers in pages/products/SessionView.tsx:352 etc.; model calls: app/pipeline/utils/claude_client.py (_call_claude_impl L44, POST L129), openrouter_client.py (generate_image L57), embeddings.py (_post_with_retry L43), app/backend/services/analysis_llm.py::chat L53, fal_client.py::_request L198, heygen_client.py::_request L171, elevenlabs_client.py (_with_retry L128, _tts_request L483); image registry app/pipeline/config.py (IMAGE_MODEL_IDS L111, resolve_image_model L141, load_settings_from_db L189); settings app/backend/services/settings_service.py, encryption.py (Fernet, ENCRYPTION_KEY); export routes/products.py:213 → services/export_service.py::export_product L18, zips in routes/video_storyboard.py L1079/L1170, client zip lib/download.ts; tests pytest app/backend/tests (no DB fixture; conftest.py blocks real network); Docker: Dockerfile L38–56 installs Postgres, supervisord.conf programs postgresql/uvicorn/scrape_worker, docker/init-db.sh re-runs schema.sql on every boot with an init guard at L38.
V1 · SSO in production API/UI · S · feat/v/v1-sso
Context. One cookie must open Production. Today it mints its own HS256 JWT and the frontend keeps it in localStorage and appends ?token= to 22 image/SSE/download URLs. Rewriting those is not S1 work, so S1 keeps the token paths and swaps the token.
Read first. Lock doc §4.1 incl. "static-ads compatibility bridge"; app/backend/auth.py L48–88; app/backend/main.py L75–78; app/frontend/src/lib/api.ts L6–59 and the ?token= lines; packages/contracts/auth/verify.py + samples (S1a).
Deliverables. (1) auth.py: get_current_user decodes with verify_access_token from the contracts package (cookie rs_access → Bearer → ?token=, in that order); map claims → the existing user dict: email, is_admin = 'admin' in roles, can_see_intelligence = {'researcher','admin'} & roles, token_version = tv; look up users by email; if missing, create the row with email, display_name = claim.name, is_admin from roles, token_version = tv, and password_hash = a bcrypt hash of a random 32-byte secret (users.password_hash is NOT NULL) so legacy password login stays unusable when LEGACY_LOGIN_DISABLED=1. (2) Remove create_token usage from the API path and disable the legacy auth routes that actually exist today (app/backend/routes/auth.py, mounted under /api/auth): POST /api/auth/login, PUT /api/auth/me/password, PUT /api/auth/users/{user_id}/password. Keep GET /api/auth/me. Leave the /api/auth/users* admin-management routes in place until V11 replaces them. LEGACY_LOGIN_DISABLED=1 returns 410 on each disabled route until cutover cleanup. (3) lib/api.ts: getToken() reads localStorage.auth_token as today; add refreshSessionToken() = GET /api/auth/session-token → setToken(), called on app mount (main.tsx) and once on any 401. V2a owns the shell login UI and route wiring.
Interfaces. Consumes S1a. Exposes nothing new.
Steps. Verifier swap → route disabling behind flag → frontend token refresh → tests.
Acceptance. pytest app/backend/tests/test_auth_sso.py green (valid → 200; expired / wrong-kid / revoked-tv → 401; admin route with viewer token → 403); manual on local stack: with a valid rs_access cookie present, open a Production page and an <img src=…?token=…> — both load; after POST /api/auth/logout, both 401 within 60 s (screenshots or curl transcript).
Out of scope. Removing ?token= (V21, later slice); users/roles UI (V11, later slice).
V2a · Shell skeleton · S · feat/v/v2a-shell
Context. The unified frontend is the static-ads frontend grown into a shell. The nav becomes the product's map.
Read first. Functional design §3 (modules), §5; App.tsx L47–138; Sidebar.tsx L42–44, L107–307; index.css @theme L7–56; lock doc §4.1 cookie names.
Deliverables. (1) src/shell/: ShellLayout.tsx (top nav: Intelligence · Strategy (stub page "a later slice") · Studio · Production · Workspace · Inbox (stub) · Admin; workspace switcher reading /api/auth/me — consumes exactly the S1a shape {id, email, name, ws, ws_slug, roles, permissions, feature_flags?}), routes.tsx (route folders /intelligence, /strategy, /studio, /production/* = all existing routes moved under the prefix with redirects from old paths, /workspace, /inbox, /admin), auth/ (LoginPage posting to /api/auth/login), RequirePermission.tsx (gates on permissions[], never on role names). (2) Theming: keep tokens; product name appears only in the nav brand component (BRAND_NAME const, one place). (Invite/reset pages are delivered in V6 after S1b; the session-token refresh hook is V1's.)
Acceptance. pnpm --filter web build clean; pnpm --filter web lint clean; local: every existing Production page renders under /production/*; old URLs redirect; a user lacking admin.settings.manage cannot open /admin (screenshot).
Out of scope. Legacy mounts (V6), launch board (V9), admin content (V5).
V3 · Production on unified Postgres · M · feat/v/v3-production-pg
Context. Production's Postgres lives inside its container today (supervisord starts it; init-db.sh re-runs schema.sql every boot). On unified PG it becomes schema production with real migrations.
Read first. Dockerfile L38–56, L131–140; supervisord.conf; docker/init-db.sh L12–19, L38–97; schema.sql (63 CREATE TABLE IF NOT EXISTS, migrations block from L336 — idempotent ALTER … IF NOT EXISTS); app/backend/db.py; implementation PRD §5 (migrate step: ledger, checksum, pre-dump).
Deliverables. (1) db/migrations/unified/production/001_baseline.sql = schema.sql rewritten for schema production (create schema if not exists production; set search_path = production, public; + create extension if not exists vector in public), unchanged otherwise. (2) apps/api/migrate.py (the runner, shared with core): applies db/migrations/unified/*/NNN_*.sql in order inside one transaction each, records in core.schema_migrations(version, checksum, applied_at), refuses a changed checksum, --dry-run, exit ≠ 0 on error; docker compose run --rm migrate entrypoint. (3) db.py: DATABASE_URL + options=-c search_path=production,public. (4) Dockerfile: remove Postgres install (L38–56), init-db.sh, postgresql program; keep uvicorn + scrape_worker. (5) docs/MIGRATIONS.md (forward-only, additive during S1, how to add a file). (6) Ops endpoints in app/backend/main.py: GET /api/healthz → {"ok":true} once the API is up and can read the migration ledger; GET /api/version → {"sha":"<git sha>","migration_head":"<latest applied version>"} (sha baked in at build via GIT_SHA build arg). These are the endpoints A2/A6/X3/X5 and the smoke gate use.
Acceptance. python apps/api/migrate.py --dry-run lists 001; pytest app/backend/tests green against unified PG in the existing docker-compose.yml dev stack (postgres service added there; A2 introduces the monorepo compose files later); full Explore + Storyboard run on the local stack (session created, images generated, storyboard delivered — screenshots); image no longer contains postgres binary (docker run … which postgres → empty); curl -s localhost:8000/api/healthz → {"ok":true} and curl -s localhost:8000/api/version returns the current git sha plus the latest applied migration version.
Out of scope. Data migration of the live DB (S12/X7); R2 changes.
V4 · Model bindings + usage events + spend guard · L · feat/v/v4-bindings-spend-guard
Context. Which model runs which job becomes a setting, not a deploy; spend gets a guard. Production is the first consumer; sidecars post usage later.
Read first. S4b contract (packages/contracts/bindings); app/pipeline/config.py L111–216 (IMAGE_MODEL_IDS, CLAUDE_MODEL, load_settings_from_db writing module globals); app/backend/services/system_settings_service.py (resolve L280, set_value L294); technical plan §2.4.4.
Deliverables. (1) Migration core/006_bindings.sql: core.providers(id, workspace_id, kind check in ('openrouter','vendor','claude_subscription'), name, base_url, config_encrypted, created_at), core.models(id, provider_id, slug, modality check in ('chat','image','video','tts','embedding'), price_in_usd, price_out_usd, active), core.model_bindings(workspace_id, workflow_key, model_id, provider_id, updated_by, updated_at, pk(workspace_id, workflow_key)), core.usage_events(id bigserial, workspace_id, service, workflow_key, model, tokens_in, tokens_out, cost_usd numeric(12,6), request_id, created_at), core.spend_guards(workspace_id pk, window_hours default 24, warn_usd, pause_usd, paused_at, paused_by, resumed_at). Seed for rescale: OpenRouter provider (key from env), models = today's CLAUDE_MODEL, LONGFORM_OPUS_MODEL, HAIKU_MODEL, GENDER_DETECT_MODEL, the three IMAGE_MODEL_IDS; bindings for workflow keys production.concepts, production.longform, production.image, production.analysis, production.embeddings, production.storyboard.llm, production.video, production.tts. (2) apps/api/core/bindings/: GET /api/bindings/:workflow (resolves binding → provider config decrypted server-side, returns the contract shape + paused + guard state), POST /api/usage (service token or user), PUT /api/admin/bindings/:workflow, PUT /api/admin/spend-guard, POST /api/admin/spend-guard/resume; admin read endpoints required by V5: GET /api/admin/bindings (all workflow keys, current binding, available models grouped by modality, masked provider metadata) and GET /api/admin/spend?window=24h|7d (totals per provider and workflow from core.usage_events plus the current guard state); guard.py (rolling sum over window_hours; ≥ warn_usd → one Discord/Telegram message per hour; ≥ pause_usd → set paused_at; SPEND_GUARD_KILL_SWITCH=1 env pauses everything). (3) apps/api/core/gateway.py: @gated(workflow_key) decorator: pre-call resolve_binding() → raise SpendPaused (HTTP 402, message "Spend guard paused this workspace at €X; an admin can resume in Admin → Spend") if paused; post-call record_usage(tokens, cost); cost from models.price_* or the provider's usage field.
Interfaces. Exposes the contract endpoints; V8 wires production calls; V5 is the UI.
Acceptance. pytest app/backend/tests/core/test_bindings.py green: change the binding for production.concepts → next resolve_binding returns the new model (no restart); usage of 3 events sums correctly; crossing pause_usd pauses and the decorator raises 402 with the message; resume clears. Post output.
Out of scope. The admin UI (V5), wiring the call sites (V8), per-user keys (M1).
V2c · Web foundations layer · L · W6 · feat/v/v2c-web-foundations (discovered by the S1 pages UX design, S1-rev 2026-09-07)
Context. The five remaining S1 frontend surfaces (V2b, V5, V31, V33, V10a/V10b) would each hand-roll the same missing pieces: the app has no toast, alert, skeleton, table, sheet, tooltip, label, empty-state component, no 403/404 surface, no per-page document title, no aria-live region and not one htmlFor/id pair (final/skynet-s1-pages-ux-design.md §2; the reader reports in development-processes/skynet/ux-design/). It has three error treatments, two label styles, raw palette colours beside tokens, alert() in four files, a login page off the design system, gates that redirect silently, a ConfirmDialog that swallows errors and no money or date formatter. The muted text token measures 4.72:1 on the page background but 4.44:1 on cards (WCAG AA needs 4.5). The web app has no unit-test runner (apps/web/src/README.md). This card builds the shared layer once so the five pages cannot diverge.
Read first. final/skynet-s1-pages-ux-design.md §1–§2 (the principles, the component contracts, the token change, the lint rules); apps/web/src/index.css; components/ui/* (button.tsx, dialog.tsx, confirm-dialog.tsx, segmented-toggle.tsx — the @base-ui/react 1.3.0 + cva idiom); shell/RequirePermission.tsx, shell/routes.tsx (LegacyRedirect), shell/pages/ModuleStub.tsx; lib/api.ts:402-457 (handle401, ApiError, apiError), lib/utils.ts:170-200 (EYEBROW_CLASS, formatRelativeTime); pages/system/Settings.tsx:329-338 (the canonical catch); pages/team/TeamPage.tsx:152-241 (the table shape); pages/products/SessionsList.tsx:640-662 (the empty state); pages/system/PastSessions.tsx:132-141 (the one keyboard-reachable row); e2e/ (Playwright), .github/workflows/ci.yml web job, docs/CI.md; docs/ops/v2a-acceptance.md:13,66 and docs/ops/v6-acceptance.md:28 (the pinned denial redirect).
Deliverables. (1) components/ui/: toast.tsx (Toaster, Base UI Toast, one region mounted in App.tsx; the manager and the toast.success / toast.error calls live beside it in lib/toast.ts — amended at build time so the component file exports only a component and so lib/download.ts can raise a message without importing the viewport —, aria-live polite for success and role=alert for errors, 4 s auto-dismiss paused on hover/focus, errors stay, max 3), alert.tsx (variant error|warning|info|success, icon, title?, action?, onDismiss?, role per variant), skeleton.tsx, empty-state.tsx (icon, one sentence, at most one action), table.tsx (Table, THead, TBody, TR, TH, TD; align, tabular-nums on right-aligned cells, TR interactive = tabIndex 0, role=link, Enter/Space), sheet.tsx (Base UI Drawer, right side, focus trap, Escape, focus return), tooltip.tsx (Base UI Tooltip, 300 ms), form-field.tsx + label.tsx (Label htmlFor, help, error, aria-describedby, aria-invalid), copy-button.tsx (icon-xs ghost, Copy → Check for 1.5 s, aria-live "Copied"), money.tsx (Intl.NumberFormat currency, approximate → a leading ≥, — for null), relative-time.tsx (formatRelativeTime + the absolute time in a tooltip and <time dateTime>, live re-render every 30 s), page-meta.tsx (document.title = "<title> · <BRAND_NAME>", restored on unmount), route-error.tsx (a React error boundary — the app uses the declarative <BrowserRouter> + <Routes>, so no errorElement — keyed on location.pathname, "Something went wrong on this page.", Reload + Go to Production), lineage-chain.tsx (the GET /api/lineage/chain response as a depth-ordered rail with the root marked, relation connectors, a Show-attributes Collapsible per node, the human reference per node type from the design's §5.4). (2) shell/pages/Forbidden.tsx ("You don't have access to this page" + the permission name + Go to Production) and NotFound.tsx; RequirePermission renders Forbidden in place instead of <Navigate to="/production">; LegacyRedirect keeps the legacy-family rewrite and renders NotFound for the rest. (3) lib/useLoad.ts ({data, error, loading, reload}, an AbortController per call, a sequence number so an older response never overwrites a newer one) and lib/api.ts::describeError ({message, kind: api|session|network}, extracted from Settings.tsx:329-338). (4) The token line: --muted-foreground oklch(0.60 …) → oklch(0.62 …) (index.css:70); no other token moves. (5) ESLint rules scoped to components/ui/** and the files this card adds (shell/pages/{Forbidden,NotFound,DevGallery}.tsx, lib/{useLoad,format,lineageApi}.ts, test/**) — never shell/** as a whole while the login page carries its 18 zinc-*/red-400 classes (V2b's rewrite); each later card widens the list with its own directory: no raw Tailwind palette class (text|bg|border|ring…-<colour>-<number>), no title= on a Button, no alert(. (6) The first web unit-test runner: vitest + @testing-library/react + @testing-library/jest-dom + jsdom, npm test, vitest.config.ts sharing the @ alias, a test step in the CI web job; @axe-core/playwright in e2e/. (7) A gallery route /dev/ui rendering every component in every state — the Playwright screenshot source for this and the later cards — present only when the build sets VITE_UI_GALLERY=1 (amended from import.meta.env.DEV at the implementation co-plan: DEV is false in vite preview and in the skynet-web image, so the acceptance build is VITE_UI_GALLERY=1 npm run build and a production image never carries the route); the route sits outside RequireAuth (it renders static markup and imports no page module that fetches). (8) Docs: apps/web/src/README.md § the shared layer; docs/CI.md (the web job gains npm test); docs/ops/v2a-acceptance.md:13,66 and docs/ops/v6-acceptance.md:28 each gain a dated line ("since V2c the guard renders the Forbidden page in place; the viewer still cannot open /admin"); docs/INTEGRATION_CHECKLIST.md row 9's expectation becomes "refused: the Forbidden page names admin.settings.manage"; docs/ops/v2c-acceptance.md with the contrast table and its script.
Interfaces. Exposes the components, useLoad, describeError, Forbidden/NotFound, the /dev/ui route, npm test. Consumes nothing new from the api.
Steps. The test runner + the CI step → the token + the lint rules → the primitives with unit tests → Forbidden/NotFound + the two guards → /dev/ui → the record lines and docs → the acceptance record.
Acceptance. npm test green (every component has a test; useLoad: two overlapping calls resolving in reverse order leave the second's state; RouteError: a throw, a navigation, the next route renders; RequirePermission: without the permission the page names it, with it the child renders; Money/RelativeTime formatting incl. — for null; Toaster announces through aria-live); npm run build green; npm run lint clean for the scoped directories; the CI web job runs the tests; a Playwright pass over /dev/ui with @axe-core/playwright reporting zero violations and one screenshot per component state in docs/ops/v2c/; the contrast script's table in docs/ops/v2c-acceptance.md shows every text token ≥ 4.5:1 on every surface it is used on; as viewer, /admin shows the Forbidden page naming the permission (an e2e case).
Out of scope. Any of the five pages (V2b, V5, V31, V33, V10a/V10b); converting the imported Production pages to the new components (V10b); a light theme; a storybook.
Waits on / Unblocks. V2a (V), V6 (V) / V2b (V), V5 (V), V31 (V), V33 (V), V10a (V), V10b (V).
V2b · Accept, magic-link and reset pages · M · W6 · feat/v/v2b-accept-pages (discovered by M3; S1-rev 2026-09-07: S → M, designed without Figma)
Context. The emailed invite / magic-link / reset URL is PUBLIC_ORIGIN/api/auth/accept?token=, which Caddy hands to the api: a person who clicks it sees the JSON preview. Completing an invite or reset needs POST /api/auth/accept {token, password} (a magic link: the same POST without a password); nothing in the SPA issues it (shell/routes.tsx has /login only), and /login has no "email me a link" or "forgot password" form.
Read first. apps/api/app/core/auth/router.py (GET /accept preview, POST /accept, POST /reset/confirm, the two 202 request routes and their uniform bodies — ADR-001 §5), auth_tokens.accept_url, apps/web/src/shell/auth/LoginPage.tsx, shell/routes.tsx; M3's templates (what the email says the link does).
Deliverables. /accept?token=: reads the preview; invite/reset → a set-password form; magic → one-click continue; posts; lands signed in; every 410 → one uniform "this link is invalid or expired" state. /login gains "email me a sign-in link" and "forgot password" forms that post the two 202 routes and show ONE uniform "check your email" state (never whether the account exists). auth_tokens.accept_url → the SPA path (+ its test); the emails need no change. The Figma link with the node id comes first.
Acceptance. npm run build green; the browser click-through on staging — the invite from M3's sandbox inbox, the password set, the app open — with a screenshot (the leg M3 hands over); pytest apps/api/app/backend/tests/core/test_users.py green on the new URL.
Depends. S1b, M3.
S1-rev 2026-09-07 (size S → M; the design replaces the Figma link). The design is final/skynet-s1-pages-ux-design.md §3: /login = one card on the design system with three modes (password · link · reset) and ONE sent state identical for known, pending and unknown addresses; every failed login shows two sentences, always ("Invalid email or password." and "If your account recently moved, request a sign-in link and check your inbox." — open item #27, ADR-015); /accept?token= = one card with four bodies by the preview's kind (invite, migrate, reset, magic — the magic body posts only on the person's click), every 410 = one uniform invalid-or-expired state with a way to request a new link, success lands on /production. The api half: auth_tokens.accept_url → ${PUBLIC_ORIGIN}/accept?token= with its test AND every consumer of the old shape — docs/INTEGRATION_CHECKLIST.md rows 13/13b, docs/ops/m3-acceptance.md:21, docs/ops/s1d-acceptance.md, docs/AUTH_SESSION.md §3, docs/GO_LIVE.md 9b / docs/CUTOVER.md 9b where they name it, the auth_tokens.py:189 docstring, the router.py:683 comment, the delivery-link fixtures tests/core/test_mail.py:29 and tests/core/test_migrated_first_contact.py:456,510,514-515 (the direct API-preview tests stay — they test the API); api.login throws ApiError (today a bare Error) so the page tells 401 from 429 from 5xx. Built on V2c's foundations (FormField, Alert, Skeleton, PageMeta, RouteError). Acceptance adds e2e/auth-pages.spec.ts (the design's §3.8: the two sentences; an identical sent DOM for a known and an unknown address; an invite's link opens the SPA /accept, the short-password error is client-side with no POST, the valid password lands on /production; the same link again → the invalid state; a migrate token shows "Your account has moved"; a magic token posts nothing before Continue; axe clean; Tab reaches every control with a visible ring), screenshots in docs/ops/v2b/, docs/ops/v2b-acceptance.md. Waits on V2c as well.
Build note 2026-09-07 (what the card did beyond its text). A defect the card had to fix to be honest: bcrypt refuses a password over 72 BYTES rather than truncating it (bcrypt 5.0), so before this card a long passphrase raised inside EVERY rail that stores one — POST /accept, POST /password (S1e's), and the two legacy admin rails POST /api/auth/users and PUT /api/auth/users/{id}/password, which are live wherever LEGACY_LOGIN_DISABLED is unset (staging included); POST /login was saved only by a ValueError catch already in _password_matches, and the startup admin and the seed scripts would have died on bcrypt's own message naming no variable. V2b ships the first screens where a person chooses a password, so the rule now lives in ONE module, app/core/auth/passwords.py (an 8-character floor counted in characters, a 72-byte ceiling counted in bytes), and every writer in both packages asks it — the legacy rails after their own 410 gate, and the environment-supplied passwords through refuse_unusable, which names the variable. apps/web/src/shell/auth/passwordRule.ts mirrors the rule and also counts bytes, so the page refuses what the api would refuse and never posts it. tests/core/test_password_length.py pins every rail, including the three login legs that were already safe. (The two legacy rails were found by review round 1, not by the build.) Three smaller decisions. formatTimeUntil is new in lib/format.ts — formatRelativeTime computes now − t and reads a future instant as "just now", so the expiry line could not have come from it or from RelativeTime; the line is prose with a <time dateTime> rather than the component, which would add a tab stop and a tooltip to a sentence. Signing in CLEARS the module-level sessionsCache (one signInWith shared by api.login and api.accept): /accept can replace a live session with a different person's without a page load, and those rows belong to whoever loaded them. The accept page owns its preview effect instead of useLoad, which describes an error as one sentence — this page must tell a spent link (410, its own state) from a server that did not answer (a Retry). The e2e shape. Legs 1–4, 6 (magic) and 7–8 run in e2e/auth-pages.spec.ts; the magic token has no HTTP source (/magic-link answers {"ok": true}), so the CI job mints one the way an operator does — the api's own relay-by-hand log line — and passes it as E2E_MAGIC_TOKEN, which the spec REQUIRES under CI so a missing one fails instead of skipping. Leg 5 (migrate) is proven at unit level (the four kind bodies) plus the server tests that mint the kind and the staging click-through of integration-checklist row 13b: minting one in CI would mean seeding a legacy production.users row in SQL from the workflow, which buys one label over the invite leg's identical code path.
V6b · Scraper-admin SSO verifier · S · feat/v/v6b-scraper-sso
DROPPED (S1-rev): the scraper stays standalone until S2.
V6 · Shell with links to the standalone apps · S · W3 · feat/v/v6-shell-links (S1-rev)
Context. Day-14 shell = Production native + links. No mounts, no SSO into the standalones.
Read first. Architecture doc §2 (boundaries), functional design §5; V2a routes.
Deliverables. Nav entries: Intelligence → https://meta-ads.rescale.media (new tab, label "opens the scraper — separate login for now"); Studio → the console URL (same treatment); Workspace → ClickUp Launches list deep link + the pilot placeholder ("native boards come with the Workspace slice"); Strategy, Inbox stubs with one sentence each; Admin → V5. Link targets from env (LINK_SCRAPER_URL, LINK_STUDIO_URL, LINK_CLICKUP_LAUNCHES_URL). docs/INTEGRATION_CHECKLIST.md rows: login, each nav entry, logout, one SSE stream, one upload.
Acceptance. Checklist green on staging (screenshots); viewer cannot open /admin.
Out of scope. Any native Intelligence/Studio page (S2/S3).
M1 · Per-user settings/keys migration · S · feat/v/m1-settings-migration
Context. Each user's encrypted OpenRouter key and generation settings must survive the move and become workspace + user scoped.
Read first. schema.sql L159–174 (settings columns incl. api_key_encrypted, Canva tokens), app/backend/encryption.py, settings_service.py, config.py::load_settings_from_db L189–216.
Deliverables. Migration core/007_user_settings.sql: core.user_settings(workspace_id, user_id, key text, value_encrypted text, updated_at, pk(workspace_id,user_id,key)); data migration in production/002_settings_to_core.sql (copy settings rows for every user → keys openrouter_api_key, claude_model, default_aspect_ratio, images_to_generate, images_to_keep, canva_*; same Fernet ciphertext, same ENCRYPTION_KEY); settings_service.py reads/writes core.user_settings for the current user + workspace; load_settings_from_db(user_id) unchanged in signature, new source. instructions table stays in production (seed for a later slice overlays).
Acceptance. pytest app/backend/tests/test_settings_migration.py (round-trip of a known ciphertext); on staging every imported user's settings page shows their values; a generation uses the user's key (log line masked).
Out of scope. Key rotation; per-workspace provider keys (that is V4 providers).
V7 · Creative code on export + lineage writes · M · feat/v/v7-export-code-lineage
Context. The stamp. Every exported asset carries a creative code from S3; the chain is written to lineage.
Read first. Lock doc §4.2 (stamping rules); S3 routes; S4 API; routes/products.py:213 POST /{product}/export → export_service.export_product L18; routes/video_storyboard.py zip paths L1079/L1170; lib/download.ts (downloadImagesAsZip L17); product_creatives.creative_code (human-typed today, routes/products.py:450).
Deliverables. (1) Export flow: on export of a concept/asset, call POST /api/creative-codes (kind from asset type: image → TM, video → RC; team_made = session mode Explore → true, Expand → false; product_number from the product's naming product number — add products.product_number if absent, editable in product settings) → stamp: filename <code>__<concept-slug>.<ext>, manifest.json {code, product_number, session_id, concept_id, naming_version:'v3', assembled_ad_name?}, the copy-ready ad name from POST <WORKOS_SERVICE_URL>/naming/ad-name when S8 is live (else omitted, logged). (2) Lineage batch to S4: nodes session → concept → asset → export → creative_code, edges in order. (3) product_creatives upload form: creative_code becomes read-only, prefilled from the issued code; existing free-text codes are left as-is and reported by a new script scripts/report_unmatched_creative_codes.py added in this PR (CSV output: product, creative_code, reason). (4) Client zip (download.ts) uses the stamped filenames from the API response.
Acceptance. pytest app/backend/tests/test_export_code.py (mocked S3/S4: filenames + manifest carry the code; lineage batch posted once); manual: export one concept on the local stack, unzip, show manifest; GET /api/lineage/chain?type=creative_code&ref=<code> returns 5 nodes (paste JSON).
Out of scope. Uploading to Meta; winner display (V10).
V8 · Gateway client in production · M · feat/v/v8-gateway-client
Context. Every outbound model call in Production goes through the binding + guard from V4. There are seven choke points.
Read first. V4 gateway.py; call sites: claude_client.py::_call_claude_impl L44 (workflow production.concepts / production.longform chosen by caller — add a workflow_key kwarg with defaults per caller module), analysis_llm.py::chat L53 (production.analysis), openrouter_client.py::generate_image L57 (production.image), embeddings.py::_post_with_retry L43 (production.embeddings), fal_client.py::_request L198 and heygen_client.py::_request L171 (production.video), elevenlabs_client.py::_tts_request L483 (production.tts); render_provider.py (active_provider L49) and config.py::resolve_image_model L141.
Deliverables. Each choke point wrapped with @gated(workflow_key): model slug + base URL + key come from resolve_binding() (fallback to today's env only when no binding exists, with a warning log); usage posted with real token counts where the provider returns them (OpenRouter usage), else the model's price_* × estimate; resolve_image_model reads bindings (production.image default; the UI's model dropdown lists core.models with modality image). Remove the import-time binding of OPENROUTER_API_KEY in claude_client.py L8/L132 (late-bind).
Acceptance. pytest app/backend/tests/test_gateway_client.py (each choke point calls resolve_binding and record_usage; paused → 402 surfaces to the route as a clear error); staging: run one Explore session → Admin → Spend shows usage rows for production.concepts and production.image (screenshot); set pause_usd low → next run blocked with the message.
Out of scope. Sidecar usage posting (later slices), pricing accuracy beyond OpenRouter's usage field.
V27 · Launch-package producer for the existing Launch card (POST /launches/:id/package) · M · W4 · feat/v/v27-launch-package (S1-rev)
Context. S1 exit gate item 5: the export package attached to the existing ClickUp Launch card through the workos bridge. No producer exists (open item #18, ADR-005 §5). The bridge's code-only ad-name path already resolves the linked Launch and withholds with no launch links this creative yet until one exists, so the attach is an eventual reconcile, not an export-time side effect. The producer also makes the package durable from the app side and writes the export node's missing manifest_key.
Read first. docs/decisions/ADR-005-launch-package-contract.md; packages/contracts/launch/README.md + both schemas + fixtures; packages/contracts/lineage/README.md (the export node's manifest_key + additive assembled_ad_name re-post; the reserved launch node type, ref_id = the ClickUp task id, and the launched relation); apps/api/app/backend/services/stamp_service.py (_assembled_ad_name, _issue_or_reuse: the register row's asset_key is the image's R2 key, the storyboard deliverable's is storyboard-delivery); export_service.py and routes/video_storyboard.py (_stamped_manifest, get_manifest, clips.zip, the ChatCut download routes); app/core/readback/job.py + worker.py::_readback_due (the cadence pattern); app/core/lineage/service.py; app/core/auth/service_tokens.py; app/backend/auth.py::get_current_user (cookie → Bearer → ?token=); rescale-workos service/src/modules/skynet-bridge/{routes,auth,routes.test}.ts; task-specs S10, S8a, V7; open-items #15, #18.
Deliverables.
- Bridge additive delta (rescale-workos, LOCAL branch
feat/skynet-bridge-s1, hand-forward row in open-items): on the code-onlyPOST /naming/ad-namesuccess pathAssembledgains optionallaunch_task_id(the Launch the name was assembled from). No new bridge route. Contract:ad-name.schema.json#Assembled.launch_task_id, the fixtures re-copied from the new workos commit, README source commit + hashes, the Python fixture-hash pins, the apps/api consumer test. - Durable package URLs:
GET /api/creatives/{code}/package/manifest.jsonandGET /api/creatives/{code}/package/{filename}— stable app routes that redirect to fresh presigned R2 URLs, guarded by the transition verifier (get_current_user: thers_accesscookie first, so a browser click from ClickUp works for a signed-in reader; Bearer and?token=as today). Cross-workspace → 404. - Manifest snapshot + export-node update, at first attach: the manifest object is stored in R2 under a key by the EXPORT identity,
exports/<workspace>/<session_id>/<export_ref_id>/manifest.json(the lineage contract keys the manifest by the export, not by the code) — a TM code: the image-export serialisation as a ONE-entry JSON array; an RC code: the storyboard manifest object plus the stampedcreative_codeblock — and the export lineage node is re-posted additively with{manifest_key, assembled_ad_name}. - The reconcile job
app/core/launch_packages/job.py::run_once()+ a worker cadence hook beside the readback (LAUNCH_PACKAGE_INTERVAL_MINdefault 15, 0 disables;LAUNCH_PACKAGE_WINDOW_DAYSdefault 30). One cycle, per workspace: the register's codes issued in the window are the population (rows are created lazily from the register — a code stamped before this card lands is covered, and stamping stays unaware of packages);export_ref_idis read from the lineagestampededge into the code's node; due rows are CLAIMED with a lease (next_attempt_atmoved ahead before the bridge is called), so two workers never post the same code and no database lock is held across the bridge's round trips; rows whosenext_attempt_atis in the future are skipped. Per code: the existing code-only ad-name call → withheld = pending with the bridge's reason (never an error) → assembled +launch_task_id: build the fullPackageRequestProducer(assets below), validate it against the contract BEFORE the wire (in code —app/core/launch_packages/contract.py, pinned to the schema by test, because the api image carries nopackages/contracts), store the manifest snapshot,POST /launches/{launch_task_id}/package, then in ONE core transaction upsert the row (attached,bridge_already), write thelaunchnode + thelaunchededge, re-post the export node, and emitlaunch.package_attachedto the outbox (audit-only: no consumer before S14). 404 / 409 / 5xx →errorwithattempts,last_error,next_attempt_at(backoff), retried on later cycles; 401 →_assembled_ad_name's one-fresh-token rule. A workspace's failure is a report line; nothing raises out ofrun_once. - State:
db/migrations/unified/core/NNN_launch_packages.sql(next free number) —core.launch_packages(id, workspace_id, code, export_ref_id, launch_task_id, manifest_key, ad_name, assets jsonb, status ∈ pending|attached|error, bridge_already, attempts, last_bridge_reason, last_error, last_attempt_at, next_attempt_at, attached_at, created_at, updated_at, UNIQUE (workspace_id, code));GET /api/creatives/{code}/packageanswers the row (the S10 evidence surface and the support answer to "did it attach?") — read-only, no attach-now route, no UI. - Assets under the current contract: a TM code → its stamped image (
<code>__<concept>.<ext>, kind image, the register row'sasset_key); an RC code → the newest ChatCut assembly that produced a file (the newestchatcut_assembliesrow withr2_key, the delivery's own rule) when one exists, else each scene's selected clip as one video asset each — the versionstoryboard_scenes.current_video_version_idpoints at, itsr2_key_voicedwhen present elser2_key(the delivery's choice; nevermute, neverassembly), named<code>__scene_01.mp4,<code>__scene_02.mp4, … (the delivery's zero-padded position).clips.zipstays a delivery route, not a package asset, unless ADR-005 andAssetProducerchange first. - S10 hand-forward: the loop runbook's hop (2) proof becomes the reconcile's evidence — the job's log line, the Launch comment with working links, the lineage chain carrying the
launchnode — instead of a manual bridge POST.
Interfaces. Consumes: the S3 register, S4 lineage + outbox, both S8a bridge routes, R2, the in-process service-token mint. Exposes: the two package file routes + the read route, core.launch_packages, the cadence, the launch lineage node and the launched relation.
Steps. Bridge field + contract re-copy → migration + state seam → package file routes + manifest snapshot → reconcile job + worker cadence + backoff → lineage re-post + launch node/edge → tests + acceptance record + S10 hand-forward + open-items rows.
Acceptance. pytest apps/api/app/backend/tests/test_launch_package.py (fake bridge) + tests/core/test_launch_packages.py (Postgres) green: withheld → pending row, no package POST, no write beyond the row; assembled + launch_task_id → the manifest object stored, the exact producer body validated, posted once, one row attached, the launch node + launched edge, the export node carrying manifest_key + assembled_ad_name, one outbox event; a repeat cycle posts nothing; already: true → attached; 404 / 409 / 5xx → error with backoff, retried next cycle; 401 retried once only after a cached token; a code outside the window untouched; the file routes redirect in-workspace and 404 cross-workspace; test_migrate carries the new migration in BOTH version lists (the plan orders core/* before production/*, so the head stays production/004_sessions_workspace and its three asserts do not move). pnpm --filter contracts test + pytest packages/contracts green after the fixture re-copy. Live leg (after open item #15 deploys the bridge): on staging export one Explore image, link its Creative to a sandbox Launch, wait one cycle → the Launch comment with working links, GET /api/lineage/chain?type=creative_code&ref=<code> with the launch node, both pasted into docs/ops/v27-acceptance.md.
Out of scope. Creating Launches; any launch list/read API; an attach-now route or UI; the lineage drawer (V10); Meta upload; outbox consumers.
Waits on / Unblocks. V7 (V), S4 (S), S8a (S) / S10 (S).
V29 · Per-run settings and workspace instructions · L · W6 · feat/v/v29-per-run-settings (S1-rev)
Context. Open item #23, surfaced by V8 and left unfixed there. app/pipeline/config.py::load_settings_from_db(user_id=1) does not read a caller's settings — it ASSIGNS MODULE GLOBALS for the whole process (OPENROUTER_API_KEY, CLAUDE_MODEL, DEFAULT_ASPECT_RATIO, IMAGES_TO_GENERATE, IMAGES_TO_KEEP), and eleven pipeline entries call it with no argument, so every run of every workspace executes on legacy user 1's model, aspect ratio and image counts; 13 non-test sites read those globals afterwards. Passing the run's creator instead would turn a benign race into a real one — the image gate runs up to MAX_CONCURRENT_IMAGE_JOBS runs at once, plus the worker, and they share the globals. The same identity defect runs through the storyboard's operator instructions (storyboard_llm.load_instruction reads instructions for user 1; the System routes write them there), and through time: launch_config is documented as the durable snapshot a restart replays, but its nullable image counts send a restart back to whatever the settings table says TODAY, so a rerun is not reproducible. The outcome this card buys: no production run resolves mutable runtime behaviour through a legacy user identity.
Read first. app/pipeline/config.py (load_settings_from_db and every global it assigns); the eleven callers (orchestrator.py, expand.py ×3, expansion.py, longform.py, iv2_runner.py, labs_shared/runner.py); the 13 readers, and steps/step2_copy_qa.py, which imports CLAUDE_MODEL BY VALUE at import time and so cannot be fixed by a context field alone; pipeline/utils/claude_client.py (the fallback to the mutable user key); app/core/user_settings.py (get_values takes a legacy id only and refuses to choose among workspace memberships); services/pipeline_runner.py (launch_config's snapshot claim, the nullable counts, the restart path); pipeline/storyboard_llm.py::load_instruction and services/db_service.py's instruction reads/writes (both default to user 1); db/migrations/unified/production/005_products_workspace.sql (the cross-schema precedent this card follows); ADR-008 (the workspace credential and the bindings win at the gateway), ADR-009 (sessions.workspace_id is the runtime truth); apps/web/src/pages/products/NewSession.tsx (the SPA loads the creator's defaults and submits explicit values).
Deliverables.
- The two values that leave per-user settings entirely.
openrouter_api_keyandclaude_modelare resolved by the session's workspace — the stored credential and theproduction.*binding (ADR-008) — never from a user row and never from a module global. The by-value import instep2_copy_qaand the mutable-key fallback inclaude_clientgo with them: a caller's model must be an explicit argument or the binding's, never an import-time snapshot of somebody's setting. - The three that stay per-user, as CREATION defaults.
default_aspect_ratio,images_to_generateandimages_to_keepare resolved once, for the request's creator, at session creation, and the CONCRETE values are written intolaunch_config. Execution and restart read the snapshot and nothing else — which is whatlaunch_config's own docstring already claims and the nullable counts break. A direct API caller omitting a value asks for the server-resolved default; omission is never conflated with an explicit1:1. - An immutable
RunScoperesolved at the head of every background run, from the session's row, and attached toPipelineContextfor the pipeline steps — but not carried by the context alone: the labs runner does paid preparatory work before a context exists, and the storyboard path has noPipelineContextat all, so the scope is passed explicitly through the pre-context work.load_settings_from_dbloses itsuser_id=1default and its callers; the globals remain as the shipped defaults for a caller with no scope. - A workspace-aware settings resolver in core —
user_settingsgains an explicit(workspace_id, legacy_user_id)read, because today's signature takes a legacy id and refuses to choose among a user's workspace memberships. A small core API addition, not a data-model change; S reviews it. - Instructions become workspace configuration.
db/migrations/unified/production/006_workspace_instructions.sqlon the S13 shape:production.instructions.workspace_id UUID, no cross-schema FK, backfilled fromcore.workspaces.slug = 'rescale'for the authoritativeuser_id = 1rows, with a workspace+step uniqueness index. The other legacy per-user rows are left unmigrated rather than merged into one workspace — no runtime reader uses them, and merging competing prompts would be a silent edit; the migration's comments and the acceptance record say so.storyboard_llm.load_instructionreads by workspace, the System routes read and write by workspace, and the shipped-file fallback survives only for a workspace with no override.
Acceptance. pytest apps/api/app/backend/tests green, including: a static scan — no runtime load_settings_from_db call, no runtime read of the five old globals, no hard-coded instructions.user_id = 1 (bootstrap references such as the binding seed are allowed); a two-workspace concurrency test that synchronises two runs after resolution and proves each model call uses only its own workspace's binding and credential and cannot observe the other's — it fails against today's globals; a snapshot test — create a session with the creator's defaults, change those defaults, restart, and the original persisted values are what replay; a workspace-instruction test — two workspaces resolve different overrides, a workspace with none gets the shipped file and never another workspace's legacy row; a route test — the SPA's existing behaviour (load the creator's defaults, submit explicit values) still holds, and an omitted value asks for the server default. test_migrate.py carries production/006 in both version lists and its head asserts. docs/ops/v29-acceptance.md; open item #23 closed.
Out of scope. Retiring RENDER_PROVIDER (open item #21 — the binding validates the resolved provider rather than selecting it, so that needs capability work first); a per-user settings UI; row-level security; the unbounded background threads (open item #17, card A2e).
Waits on / Unblocks. V8 (V), S13 (S), M1 (V) / an honest per-run identity before the first outside workspace exists.
V8b · The default renderer is the workspace's binding · M · W6 · feat/v/v8b-render-provider-binding (S1-rev)
Context. Open item #21, discovered by V8. The floor of the three-level provider choice (per render → session → floor) was RENDER_PROVIDER: a box-global free-text system setting on the Operations page, read by one function (render_provider.active_provider), validated by nobody at write (a typo rendered on fal through normalise_provider), and read under a "never raise" contract — while every paid call had moved onto the workspace's bindings (ADR-008) and production.video was bound, seeded to fal beside eight active video rows, validating the resolved slug at every submit but choosing nothing. Two homes for one decision, one of them a text box. Two defects sat in the same path: the render worker dispatched any slug it did not recognise to HeyGen through an else (a corrupt job row bought a HeyGen clip) while its own comment claimed it failed closed, and the create route accepted any window-table slug (HeyGen included) and silently defaulted an unknown one, while the per-render override refused both. The outcome this card buys: one admin home for the default renderer, per workspace, validated when written and refused rather than guessed when unusable.
Read first. app/backend/services/render_provider.py (active_provider, PICKER_PROVIDERS, the per-provider tables), app/backend/services/model_gateway.py (bound_model), app/core/bindings/{store,seed,admin_router}.py, app/backend/routes/video_storyboard.py (create, GET /config, the override), app/backend/services/storyboard_runner.py (the dispatch), app/backend/services/system_settings_service.py, db/migrations/unified/production/003_settings_to_core.sql + infra/migrate/production/restore.sh (the restore re-runs 003 from the file — the precedent), ADR-006 §4, ADR-008 §4/§6, ADR-011, ADR-014.
Deliverables. (1) The floor is the workspace's production.video binding: render_provider.bound_provider reads it per call and slug_for_model bridges the bound catalogue row onto a render slug with no table of its own (a kie: row's own slug, else its provider's name; a row outside clip_policy.WINDOWS is refused). (2) The read contract refuses, never guesses: WorkspaceUnresolved (403) without a scope, RenderProviderUnavailable (503) for an unbound key or a model this app cannot render, a database failure propagated; the session levels keep never raising; main.py maps the refusal. (3) A session snapshots the floor at creation (ADR-014's rule): the create route resolves an omitted choice to the binding and stores that slug; an explicit value is canonicalised and must be a picker slug or the workspace's own default, else 400. (4) The worker fails closed: a job-row slug outside the window table fails the job before narration with nothing spent; an explicit HeyGen branch; the dispatch's else raises. (5) The SettingSpec retired (the Operations row disappears with no SPA change); production/008 moves a legacy RENDER_PROVIDER row onto the rescale workspace's binding in SQL without a model-id literal, only when no admin has set it, deletes the row and writes one audit 'clear' row; restore.sh re-runs it inside the copy transaction like 003 and reports it; checksums.py compares system_settings and its audit with exactly that row excluded on both sides. (6) The scope-less callers closed: storyboard_narration takes its window from the caller (required), the voice-clone preview passes the session's, the two fal probes name fal. (7) ADR-018; ADR-008 §6 superseded note; apps/api/CLAUDE.md (the three-level paragraph, the env row, the slug table gains kie:kling-3.0-omni); docs/runbooks/MIGRATION_PRODUCTION.md; docs/ops/v8b-acceptance.md; open item #21 closed, the operator parameter (the old host's .env) and the 006 restore gap recorded as items.
Acceptance. pytest apps/api/app/backend/tests green, including: the seed's video rows bridge onto exactly clip_policy.PROVIDERS; unbound → 503, a chat model bound to the key → 503, no scope → 403, a database error propagates, a session's own choice never touches the binding; GET /config answers each workspace's default (two workspaces, two defaults) and 503 for one that cannot render; an omitted create choice becomes the floor, an explicit one is canonicalised, an unknown one is 400, HeyGen only when it is the floor; the worker fails an unknown job-row slug and an unbound workspace with nothing spent and resolves a NULL-provider row through the floor; production/008 against a seeded binding (the restore's topology): a kie value moves the binding, a provider-named value maps through the provider's video row, an admin's binding is never reverted, an unmappable value moves nothing, the row is cleared with one audit row, a re-run is a no-op; test_migrate pins → 008; the S12 drill carries a legacy RENDER_PROVIDER row through restore.sh and verify.sh passes with the transformed row excluded. npm run build green; gitleaks clean. docs/ops/v8b-acceptance.md; open item #21 closed.
Out of scope. An allow-list on the binding's config or a models.active toggle (the picker stays a code fact; V5's extension); V5's page (the admin home is the existing binding route); the vendor keys (ADR-008 §5, S22); 006's missing restore backfill (its own item).
Waits on / Unblocks. V8 (V), V4 (V), S12 (S) / one admin home for the default renderer.
V5 · Minimal admin page: bindings + spend · M · W6 · feat/v/v5-admin-bindings (S1-rev 2026-09-07: S → M, W5 → W6, designed without Figma)
Read first. V4 endpoints.
Deliverables. Use V4's admin read endpoints only: /admin/bindings (table of workflow keys → model dropdown from GET /api/admin/bindings, save → PUT /api/admin/bindings/:workflow), /admin/spend (totals per provider/workflow for 24 h / 7 d from GET /api/admin/spend, guard thresholds form, pause state, Resume button → POST /api/admin/spend-guard/resume), providers list with masked keys (last 4, from the same endpoint). Role admin only.
Acceptance. Screen recording: change a binding, run a job, see the new model in the usage row; trigger a pause, resume from the UI — no DB access.
Out of scope. Users/roles UI (V11, later slice), charts.
S1-rev 2026-09-07 (size S → M, W5 → W6; the design replaces the Figma link). The design is final/skynet-s1-pages-ux-design.md §4: /admin becomes a module with a left rail (Bindings · Spend; /admin → /admin/bindings); /admin/bindings = one row per workflow key grouped by the bound model's modality, a picker listing only the models the SERVER says fit the key, per-row Save/Cancel (owner decision 2), provenance ("set by <email> · <when>" or "seed default"), the Providers list collapsed above (read-only; the keys stay in system settings until S22, ADR-018 §7), a persistent warning under a row whose save reply says the guard is paused (with a link to Spend); /admin/spend = ?window=24h|7d, the guard card keyed on paused_reason (threshold → Resume spending through a ConfirmDialog; kill switch → no Resume, the operator sentence), the Edit-thresholds dialog with 0 ≤ warn ≤ pause and the immediate-pause warning when the new pause is under today's spend, three KPI cards, two tables in USD (owner decision 3) with ≥ when unknown_events > 0. The api half (core bindings, S reviews): WORKFLOW_MODALITIES (the 15 keys → modality) in store.py; ONE store-level validator called by both set_binding (store.py:296) and ensure_binding (store.py:320-332, the seed's path — it validates only the row it would INSERT and leaves an existing binding untouched, so an idempotent seed never fails on a later catalogue change): 422 "model {slug} is a {modality} model; {key} takes a {expected} model", and for production.video ADR-018's bridge render_provider.slug_for_model imported at call time (the one bridge, never a copy) → 422 "model {slug} of provider {name} cannot render video (one of {PROVIDERS}); bind a renderable video model"; GET /api/admin/bindings gains expected_modality per key and renderable: bool per video model row (additive). Built on V2c. Acceptance adds tests/core/test_bindings.py cases (a chat model on production.image → 422 through set_binding and through ensure_binding on an unbound key; a chat model on production.video → 422; an already bound key is left as it is by ensure_binding whatever the model offered; the deploy regression — seed, make the bound video model non-renderable under the bridge, seed again → success, zero new bindings, the original unchanged; every seeded pair passes the map; renderable false for a video row of an unknown provider), an api parity test pinning apps/web/src/lib/renderSlugs.ts (labels and clip windows only) against clip_policy.PROVIDERS + WINDOWS, vitest for the picker filter and the guard-state mapping (state × paused_reason), the Playwright drive of the design's §4.8 (15 rows in groups; a binding change shows "set by <admin> · just now" and GET /api/bindings/production.concepts returns the new slug; the video picker lists exactly the renderable rows; viewer sees the Forbidden page; a pause below today's spend pauses and Resume restarts the window at $0.00; ?window=7d survives a reload; axe clean; screenshots in docs/ops/v5/); INTEGRATION_CHECKLIST.md rows 8/9; docs/ops/v5-acceptance.md. Waits on V2c as well.
V10a · Production polish — creative code, lineage drawer, performance footer · M · W6 · feat/v/v10a-code-lineage-snapshot (V10 split, S1-rev 2026-09-07)
Context. The first half of V10 (its winners / product-creatives deliverable), split out because it depends on two cards V10's page-by-page pass does not: V31 (the page the code chip links to) and V7b (the codes on the winner rows). The design is final/skynet-s1-pages-ux-design.md §7.2. The original V10 card text is kept under V10b for the record.
Read first. The design's §7.2; pages/winners/WinnersGallery.tsx, pages/products/ProductCreatives.tsx; V2c's LineageChain, Sheet, CopyButton, Money, RelativeTime; V7b's codes[] on GET /api/winners; GET /api/performance?code= (S7; performance.read).
Deliverables. On every winners-gallery card: one chip per code in codes[] (mono + CopyButton, a Link to /creatives/<code>) and a Lineage icon Button (aria-label="Lineage of <code>") opening the Sheet with LineageChain for type=creative_code&ref=<code>; the latest performance snapshot as a footer (€<spend> · ROAS <x> · <date>, the winner chip when creative_hit) fetched lazily when the card enters the viewport, cached per code for the page's life, silent on 403; no chip when codes is empty. On product creatives: the existing creative_code rendered mono + CopyButton + the same Lineage button, link and footer. The parity checklist's "winners gallery + product creatives (code + lineage drawer)" row.
Interfaces. Consumes V7b's codes[], GET /api/lineage/chain, GET /api/performance. Exposes nothing new.
Steps. V7b's type in lib/api.ts → the chip + link → the Sheet → the lazy footer → tests → acceptance.
Acceptance. npm run build; vitest for the chip cardinalities (0 / 1 / many) and the footer's 403 silence; a Playwright drive with a seeded chain (the chip text is the code, the copy button announces "Copied", the Sheet lists the chain's rows and returns focus to its opener, the chip links to /creatives/<code>, the footer appears for a code with a snapshot and nothing for an editor); screenshots in docs/ops/v10a/; docs/ops/v10a-acceptance.md.
Out of scope. Every other Production page (V10b); the creative page itself (V31); the api (V7b).
Waits on / Unblocks. V2c (V), V31 (V), V7b (V) / the parity checklist row.
V10b · Production polish — states, navigation, defaults, parity · L · W6 · feat/v/v10b-production-states (V10 split, S1-rev 2026-09-07)
Context. The second half of V10: the regression pass over the imported Production module, done as a rule per idiom rather than page by page. The design is final/skynet-s1-pages-ux-design.md §7.3–§7.7. The original V10 card (W4, M) read: Winners gallery + product creatives show the creative code and a "Lineage" drawer (chain from S4) and the latest snapshot (S7) when present; regression pass over Explore, Expand, Storyboard (scenes/clips/narration/manifest/delivery), Canva polish + callback, Localize, bulk recreate, labs; dead nav removed; error + loading states on every /production/* route; docs/checklists/production-parity.md with these rows explicitly: Explore create/open/regenerate/download; Expand create/open/regenerate/download; Storyboard scenes/clips/narration/render/download; Canva polish callback/download; Localize create/stream/output/download; bulk recreate; labs entry pages; winners gallery + product creatives (code + lineage drawer). Each row has columns owner, expected result, actual result, screenshot link. Acceptance: parity checklist all green with screenshots, run on staging in the W5 parity pass. Out of scope: Lighthouse/UX polish (V21, later slice).
Read first. The design's §7.3–§7.7; the reader reports 01 §5 and 02 §9 (the weak spots with file:line); components/ui/confirm-dialog.tsx:43-46; components/layout/Sidebar.tsx; shell/ShellLayout.tsx:58-67; pages/explore-experiments/ExploreExperimentsPage.tsx:51-53 and pages/products/NewSession.tsx:111-114; docs/checklists/proxy-io.md (the checklist shape); the pages the original card names (SessionView.tsx, the storyboard pages, CanvaPolishedLightbox.tsx, the localize pages, RecreateModal.tsx).
Deliverables. (1) Loading: every page-level Loader2 spinner → a Skeleton matching the page's layout (in-button spinners stay). (2) Errors: every bare error line and every silent .catch(() => []) → Alert error + Retry through useLoad; Settings.tsx:105-114's save gains try/catch + a toast; TeamPage.tsx:70,85,99,109 show ApiError.message; Operations.tsx:184,187 surface their messages; SessionView.tsx:262's approval reverts and shows the error; the four alert() calls → toast.error. (3) Confirms: SystemDocuments.tsx:200 and Operations.tsx:238 gain ConfirmDialogs with consequence-first copy; ConfirmDialog stops swallowing errors (stays open, disables the confirm while awaiting, shows the error line). (4) PageMeta on every page with a PageHeader. (5) Tokens: the raw palette classes in the files this card touches → tokens; the sidebar eyebrows text-sidebar-foreground/40 (3.7:1) and the brand sub-line /50 → /60 (5.1:1); the lint rules extended to the converted directories. (6) Navigation: the shell's workspace chip becomes a non-interactive <span> with a Tooltip; the shell's Admin entry stays (the explanation is the Forbidden page); the admin-only tools and the legacy lab routes stay (working tools and bookmarks are not dead nav). (7) The Explore Experiments defaults load api.getSettings() like NewSession.tsx (a skeleton on the three fields until settled). (8) docs/checklists/production-parity.md in proxy-io.md's shape with the rows the original card names plus: every /production/* page's loading skeleton and error banner, the PageMeta titles, the Explore Experiments defaults; columns owner · expected · actual · screenshot. One directory per commit so review stays readable.
Interfaces. Consumes V2c's components. Exposes nothing new.
Steps. The ConfirmDialog fix → one directory at a time (system, team, products, winners + canva, video-storyboard, localize, scraped-ads, labs) → the navigation items → the Explore defaults → the checklist → the two drives → the acceptance record.
Acceptance. npm run build; npm run lint clean for the converted directories; a Playwright drive over every /production/* route with (a) the API healthy — a screenshot per page, no console error, a document.title per page — and (b) /api/* routed to a 500 — every page shows the Alert error with Retry, none a blank or an endless spinner; the Explore Experiments fields seeded from the settings; the sidebar eyebrows measured ≥ 4.5:1; docs/checklists/production-parity.md filled on staging in the parity pass (the original card's acceptance); docs/ops/v10b-acceptance.md.
Out of scope. Lighthouse / UX polish beyond the states (V21); the code / lineage / footer half (V10a); redesigning any Production flow.
Waits on / Unblocks. V2c (V), V6 (V), V7 (V) / the parity checklist; S13 (S) as the original V10 did.
Built as (2026-09-11, PR #76). One rule per idiom, one directory per commit, each reviewed on its own (49 rounds). Every page-level read goes through useLoad with its abort signal, shown as the server's sentence alone in an Alert with a Retry, one alert per panel; the hook itself ties every answer to a copy of the dependencies it was read for, so a route or selection change reports a fresh load from its first render and never paints the previous request's data or failure (the sidebar reloads the same request per navigation instead of re-keying it). A page that can answer 404 keeps its not-found page. Action failures are toasts with the server's words; a refused delete stays in its ConfirmDialog; every destructive delete asks first. Every control is named by its visible label, every muted line clears AA on an inventoried token pairing, the shared badge palette uses the theme's tokens, the design-system lint covers every page directory, the shell chrome and the shared badge module and refuses a hover title on any interactive element, not only the shared Button; the session rows are a stretched link with a sibling delete control, not a button inside a button. iv2IngestCreative goes through a multipart helper so a refused upload reaches its dialog as the server's sentence; on the api, a stack without object storage answers every media request with a 503 naming the variables to set, and the dev seed writes the product and copy-board session the browser drive reads. The proof set: apps/web/src/shell/production-routes.ts (gate, fixture need, skeleton and request-on-arrival per route), routes.inventory.test.ts (equal to routes.tsx path for path and gate for gate), e2e/production-states.spec.ts (healthy with the answers held and forced-500 with the sign-in bootstrap exempt, scoped to the routed page's <main>, axe and the console on both, the console admitting only a documented list of what a dev stack lacks by design) — 80 of 80 on a seeded dev stack; its first pass found 25 red legs, each fixed and re-driven. Record docs/ops/v10b-acceptance.md; checklist docs/checklists/production-parity.md.
V31 · The creative page /creatives/:code · M · W6 · feat/v/v31-creative-page (discovered by S10, open item #31; S1-rev 2026-09-07)
Context. The workos bridge fills the Launch card's Creative URL with https://app.rescale.media/creatives/<code> and no SPA route exists (open item #31; docs/ops/s10-acceptance.md). The media buyer arriving from ClickUp, the editor tracing an image and the ops floor asking "did it attach?" all need one hub. The design is final/skynet-s1-pages-ux-design.md §5. The workos half (SKYNET_APP_BASE_URL on Railway) stays the operator's row.
Read first. The design's §5; app/core/creative_codes/router.py (GET /api/creative-codes/{code}, 404 for unknown and cross-workspace alike; asset_type is free non-empty text), app/backend/routes/creatives.py + app/core/launch_packages/job.py (the package row, its states, the 307 file routes), app/backend/services/launch_packages.py:95-121 (the package's kind is derived from the source identity — the preview authority), app/core/lineage/router.py (GET /api/lineage/chain), app/core/readback/router.py + job.py (GET /api/performance?code=, performance.read, agree-or-NULL), packages/contracts/creative-code/normalize.ts + roundtrip.json; db/migrations/unified/core/001_core.sql:126-176 (editor lacks performance.read).
Deliverables. A shell route /creatives/:code under RequireAuth (no Production sidebar — owner decision 7). lib/creativeCode.ts = the contract's normalizeCreativeCode copied (apps/web is not a pnpm workspace member, ADR-001 §9) and pinned by a vitest against the contract's live-samples.json (every raw → canonical pair) and schema.json's CreativeCode pattern; the page normalises first and navigates to the register code before fetching — a spelling that does not normalise onto the canonical grammar (the api's own rule, readlayer/queries._canonical) is the not-a-code state, and a hook N suffix is folded off (readback/job.split_hook). The register first; on its 200 the package, the chain and the performance in parallel through useLoad, each panel with its own states (the design's §5.4/§5.5): the register → the header (the subtitle from asset_type, team_made, kind as the code's own prefix, product_number, variant, issued_at; never inferred from TM/RC); the package → the Launch Package panel (pending with last_bridge_reason; attached with ad_name + CopyButton, the ClickUp link, the asset links → 307, the manifest link, attempts; error with last_error and the next try (formatTimeUntil(next_attempt_at)); 404 = the empty state "No launch package yet…", not an error); the chain → LineageChain inline (404 = "No lineage recorded for this code."); the performance → the newest snapshot summarised in EUR with — for null and a tooltip, "Show all N snapshots" as a table, 403 = an Alert info naming performance.read, count: 0 = the empty state. The asset preview from the package's first asset — the package asset's kind alone chooses <img>/<video>; a disagreement with asset_type shows a muted non-blocking line. The Open-in-Production action from the lineage's session node: a video_storyboard session → /production/video-storyboard/<session_id>, else its product → /production/products/<product>/sessions/<session_id>, else omitted. The register's 404 → the page's not-found state with the code in the title and NO further requests; the register's 403 → Forbidden. PageMeta = the code. lib/creativeApi.ts beside videoStoryboardApi.ts. docs/runbooks/LOOP_E2E.md hop 2 names the page; open item #31 narrowed to the workos half.
Interfaces. Consumes the four routes above. Exposes the route V10a's chips link to.
Steps. The normaliser copy + pin → the route + header → the four panels → the preview → tests → the acceptance record.
Acceptance. npm run build; vitest for the normaliser pin, the human-reference map and the panel-state mappings; a Playwright drive on the dev stack with a seeded chain (the register row through POST /api/creative-codes — the dev stack carries the scratch counter, infra/scripts/dev-counter.sh — and the lineage fixture re-keyed to that code through POST /api/lineage/nodes): /creatives/<code lowercased> redirects to /creatives/<code>; the six lineage rows in depth order with the root marked; an unknown code → the not-found state with no further requests (network log); the package panel's states from the V27 wire shape — vitest, plus ONE browser leg whose package answer is stubbed at the browser's network layer (an attached row needs the bridge, R2 and a Production image: the staging loop's hop 2 is the real one, and the leg says so in its name); as the seeded SMOKE_EDITOR_* editor the performance panel shows the permission sentence while the other three render; a service token → the bare Forbidden; axe clean; screenshots in docs/ops/v31/; docs/ops/v31-acceptance.md.
Out of scope. The workos half of #31; issuing or iterating codes from the page; editing anything.
Waits on / Unblocks. V2c (V), V7 (V), V27 (V), S7 (S) / V10a (V), the hop-2 click-through of LOOP_E2E.md.
V33 · The status page /status · M · W6 · feat/v/v33-status-page (discovered by X3, open item #33; S1-rev 2026-09-07)
Context. GET /api/status (ADR-012 §5, docs/runbooks/ALERTS.md §4) is the ops floor's data and the JSON is the page today. The design is final/skynet-s1-pages-ux-design.md §6. Two api gaps decide the card's shape: the response carries no cadence, so no page can say honestly when a cycle is late, and the route runs one query per returned cycle (app/core/synthetic/router.py:50-64 — up to 290 statements at ?cycles=288).
Read first. The design's §6; app/core/synthetic/router.py, checks.py (the statuses ok|fail|warn|skipped, the check names), app/backend/worker.py:61-67 (SYNTHETIC_INTERVAL_MIN, 0 disables); tests/core/test_synthetic_checks.py:402-443 (the exact-body assertion at :443); infra/backup/verify.sh:201-215 (reads checks only); docs/runbooks/ALERTS.md §4, docs/runbooks/INCIDENTS.md; docs/GO_LIVE.md:159.
Deliverables. The api half (core synthetic, S reviews): GET /api/status gains interval_minutes (the api reads SYNTHETIC_INTERVAL_MIN; the containers share the stage env file), next_expected_at (the newest cycle's observed_at + the interval; null when disabled or empty) and retention_days; the history read becomes one statement (WHERE cycle_id IN (SELECT cycle_id … GROUP BY cycle_id ORDER BY min(observed_at) DESC LIMIT %s), grouped in Python) so a request is three statements regardless of N; test_synthetic_checks.py:443 gains the keys. The page: a shell route /status under RequireAuth (any signed-in user — owner decision 1); the overall banner (ok / warn / fail counts, the newest cycle, "next expected <when>"), stale when now > next_expected_at + interval (two cadences missed: "No cycle since <time> — the synthetic worker may be down. The checks below are stale."), the disabled sentence when interval_minutes is 0; the checks table (the API's order; the status chip with the alert state as a second line in the same cell; detail and expected verbatim; latency_ms right-aligned; a row opens a Sheet with the check's fields and its results across the loaded cycles); the history grid (?cycles=12|48|288 as a SegmentedToggle; cycles as columns newest left, checks as rows, one glyph per cell ● ▲ ✕ ○ coloured by token, the detail in a tooltip, an aria-label per cell); auto-refresh every 60 s while the tab is visible for 12 and 48 cycles only (the 288 view refreshes on demand and says so), "updated … ago" live, a manual refresh, a failed refresh keeps the last data with a small warning. PageMeta "Status". docs/runbooks/ALERTS.md §4 gains the page and the three fields; INCIDENTS.md names it as the first look; open item #33 closed.
Interfaces. Consumes GET /api/status. Exposes the three additive fields.
Steps. The api half with its tests → the banner rule + the grid as pure functions with vitest → the page → the Sheet → the refresh policy → docs → the acceptance record.
Acceptance. pytest tests/core/test_synthetic_checks.py: the three fields (incl. null / 0 when disabled), the statement count at cycles=288 (three), a response-equivalence test for the rewrite (the same cycle order, the same result order inside each cycle, the exact existing fields at cycles=288 with seeded rows); vitest for the banner rule (ok/warn/fail × fresh/stale/disabled from API-provided cadence), the alert mapping and the visibility pause/refetch; a Playwright drive on the dev stack with SYNTHETIC_INTERVAL_MIN=1: 11 rows with the API's texts, N grid columns for ?cycles=N, a row opens the Sheet, the worker stopped 3 minutes → the stale banner, axe clean; screenshots in docs/ops/v33/; docs/ops/v33-acceptance.md.
Out of scope. Editing thresholds or probes from the page; alert channels; an ops permission key (ALERTS.md's recorded deviation stands).
Waits on / Unblocks. V2c (V), X3 (A) / the ops floor's first look; INCIDENTS.md.
Built as (2026-09-11, PR #73). Two statements a request, not three: the cycle list folds into the history statement's subquery; a cycle_id tiebreak in the window's limit and the body's order makes two cycles at one instant deterministic (the edge keeps the smaller id). Staleness is judged at the answer's own instant and once more the moment two cadences run out (one timer per answer), so the 24-hour view turns stale while open without another request. The cadence reader checks.interval_minutes() is shared with the worker; a negative value refuses the worker at import and the route answers 500. dev.env.example runs the cycle once a minute so the page and e2e/status.spec.ts have rows; the stale browser leg fixes the browser's clock two cadences ahead, the stopped-worker drive is the staging record's row. Record docs/ops/v33-acceptance.md.
V7b · Winner rows carry their creative codes · S · W6 · feat/v/v7b-winner-codes (discovered by the S1 pages UX design, S1-rev 2026-09-07)
Context. The winners gallery must show each winner's creative code (V10a), but GET /api/winners (routes/winners.py → db_service.get_all_winners, db_service.py:2743-2768) returns session_approvals rows — a concept per session — while the register keys the asset: stamp_session_images issues one code per session_images row, (workspace, session_id, asset_id = str(session_images.id)) (stamp_service.py:13-16, 189-199), and a concept can own several image rows. "The winner's code" is not well-defined from the winner row alone, so the api answers with every code and never guesses. The design is final/skynet-s1-pages-ux-design.md §7.2.
Read first. app/backend/services/db_service.py:2743-2781; app/backend/services/stamp_service.py (the identity, the two-cursor shape); app/core/creative_codes/service.py:124-131 (find_by_source, the single-row lookup by that identity); db/migrations/unified/core/003_creative_codes.sql:23,26 (asset_id, session_id are text); db/migrations/unified/production/001_baseline.sql:243-251 (session_images.id SERIAL); apps/web/src/lib/api.ts:1230-1237 (getWinners).
Deliverables. Every winner row gains codes: [{code, asset_id, filename}] — every register row of the caller's workspace whose session_id is the winner's and whose asset_id is one of that concept's session_images ids, in asset_id order, explicitly zero, one or many; one core query for the whole list after the production read (a bulk sibling of find_by_source), comparing the register's text columns against the production ids stringified on the production side (str(id), as the stamp writes them), never the register's text cast to an integer. The getWinners type in lib/api.ts. docs/ops/v7b-acceptance.md.
Interfaces. Exposes the additive codes field on GET /api/winners and GET /api/winners/{product}. Consumes the register.
Steps. The core lookup → the route's assembly → tests → the type → the acceptance record.
Acceptance. pytest: a winner whose concept has no stamped image → codes: []; one stamped image → one entry with the code, the asset id and the filename; two stamped images → two entries in asset_id order; another workspace's register row for the same session id never appears; the existing winner fields unchanged. npm run build.
Out of scope. Showing the codes (V10a); stamping from the gallery; the product creatives (they carry creative_code already).
Waits on / Unblocks. V7 (V), S3 (S), S13 (S) / V10a (V).
Built as (2026-09-11, PR #74). Every codes[] entry carries product_number from the register row (the product-qualified identity open item #61 makes canonical); GET /api/products/{product}/creatives rows gain a sibling register: {code, product_number, asset_id, issued_at} | null, resolved by (workspace_id, products.product_number, creative_code) in one core statement and never by code alone (null, with no statement, for a product without a number or a code the register does not hold); a partial index core/013_creative_codes_source_idx.sql on (workspace_id, session_id, asset_id) WHERE asset_id IS NOT NULL (the ledger head stays production/008); the entries are ordered by the production image id (an integer, so 10 follows 9) then the register's issue order. Record docs/ops/v7b-acceptance.md.
V34 · The Explore instructions ship with the image · M · W6 · feat/v/v34-explore-instructions-shipped (discovered on staging by the loop plan's Step 4, open item #64; carded 2026-09-11)
Context. A fresh stack cannot run Explore: script-analysis, image-analysis and concept-generation exist only as production.instructions rows, no shipped file backs them and no fallback is registered, so load_spec raises Instruction not found for step: step1 (db_step: script-analysis) (app/pipeline/utils/file_io.py:42) on staging and on every new workspace. V29's "a workspace with no row reads the shipped file" is true only for the keys INSTRUCTION_FALLBACK_PATHS names (db_service.py:2518-2533: iv2-concept-generation, image-review, the storyboard steps, the craft rules). The canonical texts live only in the production app's database; the owner exports them once.
Read first. app/pipeline/utils/file_io.py:10-42 (load_spec(step, workspace_id=None, fallback_path=None), step_map, the DB read, the file fallback, the raise); app/backend/services/db_service.py:2457-2462 (INSTRUCTION_STEPS — display filenames only), :2518-2533 (INSTRUCTION_FALLBACK_PATHS), :2545-2590 (list_instructions, get_instruction, save_instruction); app/pipeline/steps/step1_analysis.py:14,49, steps/step2_concepts.py:95-102 (the iv2 branch passes a fallback, the default branch does not), steps/step4_review.py:27-28 (the pattern to copy); app/backend/routes/system.py:87-107 (GET list / GET one / PUT, require_admin); apps/web/src/pages/system/Instructions.tsx (eight tabs; V10b gives its load failure an error state above a disabled editor — V34 layers provenance on top of that); db/migrations/unified/production/006_workspace_instructions.sql; tests/test_video_storyboard_motion_history.py:1148-1149 (the fallback-file pin); open item #64.
Deliverables. (1) The three canonical texts as versioned files app/pipeline/instructions/script_analysis.md, image_analysis.md, concept_generation.md — exported ONCE by the owner from the production app's System → Instructions page; their sha256 and the export date go in the acceptance record (provenance), and a test pins each file's hash so an unreviewed edit fails. (2) INSTRUCTION_FALLBACK_PATHS gains the three keys; load_spec("step1"), ("step1_image") and the default branch of ("step2") pass fallback_path=INSTRUCTION_FALLBACK_PATHS.get(<db_step>). (3) A coverage test: every load_spec( call under app/pipeline and app/backend/services (found by AST, not grep) names a step whose db_step has a registered fallback file that exists — so the next step added without a shipped text fails the unit run. (4) Provenance on the api: GET /api/system/instructions lists EVERY step in INSTRUCTION_STEPS with source: "workspace" | "shipped" | "missing" (+ last_modified for a workspace row); GET /api/system/instructions/{step} answers 200 {step, content, source, last_modified?} for every known step (missing → content: "") and 404 only for an unknown step; a new admin DELETE /api/system/instructions/{step} removes the workspace row so the shipped text applies again (404 when no row). (5) The admin page shows a "Shipped" / "Edited <RelativeTime>" chip per tab, never presents a missing step as editable working text ("No text — the shipped file is absent"), and offers "Restore the shipped text" behind a ConfirmDialog (consequence-first: the workspace's edited text is discarded); the web client's getInstructions type gains source. Tokens only.
Interfaces. Exposes source on the two GETs and the DELETE. Consumes nothing new.
Steps. The owner's export → files + hashes → the registry + the three call sites + the coverage test → the api source + DELETE → the page → the record.
Acceptance. pytest: a workspace with no rows renders the step1 / step1_image / step2 prompts from the files, byte for byte; the coverage test; the hash pins; the list and the single GET's source for a row-backed, a shipped and a missing step; DELETE restores shipped and a second DELETE is 404; npm test for the chip, the missing state and the restore flow; on staging the loop plan's Step 4 Explore run succeeds with NO pasted rows (the owner's leg); docs/ops/v34-acceptance.md with the three hashes and the export date; open item #64 closed.
Out of scope. Changing any prompt's wording; the storyboard and craft-rule keys (already shipped); prompt layering (S3).
Amended 2026-09-14 (measured; plan round 0). Source of the texts (amended again the same day, plan round 2): the owner pasted Script Analysis, Image Analysis and Concept Generation into the session, copied from the old static-ads app's System → Instructions page; the three files ship byte for byte AS RECEIVED from that paste — not proven byte-identical to the historical database rows — and the record states the paste date, the byte length, the sha256, UTF-8, LF line endings and a Unicode code-point census per file. One ambiguity the owner must confirm BEFORE MERGE: rule 14 of the classic concept prompt lists three identical ASCII " among its "typographic quote characters". The staging-api export is no longer the source. A read-only search of the dev box found the texts nowhere else (open item #64). Never a placeholder. Required outcomes, measured against main 385ca89 (superseded: 32cfffc, see below): (1) apps/api/app/pipeline/instructions/{script_analysis,image_analysis,concept_generation}.md registered in INSTRUCTION_FALLBACK_PATHS; step1_analysis.py (step1, step1_image) and classic step2_concepts.py pass their fallback; concept_gen.md (Intelligence Mode) is NOT reused for classic step 2; the step-alias map in file_io.py is centralised so resolution, fallback lookup, errors and tests use one map. (2) Source per instruction: a non-empty workspace row → workspace ("Edited", even if byte-identical to the shipped text); no usable row and a readable shipped file → shipped; neither → missing; an empty row counts as no override. The list api returns every registered step with its source; unknown step → 404, known but missing → 200 with empty content and source: "missing"; an admin-only DELETE removes a workspace override (restoring the shipped text; a second DELETE → 404). (3) The page shows Shipped / Edited / Missing; a missing instruction never looks runnable; an explicit admin-only "Create workspace override" action (never pre-filled with placeholder text) and a "Restore shipped text" action with confirmation for an edited one. (4) When a step has neither a row nor a shipped file, load_spec fails with an operator sentence naming the instruction and the remedy (e.g. "Explore cannot start because instruction "Script Analysis" (script-analysis) has neither a workspace override nor its shipped file. Add a complete workspace override in System → Instructions or deploy the shipped instruction file."), which reaches the Explore page through session_progress.error. (5) Tests: fixture prompt texts only (never written under the real shipped directory); every alias reads its shipped bytes exactly and a workspace row wins; the api source combinations, unknown vs missing, DELETE; an AST test over literal load_spec calls resolving aliases and requiring a registered, existing fallback (dynamic keys validated separately); a file-existence + sha256-pin test for exactly the three shipped files that FAILS while any is absent, so the PR's CI stays red and it cannot merge short; web tests for the three states, override creation and restore. Merge only with the three real files, their hashes and paste provenance in the record, the owner's rule-14 confirmation, all tests green, and a fresh-staging Explore proof BEFORE merge: the PR's candidate image deployed to staging with no workspace rows for the three steps, Explore step 1 and classic step 2 loading with source: "shipped", recorded in docs/ops/v34-acceptance.md (a post-merge note does not substitute).
Amended again 2026-09-14 (plan round 2, agreed). Source of the three texts: the owner's paste of 2026-09-14 of all eight prompts, copied from the old static-ads app's System → Instructions page. The paste is split byte for byte and preserved under development-processes/skynet/inputs/2026-09-14-owner-paste/ with a manifest. The three files ship exactly as received: script_analysis.md 2,436 bytes sha256 aa613337f00953268975ba93dd925b249195e4cfe47175180c00c835ccb8a635; image_analysis.md 3,440 bytes sha256 82a2b727fba0fd54b16c8094bc5aa1c7c5882e32bb691398d8df114530a79130; concept_generation.md 9,727 bytes sha256 64b8e683734a8659f95f69d4d04b1a19a3dceb5db43d4c83414cb6d861795d51. Provenance in the record = the paste date, UTF-8, LF, the byte length, the sha256 and a Unicode code-point census per file; no staging row and no export time. Two observed source anomalies are retained, not repaired: script_analysis and image_analysis end with two empty - list items; rule 14 of concept_generation lists three repeated ASCII quotation-mark entries among its typographic quote characters. The earlier sentence that required the owner's rule-14 confirmation before merge is withdrawn: the owner stated the paste is exactly the old app's text. The merge gate replaces the pre-merge staging proof, because deploy.yml's deploy-staging job runs only for main and no branch deploys to staging, by design: "Merge only with the three real files, their hashes and paste provenance in the record, and all tests green. Before merge, run the candidate image from the V34 worktree in the dev stack with no workspace rows for the three steps; verify the list reports shipped, run Explore through step 1 and classic step 2, and record the session progress. After merge, repeat those checks on staging after main's automatic deployment, recorded explicitly as the post-merge staging leg. The acceptance record contains no credential values." The dev-stack leg is candidate-image functional proof, not a deployment proof. Required outcomes are measured against main 32cfffc, not 385ca89. The shipped-files test pins the three real hashes and fails on the base checkout because the files are absent, not because of placeholder pins. Tests distinguish an empty workspace row from no row, and a registered fallback path whose file is absent from an unregistered step. The AST test stays, narrowed to literal instruction-loader calls resolved through the shared alias map, with dynamic keys enumerated separately.
Built as (PR #80 → main 21a3547, 2026-09-15; the post-merge staging leg recorded in PR #82 → 1f10ece). Code 1 review round → 1 must-fix (card identifiers in moved comments). The three texts ship as apps/api/app/pipeline/instructions/{script_analysis,image_analysis,concept_generation}.md — the owner's paste byte for byte, 2,436 / 3,440 / 9,727 bytes, carried by the Dockerfile's existing COPY app/ with no Dockerfile change and pinned by test_explore_instructions_shipped.py, which also asserts the directory holds exactly those three files; one instruction_registry.py owns the call-site aliases, the display filenames, a label per step and the shipped file behind each, and db_service re-exports its tables so every existing importer is untouched; run_step1, run_step1_image and the classic run_step2 pass their fallback, while the Intelligence-Mode branch keeps concept_gen.md; workspace / shipped / missing is decided once and read the same way by load_spec and the api, so the page and a run cannot disagree; the list answers for every registered step, the single GET is 404 only for an unknown step and 200 with empty content for a missing one, and a new admin-only DELETE drops the override; a step with neither a usable row nor a readable file fails with an operator sentence naming the instruction and the remedy, which reaches the Explore page through session_progress.error; the page shows Shipped / Edited / Missing, an admin-only "Create workspace override" on an empty editor and a consequence-first "Restore shipped text". The browser drive caught an accessibility regression before merge and it was fixed in the same PR: the read-only instruction box is a fixed-height scroll container that took no tab stop (scrollable-region-focusable, serious — it overflows for the first time now that a step with no row resolves to 2.4–9.7 KB of shipped text instead of a one-line placeholder), and it is now a named region the keyboard can reach, the role before the name because a <pre> is naming-prohibited. 47 new tests; whole api tier 4066 passed / 11 skipped / 3 deselected / 0 failed against 4019 on the base checkout 32cfffc; web tsc -b clean, vitest 103 files / 752 tests. Two legs: the candidate image in the dev stack before merge, 11 checks, 11 ok; the deployed merge on staging after it, 14 checks, 14 ok — the version guard first and blocking on 21a3547, the three steps shipped with the pinned hashes, one classic Explore run on the reserved test product reaching completed_steps [1, 2] with no error, the three instructions byte-identical when re-read after the run, about 0.027 USD of spend, and one Explore session the only row the leg left on staging. Three deviations recorded: the single GET no longer accepts call-site aliases (key-only, as PUT and DELETE already were); a whitespace-only row counts as no override; core/test_settings_migration.py's list assertion reads the sources instead of the rows. Record docs/ops/v34-acceptance.md; open item #64 closed; carded while building: open items #83 and #84.
Waits on / Unblocks. Nothing (the texts arrived 2026-09-14, open item #64) / the staging loop plan's Step 4 on a fresh stack; every new workspace's first Explore run.
V35 · The design system's lint covers the last of the web tree · V · M · W6 · chore/v/v35-design-system-whole-tree (discovered while planning open item #66, open item #70; carded 2026-09-15)
Context. The design-system rules run over a hand-kept file list, not over the tree. apps/web/eslint.config.js:29-56 names src/components/{ui,layout,creative-code}/**, src/shell/auth/**, three shell pages, src/shell/RequirePermission.tsx, src/shell/pages/{admin,creatives,status}/**, eleven src/lib modules, src/test/**, src/pages/** and src/shell/ShellLayout.tsx, and the comment at :24-27 says each later card widens files with its directory. Nobody widened it for src/components/'s own files. Measured at 32cfffc by running design-system/no-raw-palette, design-system/no-title-on-button and no-alert over all 282 files of apps/web/src: every file the list names is clean, and 53 reports sit outside it — 32 raw palette classes, 21 title= on an interactive element, no-alert none. All 53 are under src/components/. components/video-storyboard/ holds 28 (CharacterPanel.tsx 16, FrameSlots.tsx 4, ClipSequence.tsx 3, ImageLightbox.tsx 3, CastChip.tsx 1, RenderHistory.tsx 1) and components/LongFormOutputPanel.tsx 10 — the two the open item names, 38 of the 53. The other 15 are in four files it does not name: DocEditHistory.tsx 7, CanvaPolishedLightbox.tsx 4, expand-actions.tsx 3, ImageModelPicker.tsx 1. So the gap is the whole of src/components/, and the fix is a design migration followed by the end of the list.
Read first. apps/web/eslint.config.js:24-63; apps/web/eslint-rules/design-system.js:1-18 (what each rule promises), :20-21 (the palette pattern), :49-83 (INTERACTIVE, noTitleOnButton); apps/web/src/index.css:45-48,79-82 (--success, --warning and their --color-* aliases — the tokens the amber, green and emerald classes map onto); apps/web/src/components/ui/tooltip.tsx:19-28 (the rule a converted title= follows: aria-label names the control, a Tooltip explains it); components/ui/relative-time.tsx:27-36 (a focusable hint trigger already in the tree); the eleven files above; V10b's conversion of src/pages/** (the sequence to copy); .github/workflows/ci.yml:242-244 (web lint is continue-on-error "until the imported code is lint-clean"); open item #70.
Deliverables. (1) The 38 reports in the two directories the open item names, converted: every raw palette class replaced by the token that carries its meaning — amber → warning at LongFormOutputPanel.tsx:102,108 and CharacterPanel.tsx:1000,1020,1138,1196, green and emerald → success at LongFormOutputPanel.tsx:133 and CharacterPanel.tsx:1385 — and every title= on a control replaced by an aria-label that names it plus, where the text is a hint and not a name, a Tooltip that carries the hint. (2) The 15 reports in DocEditHistory.tsx, CanvaPolishedLightbox.tsx, expand-actions.tsx and ImageModelPicker.tsx converted the same way: the scope gap is the directory, not two of its entries. (3) One design-system block in eslint.config.js over src/**/*.{ts,tsx}: the 26-line files list and its "each later card widens files" comment are deleted, and the scoped block folds into the src-wide block no-nested-interactive arrives in, so every design-system rule — no-raw-palette, no-title-on-button, no-alert, no-nested-interactive and V36's no-tooltip-on-nonfocusable — holds over one scope. A file added after this card is inside the design system by default. (4) Web tests for what the conversion changes, in the files that have none: each control that lost a title= is found by its role and name, and its hint opens on focus. (5) docs/ops/v35-acceptance.md: the per-file report counts before and after, the token mapping, and the config diff.
Interfaces. Exposes the widened lint scope every later web card inherits. Consumes the theme tokens and the shared Tooltip.
Steps. The two named directories → the four unnamed files → the tests → the one block → the record.
Acceptance. A re-run of the design-system rules over all 282 files of apps/web/src reports nothing; cd apps/web && npm run lint green with the single src-wide block and no per-file allowlist; npm test green including the new tests; npm run build green; docs/ops/v35-acceptance.md merged; open item #70 closed.
Out of scope. Changing any component's behaviour or layout; the 14 hover-only tooltips and their rule (V36); flipping web lint to blocking (open item #66(b)); src/pages/**, already converted by V10b; the api.
Built as (PR #91 → main e0ee835, 2026-09-16). Code 1 review round → 2 must-fixes (a FrameSlots hint had started rendering on every slot where it used to exist only with history present; the converted CharacterEditor and history-strip controls had no tests), then confirmed. Measured on the base by widening the block first: 54 reports outside the list (the card's 53 plus ImageOverlayButton.tsx, reported by V36's rule only once in scope), all under src/components/, 32 raw palette classes, 21 title=, one non-focusable tooltip trigger; after: 0 over all 308 files of src. One block over src/**/*.{ts,tsx} carries no-raw-palette, no-title-on-button, no-alert, no-nested-interactive and no-tooltip-on-nonfocusable at error; the 26-entry list and its "each later card widens files" comment are gone. Tokens: amber → warning, green and emerald → success, red → destructive, sky (the file's own muted separators) → muted, purple → chart-4 in a border and a hover fill only with the label on foreground (4.38:1 on a card), the inferred badge keeping warning/80 at 4.74:1; contrast.test.ts gains no row. title= conversions land on V36's shapes: aria-label for a name; a Tooltip for a hint with the control itself as the trigger, ControlHint when the control can be disabled, HintTarget where the hint exists only while it is off; the lightbox zoom bar takes names alone because a hint trigger as the dialog's first focusable element opened an unasked tooltip and took the first Escape (pinned by a test); the sheet tile's name became Use Maya; ImageModelPicker's title= that repeated the visible text is gone. src/test/controls.ts no longer counts a natively disabled control as a control (the browser does not), tested both ways. Tests for every converted control by role and name with the hint on focus as the accessible description, both states under ControlHint; two new test files; 20 legs plus the review's additions fail on the base sources. whole web suite 118 files / 1018 tests green; npx eslint src 0 reports; lint 0; tsc; build; gitleaks. Out of scope, listed: a duplicated disabled "Edit character" button in two CharacterPanel components; LongFormOutputPanel's hand-rolled copy buttons duplicating components/ui/copy-button.tsx. Open item #66(b)'s condition (a lint-clean tree) is met: web lint has been blocking since PR #81 and now holds over the whole tree.
Waits on / Unblocks. V10b (V), V36 (V), the oi66 cleanup branch's no-nested-interactive block (V) / every later web card — and the lint-clean tree open item #66(b) needs before npm run lint can stop being continue-on-error.
V36 · Every tooltip's hint reaches a keyboard · V · M · W6 · fix/v/v36-tooltip-reaches-focus (discovered by open item #66(a)'s fix pass, open item #80; carded 2026-09-15)
Context. apps/web/src/shell/pages/status/StatusPage.tsx:102-111 wraps the history-window SegmentedToggle in a Tooltip whose trigger is a bare <span>. A <span> takes no focus, so "Auto-refresh is off for the 24-hour view" opens on hover and nowhere else: what focus actually lands on is the toggle's own tabs (components/ui/segmented-toggle.tsx:39-52 — role="tab" buttons with a roving tabIndex at :43), and they carry no description. Measured at 32cfffc over all 86 Tooltip elements in apps/web/src: 16 have a trigger that is neither an interactive tag nor carries tabIndex. Read one by one, 14 are defects; StatusPage.tsx:336 is sound (aria-hidden="true", its sentence on the cell's aria-label at :335) and SessionView.tsx:1977 is sound but only to a reader of the source, its trigger being an <a href> held in a local const at :1962-1972 and handed to the Tooltip as an expression at :1977-1980. The 14 sit in seven files: StatusPage.tsx:102,312; shell/pages/admin/SpendPage.tsx:62,270; shell/pages/admin/BindingsPage.tsx:134,139,224; pages/video-storyboard/VideoStoryboardSessionsList.tsx:175,187,201,209; pages/video-storyboard/NewVideoSession.tsx:881; pages/video-storyboard/VideoStoryboardDelivery.tsx:523; pages/video-storyboard/VideoStoryboardScreen.tsx:942. Three carry an aria-label on the trigger, and at exactly one of them (VideoStoryboardScreen.tsx:942) it repeats the tooltip's own sentence; the other two name the control without giving the explanation. So at 13 of the 14 the sentence exists on hover alone. The tree already holds the answer: components/ui/relative-time.tsx:27-36 gives its <time> a tabIndex={0} and a focus ring so the same tooltip opens on focus. The card takes all 14 sites and is sized M for them: the open item could name only the one site its own fix pass walked into, and the measurement is what the scope follows — a rule that holds over one page and 13 known defects beside it is a rule nobody can trust.
Read first. apps/web/src/components/ui/tooltip.tsx:19-52 (the contract in the doc comment; Trigger render={children}; aria-describedby only while the popup exists); components/ui/relative-time.tsx:13-37; components/ui/segmented-toggle.tsx:10-56; shell/pages/status/StatusPage.tsx:83-112 (the visible auto-refresh line and the sr-only announcement at :96) and :300-341 (the two grid tooltips, one sound and one not); the fourteen sites above; pages/products/SessionView.tsx:1958-1982 (the sound expression trigger this card inlines); apps/web/eslint-rules/design-system.js:49-83 (noTitleOnButton — the rule this one sits beside) and eslint-rules/design-system.test.js; apps/web/eslint.config.js:28-63; e2e/status.spec.ts (the page's axe pass); open item #80.
Deliverables. (1) The status page's own two: the reason moves into the header line a keyboard user already reads — the visible "auto-refresh off" text at :84-94 says why it is off for this window — and the toggle's tabs are described by it, so the explanation arrives at the control instead of over it; the column-header short time at :312 takes the RelativeTime shape (a focusable <time dateTime> with a focus ring) so its full instant opens on focus. (2) The other 12, each by the shape its site calls for. Nine of them are a hint on a word, a count or an icon, and they share one focusable trigger from components/ui/ — one component, not nine hand-written tabIndex attributes; the four VideoStoryboardSessionsList.tsx sites among them use the shared RelativeTime and that trigger instead of formatting a relative time by hand. (3) Two of the 12 already name their control with an aria-label (BindingsPage.tsx:139,224): they keep the name and gain the sentence as a description, so a reader hears both. (4) The twelfth, VideoStoryboardScreen.tsx:942, is a drag handle — a pointer-only affordance. It keeps its aria-label and loses the Tooltip rather than keeping a hint no keyboard can open, and the column-width control says once, in the text beside it, that it is dragged. (5) A new rule design-system/no-tooltip-on-nonfocusable: a Tooltip's trigger must be an interactive tag, carry tabIndex, or be aria-hidden="true"; a trigger the rule cannot read — an expression child among them — is reported rather than skipped. The one sound expression case is answered by the source and not by an escape: SessionView.tsx:1977-1980 inlines its <a href> into BOTH branches of the conditional, so the rule reads an interactive tag at each branch and what the rule reads is what a reader reads. No line in the tree suppresses this rule, and none is asked to. Its cases in eslint-rules/design-system.test.js; the rule turned on in the design-system block of eslint.config.js. (6) docs/ops/v36-acceptance.md.
Interfaces. Exposes the design-system/no-tooltip-on-nonfocusable rule and the shared focusable hint trigger every later page uses. Consumes nothing new.
Steps. The shared trigger → the status page → the other six files → the inlined anchor in SessionView.tsx → the rule and its cases → the record.
Acceptance. npm test: the status page states the reason visibly and the toggle's tabs are described by it, with no hover; the column header's full instant opens on focus; one test per converted file that the trigger takes a tab stop and the hint opens on focus; the rule's own cases (an interactive tag passes, tabIndex passes, aria-hidden passes, a bare <span> fails, an expression child fails); SessionView.tsx's polish rows render the same link in both branches and pass the rule with no suppression. npm run lint green with the new rule on and no eslint-disable for it anywhere under apps/web. npm run build. e2e/status.spec.ts axe pass still clean. docs/ops/v36-acceptance.md merged; open item #80 closed.
Out of scope. The raw-palette and title= conversion of src/components/ and the widening of the lint's files list (V35); redesigning the status page's window control or its refresh policy; StatusPage.tsx:336, which is already sound.
Built as (PR #90 → main 67bf1a4, 2026-09-16). Code 1 review round → 1 must-fix (the new rule accepted anchors without an href and disabled controls by tag name) and 1 note (a non-literal tabIndex was accepted blindly), then four re-check rounds, each finding a way the rule or the wrapper could still leave a keyboard without the hint (a dynamic disabled beside tabIndex={0}; an always-focusable wrapper adding a tab stop beside an enabled control; the enabled child not inheriting the wrapper's aria-describedby; the rule accepting ControlHint by name without reading the wrapped control; off without a matching disabled), each fixed with cases → APPROVE. Measured on the base: 91 Tooltip elements, the rule reports 16 (the card's 14 plus two expression triggers, SessionView known-sound and CreativeInsights new since the card). components/ui/hint-target.tsx (HintTarget, a span with tabIndex={0} and the focus ring on useRender, render= swaps the tag) is the one focusable hint trigger; SegmentedToggle gains ariaDescribedBy on every tab; the status page shows the reason in its header line for the window it applies to and describes the tabs by it, the column heading is a focusable <time dateTime>; BindingsPage's two named icons keep their name and gain the sentence as a description (role="img"); the sessions list formats ages through RelativeTime; the drag handle keeps its label, loses the tooltip, and the control's text says once that it is dragged; the two expression triggers are written out in both branches. components/ui/control-hint.tsx (ControlHint) carries a hint on a control that is sometimes disabled: the control itself is the Tooltip trigger while enabled and the focusable wrapper only while off, so the description is always on the element that has focus and no page gains a tab stop it did not have; the tightened rule found five such sites (IntelView ×2, ScrapedAdsList, AdAnalysisPanel, VideoStoryboardCopyBoard), all converted, with both states asserted (toHaveAccessibleDescription on the control while enabled, on the wrapper while off). design-system/no-tooltip-on-nonfocusable (173 cases in design-system.test.js, in the scoped block, files untouched): an interactive tag that can take focus, a readable non-negative tabIndex (literal, negated literal, conditional or logical of such with a reachable value ≥ 0), or aria-hidden="true"; a ControlHint is read through to the control it wraps, whose disabled source must equal the wrapper's off; anything unreadable is reported by shape, never skipped; zero eslint-disable for it. Discovered and fixed: a hand-written tabIndex={0} wrapper on the new-session page with no focus ring. Discovered, filed: open item #90 (the login page's fake-timer "send again" test failed once under load). Base proof: 11 new tests fail on the base sources. Final tree: whole web suite 116 files / 994 tests green; lint 0/0; tsc clean; build ok. Lesson recorded: a lint rule that accepts a wrapper component by name is a hole; read through to what it renders.
Waits on / Unblocks. V33 (V), V5 (V), V10b (V) / V35 (the single src-wide block carries this rule with it).
V37 · A whole-suite web run under load proves what a single run proves · V · S · W6 · fix/v/v37-settings-seed-race (discovered by open item #66(a)'s verification, open item #81; carded 2026-09-15)
Context. Two web tests each failed once in a whole-suite run that shared the 4-core dev box with other test tiers, and passed alone. The first names a real race, not a flake. apps/web/src/pages/system/labels.test.tsx:124 asserts the "Images to Generate" box holds 8, the fixture's value at :85, and the test's only gate is await screen.findByRole('button', { name: 'Save Settings' }) at :121. pages/system/Settings.tsx:28-32 mounts the form state at imagesToGenerate: 12, imagesToKeep: 10, :55-63 copies the loaded row over it in an effect, and :127's settingsReady = defaults.data !== null gates the Save button (:136-142) and the number boxes (:166) together. So the commit that first paints the button paints the boxes at 12 and 10, and the effect's next commit replaces them with 8 and 6: for one commit the page shows numbers nobody stored, and the run that failed read 12 — the mount value exactly. The second case is on a tree that is not main: at 32cfffc, apps/web/src/pages/admin/CreativeInsights.test.tsx holds five tests and no keyboard test. The failing one lives on chore/v/oi66-tracked-cleanup at 9f85a14, apps/web/src/pages/admin/CreativeInsights.test.tsx:123-166 ("selects a product from its row by keyboard with Enter and with Space, and leaves no click target to the mouse"), whose helper at :49-52 issues up to 20 userEvent.tab() calls and whose close clicks every element of a row in turn, all under TooltipProvider delay={0} (:40-46); it failed with a stack and no assertion message. apps/web/vitest.config.ts:15-20 sets environment, setupFiles, css and include and nothing else, and src/test/setup.ts configures no timeout, so vitest's 5,000 ms per-test limit and Testing Library's 1,000 ms async limit are what a contended box must meet across the suite's 103 test files (102 under src, plus the lint rules' own, vitest.config.ts:19) and 616 literal it(/test( declarations.
Read first. apps/web/src/pages/system/labels.test.tsx:85,102-126; apps/web/src/pages/system/Settings.tsx:27-67,125-170; apps/web/src/lib/useLoad.ts; apps/web/vitest.config.ts; apps/web/src/test/setup.ts; on the branch, chore/v/oi66-tracked-cleanup:apps/web/src/pages/admin/CreativeInsights.test.tsx:40-75,123-166; apps/web/src/pages/admin/CreativeInsights.tsx (the row button and its tooltip); open item #81.
Deliverables. (1) The reproduction first, recorded before any fix: the whole web suite run repeatedly beside a CPU-bound neighbour on the 4-core box, with the run count, the neighbour's shape and the observed failure rate per case — a fix with no reproduction behind it is a guess, and the open item asks for the measurement before the verdict. (2) The page, not only the test: Settings never renders a value it has not loaded — the editable state is seeded at the moment defaults.data becomes readable, not copied in a later commit — so no commit paints 12 over a stored 8. (3) The test's gate names the thing it asserts on, so it can no longer pass or fail on which commit it caught. (4) The second case carried to the same end on its branch: reproduced, its cause named (the tab loop and the click sweep against the 5,000 ms default, or a real ordering the page owns), and the page or the test made deterministic — never a raised timeout over an unexplained failure. (5) Whatever the reproduction proves the suite needs under load is set in vitest.config.ts explicitly, with its measured reason in the record; nothing is set on suspicion. (6) docs/ops/v37-acceptance.md: both reproductions, both causes, both fixes, the re-runs.
Acceptance. npm test green alone; the whole suite green through the recorded repeated runs beside the same CPU-bound neighbour that reproduced each failure, at the run count the record names; a test that fails on the pre-fix Settings page when the seeding commit is delayed and passes after it; npm run build green; docs/ops/v37-acceptance.md merged; open item #81 closed.
Out of scope. Changing the suite's runner or its pool shape beyond what the reproduction proves; the api test tiers; the oi66 cleanup branch's own merge; the other System pages' load gating, unless the reproduction implicates them.
Built as (PR #89 → main 2d97b9f, 2026-09-16). Code 1 review round → 2 must-fixes (the per-test budget was a round number, not a derived one, and the spend-page timeout the reproduction had surfaced was unexplained; both were measured and derived) and two notes on the record (disclose the discarded run sets; reconcile two worst-case figures), then APPROVE. The reproduction came first, on the pre-fix tree, the whole web suite beside N CPU-bound processes on the 4-core box: idle 1 run / 0 failures; 3 busy 5 / 0; 4 busy 5 / 1 (NewVideoSession's findByRole gave up at the 1,000 ms default); 6 busy 5 / 4 (the CreativeInsights keyboard test timed out at 5,000 ms three times, a SpendPage test once, two StatusPage gates twice); 26 whole-suite runs recorded in all, three discarded sets disclosed. Case 1, the settings page, did not reproduce in 16 runs and was fixed by analysis: settingsReady gated the Save button and the number boxes together, so the first commit painted the mount values 12 and 10 and the effect's next commit wrote the stored 8 and 6, a window always one commit wide; Settings.tsx now holds SettingsState | null seeded from the loaded row during render (the SessionView shape), never an effect copy, the Canva panel's identical effect converted the same way; the labels test's gates name the box they read and a new test records the box's whole value history through a MutationObserver, failing on the old page every time. Case 2, the keyboard test, reproduced and is not a race: each userEvent.tab() walks the document through jsdom's style computation at about 170 ms a step, 4.4 to 6.0 s under six busy processes; the per-action pauses were removed with userEvent.setup({ delay: null }), no event, order or assertion changed. Case 3, the spend page, was profiled on review: the one click that mounts the dialog, its portal and focus trap costs 230 to 383 ms idle and 911 to 985 ms under the neighbour; no test cost to remove. The budget is derived by a stated rule, the worst reported test duration anywhere in the 26 runs (7,166 ms at load 10.4 to 11.4) doubled and rounded up to the next second → testTimeout: 15_000; the same rule on the longest measured query wait (1,604 ms) → asyncUtilTimeout: 4_000 in setup.ts; hookTimeout and the pool untouched. Discovered and fixed: StatusPage.test.tsx gated on the page's permanent <h1>, painted before the api answers (case 1's defect class), moved onto the Checks table. Two findings recorded: 51 tests in 12 files set their own 20 or 30 s budgets, so the global budget never governed the worst test; reported durations include the per-test hooks. Final proof on the fixed tree: idle 199 s; beside the same six-process neighbour 481, 482 and 500 s, all green, 114 files / 914 tests, slowest 5,029 / 6,768 / 5,218 ms. tsc, lint and build clean. Filed as open item #89: a successful defaults.reload() re-seeds the form and discards unsaved edits, the behaviour the page always had, undecided.
Waits on / Unblocks. The oi66 cleanup branch merged, for the second case (V) / a blocking web suite whose red means a defect (open item #66(b)).
V38 · The Intelligence prompts a fresh workspace runs are the ones the team runs · V · S · W6 · chore/v/v38-canonical-iv2-prompts (discovered while building V34, open item #83; carded 2026-09-15)
Context. Five of the eight instruction prompts the owner pasted from the old static-ads app's System → Instructions page already ship in apps/api/app/backend/intelligence_v2/prompts/, wired as the empty-row fallbacks at app/backend/services/db_service.py:2535-2550. Three of the five are byte-identical to the paste; two are not. concept_gen.md, the iv2-concept-generation fallback at :2536, is 26,211 bytes, sha256 a56e9a2f18ea785d5331005827b49ed2ccda5f62edf6ec5aefe8faa9380a99e6, against the paste's iv2-concept-generation.md at 26,596 bytes, sha256 b858cc5a7b28c84efbf0f1d76d2afe42b8eb9918d0d379afa0d28e4a240b5b46 (both paste values as development-processes/skynet/inputs/2026-09-14-owner-paste/MANIFEST.md pins them): the paste carries a whole instruction the shipped file lacks (rule 15, "JSON field encoding"), a rewritten "prompt" field description in the OUTPUT FORMAT block, and two trailing residue lines. craft_rules_tofu.md, reached through the craft-rules-{tofu,mofu,bofu} comprehension at :2546-2549, is 7,025 bytes, sha256 dc631f7ef63d0149a6d9840daa9e9addf1b74c3c55617ce633d17e661ea06eb5, against the paste's craft-rules-tofu.md at 7,561 bytes, sha256 263730d528e96876ead5f0e13212db52088d82259c4a1398ee361082d094af3a: 49 lines only in the paste, 46 only in the shipped file — the shipped text assumes TOFU is always Problem-Aware, the paste makes the angle a brief-declared choice of three and rewrites every rule that leaned on that assumption. The owner decided on 2026-09-14 that the paste is canonical (open item #83). So a workspace with no rows runs two prompts the team does not use, and one of them is a different doctrine.
Read first. development-processes/skynet/inputs/2026-09-14-owner-paste/MANIFEST.md — the split rule, the per-file byte lengths, line counts and hashes, the Unicode census, anomaly 4, and Comparison C's line-level reading of exactly these two files — and the two source files iv2-concept-generation.md and craft-rules-tofu.md; apps/api/app/backend/services/db_service.py:2472-2477 (FUNNEL_STAGES, INSTRUCTION_STEPS), :2529-2531 (_INTELLIGENCE_PROMPTS_DIR), :2535-2550, :2577-2583 (get_instruction, which returns the row or the shipped file unchanged); apps/api/app/backend/intelligence_v2/generation/concept_gen.py:461-477 (load_craft_rules, whose body.strip() removes whitespace and nothing else); apps/api/app/backend/tests/test_video_storyboard_motion_history.py:1212-1233 (the existing registered-with-a-shipped-fallback test, the shape to follow); V34's shipped-file hash pin; open item #83.
Deliverables. (1) intelligence_v2/prompts/concept_gen.md and craft_rules_tofu.md replaced byte for byte by the paste's iv2-concept-generation.md and craft-rules-tofu.md — no reflow, no re-typing, no normalisation, so the shipped hashes become b858cc5a… and 263730d5…. (2) The decision on the two trailing lone-quotation-mark lines at the end of iv2-concept-generation.md (MANIFEST anomaly 4), stated in the record: they are KEPT. They do reach the model — get_instruction returns the file body unchanged and load_craft_rules strips whitespace only — and they are kept anyway, because this card's promise is that a fresh workspace runs the text the team runs, and the team's row is the text the owner copied; a repair only this card knew about would make the pinned hash a claim about our edit instead of about the source. V34 made the same call, the same day, on the same paste's two stub bullet lines. Changing a prompt's wording — the residue included — is an instruction edit with a card of its own, never a side effect of a provenance card. (3) A sha256 pin over every shipped prompt whose canonical source is that paste — concept_gen.md, craft_rules_tofu.md, craft_rules_mofu.md, craft_rules_bofu.md, image_review.md — in one table beside V34's pins, so the three that are already correct stay correct and an unreviewed edit fails the unit run; each entry asserts the file exists, is registered in INSTRUCTION_FALLBACK_PATHS, and hashes to its pinned value. (4) docs/ops/v38-acceptance.md: per file, the paste date, the byte length, the line count, the sha256, UTF-8, LF, the Unicode code-point census, the before and after hashes of the two replaced files, and the anomaly of deliverable (2); the paste directory is named as the source and no prompt body is copied into the record. (5) A fresh-workspace proof on the dev stack: with no production.instructions row for either step, an Intelligence Mode concept run and a TOFU craft-rules assembly read the new bytes, recorded.
Steps. The two replacements → the pin table → the record's provenance → the dev-stack proof.
Acceptance. pytest apps/api/app/backend/tests green, the pin table included — and failing on the base checkout, because the two replaced files still hash to the old values there, not because of placeholder pins; a diff of each replaced shipped file against its paste source is empty; the three untouched shipped copies still hash to the manifest's values; the dev-stack run of deliverable (5) recorded with the session progress; docs/ops/v38-acceptance.md merged; open item #83 closed.
Out of scope. Changing any prompt's wording, the residue included; the three steps whose shipped files are V34's and already merged — script-analysis, image-analysis, concept-generation — which this card does not touch; the source provenance api and the admin page's chips (V34); the directory's eval_image.md, eval_script.md and synthesis.md, which are not registered instruction steps and are not in the paste; reading production's live rows.
Built as (PR #85 → main 7d7e497, 2026-09-16). Code 1 review round → 3 must-fixes, all in the dev-stack leg and the record and none in the shipped files (the leg's TOFU proof hashed the local file after a substring match; the leg exited 0 on a FAIL and would have posted the paid session after a failed gate; the record did not say the leg was pending), then one re-check push-back (first-match section finding could pick a wrong block if a brief quoted the preamble; now the assembled user message is split on its section join, exactly one section may start with the craft-rules preamble and its successor must be the FUNNEL STAGE section, ambiguity fails) → APPROVE. concept_gen.md and craft_rules_tofu.md are the paste byte for byte (26,596 B b858cc5a…, 7,561 B 263730d5…; cmp silent; numstat 5/1 and 49/46 = the manifest's Comparison C); the two trailing residue lines are KEPT and the record says why; test_intelligence_instructions_shipped.py pins all five paste-sourced prompts (exists, registered path IS the pinned file, length, sha256, UTF-8, LF, one trailing LF; fails on base on the two real byte lengths; no directory-equality assertion because the directory holds three unregistered files). Whole api tier 4116 passed / 11 skipped / 3 deselected, +15 = the new tests. Dev-stack leg on the candidate image, 16 checks ok: both steps shipped with the new hashes and no last_modified, zero instructions rows, one Intelligence-Mode run (TOFU, one concept) complete through step 4 whose audit bundle carries the system prompt hashing to b858cc5a… and the craft block to 263730d5… extracted at structural boundaries; spend 0.142928 USD over 8 events; 1 min 56 s. Discovered: two callers hardcode prompts/concept_gen.md instead of shipped_path("iv2-concept-generation") (open item #87).
Waits on / Unblocks. V34 (V — the shipped-file pin table this card extends) / every new workspace's first Intelligence Mode concept run and every TOFU brief it assembles.
V13 · Contract/consumer tests + Production module docs · M · W5 · feat/v/v13-contract-consumers (S1-rev)
Context. Production already exists at real module roots, not at apps/api/production/ or apps/web/src/production/: API Production is apps/api/app/backend + apps/api/app/pipeline, distinct from app/core; web Production is the /production subtree mounted from apps/web/src/shell/routes.tsx, distinct from src/shell/. Four of the five contracts already have api-side consumers; auth has none, and a contract change alone never ran the api's consumers in CI.
Read first. AGENTS.md; apps/api/CLAUDE.md; docs/SECURITY.md; .github/workflows/ci.yml; packages/contracts/{auth,bindings,creative-code,lineage,launch}/README.md; the existing api consumers under apps/api/app/backend/tests/ and tests/core/.
Deliverables. (1) apps/api/app/backend/tests/contracts/: the pure contract consumers moved there (test_bindings_contract.py, test_launch_contract.py, test_launch_package.py) + the new test_auth_samples.py (the api's own verifier over the contract's sample tokens against expected.json; an api-minted access token against the access schema; the api's JWKS equals the contract's) + a README.md indexing all contract-consumer coverage. (2) The docker-backed consumer proofs stay in tests/core/; the V13 consumer suite names them: the seeded bindings' shape, the lineage fixture round trip, the creative-code normaliser parity. (3) The CI job contracts-consumers for the api-side consumers, on contract-package, api and workflow changes (its own path-filter key, not the contracts key's apps/** blur), wired into the required ci aggregator. (4) apps/api/app/backend/README.md for the Production backend module with a pointer from apps/api/app/pipeline/README.md. (5) apps/web/src/README.md for the Production frontend subtree mounted at /production.
Acceptance. The auth consumer proves the api agrees with packages/contracts/auth/samples/expected.json and validates an api-minted access token against the access schema; the api-side consumer suite covers auth, bindings, creative-code, lineage and launch from the app's side; contracts-consumers green on main and inside the required ci check; the two module READMEs merged, pointing at the architecture, security and ADR docs instead of duplicating them.
Out of scope. No new apps/api/production/ or apps/web/src/production/ directories; no rewrite of the contract packages, schemas or fixtures; no broad rewrite of apps/api/CLAUDE.md or apps/web/README.md beyond incidental pointer cleanup.
Waits on / Unblocks. S1a, S3, S4, S4b (the launch fixtures are in packages/contracts/launch/ since S8a's follow-up) / —.
X2 · Upload / download / SSE through the proxy · S · W5 (S1-rev)
Deliverables. Four checks on staging, in docs/checklists/proxy-io.md: (1) 400 MB document upload POST /api/products/{product}/documents via the shell; (2) storyboard clip download GET /api/video-storyboard/sessions/{id}/scenes/{scene}/clip/{variant} (signed R2); (3) 20-min Explore SSE GET /api/products/{product}/sessions/{id}/progress/stream stays open; (4) a storyboard render job survives a Caddy restart (queue leases). Pass/fail + timings.
Acceptance. All four pass on staging.
M2 · R2 prefix reconciliation (ADR) · M · W5 · docs/v/m2-r2-prefix (with Agon) (S1-rev)
Context. S13 made sessions.workspace_id and the workspace-scoped resolvers the runtime truth for Production (ADR-009). M2 decides how R2 keys follow the tenancy rule (architecture design §4: "queue rows, outbox and R2 keys carry the workspace") for new tenant-scoped writes without breaking stored keys, the durable Launch-package URLs or the few paths that rebuild a key from ids; existing objects keep their keys and a later card (M2b) moves them.
Read first. services/r2_service.py; pipeline/context.py (r2_session_prefix); services/storyboard_service.py (_r2_session_prefix, the adopt/purge/store sites); services/chatcut_runner.py (execute_assembly_job, _store); services/launch_packages.py (manifest_key, a carve-out); services/workspace_scope.py; ADR-008, ADR-009; the tenancy rule; PRD §6.
Deliverables. (1) docs/decisions/ADR-010-r2-key-prefix.md: new tenant-scoped writes use ws/<workspace_id>/<existing key>, applied at the key-construction seam, never inside r2_service; existing objects keep their keys; the carve-outs (exports/<ws>/…, global/docs/…, iv2/creatives/…, scraped-ads/…, flowkit-test/…) with the slice that owns each; the appendix = the stored-key inventory for the re-point job. (2) services/r2_keys.py — build_key, session_prefix (replacing the two duplicated session-prefix builders), legacy_session_prefix, product_key, staging_key, library_key, is_staging_key_of; every tenant-scoped producer builds its key through it, with the workspace threaded explicitly (request scope at mint time; the create paths' resolved workspace; PipelineContext.workspace_id; the session row in the storyboard service; ChatCut binds the session workspace at job start). (3) Reads of stored keys unchanged; the two audit-bundle rebuilds try prefixed then legacy; the storyboard purge deletes both prefixes. (4) The create/adopt boundary accepts only the caller's ws/<ws>/<staging family>/ keys (400 otherwise). (5) tests/test_r2_prefix.py + the pinned-shape tests updated. (6) apps/api/CLAUDE.md key structure, the STAGING/A6 notes, docs/ops/m2-acceptance.md; the follow-up card M2b · R2 re-point job written before its code exists.
Acceptance. ADR merged; pytest apps/api/app/backend/tests/test_r2_prefix.py green with no live R2 (pure key building, the audit-bundle fallback, the purge of both prefixes, staging-key validation, ChatCut binding, the context's workspace); the full suite green.
Out of scope. The copy/verify/re-point job itself (M2b); deletes of legacy objects; the manifest-key shape; tenancy for Intelligence v2, scraped ads, global docs, FlowKit; the standalone scraper service.
Waits on / Unblocks. S1a, S13 / M2b (needed by M5's retention manifest, the workspace export and S25b's purge).
M2b · R2 re-point job · M · W6 · feat/m/m2b-r2-repoint (with Agon)
Context. ADR-010 put every NEW tenant-scoped object under ws/<workspace_id>/… and left the objects written before it at their old keys. Until they move, a workspace's objects are split across two shapes — invisible to reads (every read follows the stored key) but visible to the purge, to a bucket listing, and to the cards that need one prefix per workspace: M5's retention manifest, the workspace export and S25b's purge. This card moves them.
Read first. ADR-010 (the Appendix is the inventory: 38 stored-key columns and JSON paths, six duplication groups, the RC asset_key sentinel that is not a key); services/r2_keys.py; services/r2_service.py.
Deliverables. r2_service.copy_object (server-side CopyObject — never download-and-reupload for 5–29 MB clips) + head_object (size, ETag, headers) + list_keys; app/core/scripts/r2_repoint.py: per workspace, per row of the inventory, copy → verify (HEAD: size always, ETag when the source is single-part, the streamed MD5 of the source against the copy's ETag when the source was multipart-uploaded — a server-side copy is single-part; Content-Type and Cache-Control carried over) → re-point every reference of that object in ONE transaction (the duplication groups move together; the workspace comes from the owning table's workspace_id, never from the key string; RC rows' asset_key sentinel untouched) → delete the old key only after the verification succeeded, with --dry-run, --workspace, --limit, resumable (a row already under the prefix is skipped), a report per family; the storyboard purge and the two audit-bundle fallbacks lose their legacy branch once every object has moved (a follow-up commit, gated on a bucket listing with zero legacy keys). Acceptance evidence: the dry-run report on staging, then the run, then the listing.
Acceptance. Dry run and run on staging with the report; pytest apps/api/app/backend/tests/core/test_r2_repoint.py green (the docker core tier — the only fixture with both schemas) (a fixture bucket of legacy objects across every family, every duplication group re-pointed atomically, a failed verification leaves the old key and the references untouched); the bucket listing on staging shows no legacy keys.
Out of scope. The carve-out families of ADR-010 §6 (they move with their own slices).
Waits on / Unblocks. M2 / M5 (the retention manifest), the workspace export, S25b.
Lane A — Agon (Platform & Intelligence)
| Wave | Key | Card | Size | Waits on | Unblocks |
|---|---|---|---|---|---|
| W0 | A0 | Box prep on both boxes | S | — | X4b (A), X7 (A) |
| W1 | A1a | Repo + imports + CODEOWNERS | M | S0a (S) | A1b (A), A2 (A), A6 (A), S3 (S), S8 (S), V3 (V) |
| W1 | A1b | CI + governance | M | A1a (A) | A2 (A), A3b (A), A6 (A) |
| W2 | A2 | Compose stack + local dev | M | A1a (A), A1b (A), V3 (V) | A2c (A), A3 (A), A3b (A), A4 (A), A6 (A) |
| W2 | A2c | Compose resource limits + Postgres tuning + concurrency caps | S | A2 (A) | X3b (A) |
| W2 | A3 | Caddy for the unified app | S | A2 (A), S1a (S) | A3b (A), A6 (A), X1 (A), X2 (V) |
| W3 | A3b | Deploy pipeline on the ecomprofits pattern (two targets, digest pinning, smoke gate, Telegram) | M | A1b (A), A2 (A), A3 (A) | A6 (A), X7 (A) |
| W3 | A6 | Staging stack on server.rescale.media | M | A1a (A), A1b (A), A2 (A), A3 (A), A3b (A), V2a (V), V3 (V), S1a (S) | S12 (S), X2 (V), X3 (A), X4 (A), X5 (A), X7 (A) |
| W4 | X5 | Rollback drill + cutover matrix incl. old-host cron shutdown plan | M | A6 (A), V3 (V) | M5 (S), M6 (A), S11 (S), X7 (A) |
| W4 | X7 | Production stack prepared on server.ecomprofits.io + go-live checklist (moved 2026-09-19: the stack's deployment is the terminal go-live after S3) | M | A0 (A), A3b (A), A6 (A), X5 (A) | X3 (A), X3b (A), X4b (A) |
| W5 | X3 | Synthetic checks + backup freshness + status page | S | A6 (A), V3 (V), X7 (A) | M4 (A) |
| W5 | X3b | Supabase-protection smoke (Skynet under caps, Supabase latency unchanged) | S | A2c (A), X7 (A) | — |
| W5 | X4 | Backup restore drill | S | A6 (A) | — |
| W5 | X4b | Backup path + sweep validation on the production box | S | A0 (A), X7 (A) | — |
| W6 | A2e | Every background job in the api container is bounded | L | A2c (A) | — |
| W6 | A3c | The rollback resolves the images the deploy pushed | S | A3b (A) | — |
| W6 | A3d | The address a rate limit may key on | M | A3 (A) | X7 (A) |
| W6 | A3e | The wait's clock belongs to the runner | S | — | — |
| W6 | M3 | Email delivery | S | S1b (S) | — |
| W6 | M4 | Cross-box monitoring runbook | S | X3 (A) | — |
| W6 | M6 | Comms + training pack | S | X5 (A) | — |
| W6 | X1 | Auth/session policy tests | S | S1a (S), A3 (A), V1 (V) | — |
| S2 | A4 | Scraper data-access SPIKE | L | A2 (A), S1a (S) | — |
Facts every A card relies on (verified against the repos): meta-ads-scraper — Dockerfile: stages node:20-bookworm-slim (deps, admin-build) → mcr.microsoft.com/playwright:v1.58.1-noble (production), PM2 via ecosystem.config.cjs, EXPOSE 8080 3000, healthcheck curl -f localhost:8080/health, CMD /app/start.sh; admin/middleware.ts (cookie admin_session L9, redirect to /login L46/64, matcher L71); admin/lib/auth.ts exports createToken/verifyToken/getSession/getCurrentUser/loginUser/getAuthCookieOptions/COOKIE_NAME; server.js L40 API_KEY = SCRAPER_API_KEY, auth middleware L111–115 accepts x-api-key or Authorization: Bearer; 10 cron.schedule calls (L1037 /10, L1070 /15, L1157 /5, L1441 /10, L1484 /20, L1516 /30, L1674 hourly, L1691 6-hourly, L1747 quarter-hourly, L1763 daily 03:40); package.json scripts test:unit = node --test test/, admin has build/lint, no test; migrations/ = 30 ad-hoc .sql files (no runner; full-schema.sql + supabase-schema.sql are the base); transcriber/Dockerfile python:3.12-slim, uvicorn app:app --port 9010; .env.example keys SUPABASE_URL/SERVICE_KEY/ANON_KEY/BUCKET, STORAGE_PROVIDER, R2_*, SCRAPING_PROVIDER, DECODO_AUTH_TOKEN, SCRAPER_API_KEY; live deploy = host compose at /opt/meta-ads (service app, external network meta-ads-net, ports 32804/32805 behind Plesk). RescaleOS — console/Dockerfile node:22-bookworm-slim, WORKDIR /workspace/RescaleOS/console, EXPOSE 3000, tini + docker-entrypoint.sh, CMD npm start; docker-compose.yml project name: rescaleos, service console, image ghcr.io/rescale-media/rescaleos:latest, env_file./console/.env.docker, DATA_DIR=/workspace/RescaleOS/console/data, named volumes rescaleos-claude-home, rescaleos-console-data (never touch in Plesk GUI); console/src/server/auth.ts exports SESSION_COOKIE="console_session", getUserFromRequest(req), requireUser/requireAdmin/requireStaff/requireStrategist(req), applySessionCookie, checkOrigin; route guard = console/src/proxy.ts; engine API token check in console/src/server/scripts/engineAuth.ts (x-cron-token); scripts build/start/lint (no test). static-ads-automation — top level app/ (backend, frontend, pipeline) docker/ flowkit/ scripts/ tools/ Dockerfile supervisord.conf schema.sql docker-compose.yml; Dockerfile node:20-alpine (frontend build) → python:3.12-slim, EXPOSE 8000; CI .github/workflows/docker-build.yml (dispatch-only). rescale-workos — top level service/ (src, scripts, package.json, pnpm-lock.yaml, railway.json, nixpacks.toml, vitest.config.ts) migrations/ docs/ scripts/ presentation/ archive/ secrets/ CLAUDE.md.
A2b · Sidecar network aliases · S · box-only
DROPPED (S1-rev): no sidecar mounting — the standalones are links.
A1a · Repo + imports + CODEOWNERS · M · chore/a/a1a-monorepo
Context. One repo with history from four. Runtime bumps ride along so day-1 CI runs on the target versions.
Read first. Technical plan §2.1 (layout); S0a hand-over (scrubbed workos tree path + pattern file); source layouts above; scraper Dockerfile (Node 20 → 24: Playwright base image stays v1.58.1-noble, deps stages become node:24-bookworm-slim), static-ads Dockerfile (python:3.12-slim → 3.13-slim; frontend stage node:24-alpine).
Deliverables. (1) Rescale-Media/skynet created; git subtree add --prefix=<dest> <repo> main for: static-ads-automation → apps/api (paths app/backend, app/pipeline, schema.sql, requirements.txt, Dockerfile, supervisord.conf, docker/, scripts/, tools/, flowkit/) and apps/web (app/frontend) — one subtree into apps/static-ads-import/ then git mv into the two targets in the same PR (history preserved by git log --follow); meta-ads-scraper → services/scraper; its transcriber/ → services/transcriber (git mv); RescaleOS → services/agent-runtime (console/ + workspaces/, engine dirs); the scrubbed workos tree → services/workos (plain copy, not subtree — the old repo's history contains secrets). (2) Root CLAUDE.md + AGENTS.md (package map + the TechDesk task-discipline rules from techdesk-skynet-integration-plan.md §1.4), CODEOWNERS (apps/api/production/* @vince, apps/web/* @vince, apps/api/core/* @sead, packages/contracts/* @sead, services/workos/* @sead, services/scraper/* services/transcriber/* services/agent-runtime/* infra/*.github/* @agon, db/migrations/* @sead @agon), .gitignore union + secrets/, .pre-commit-config.yaml from S0a, Renovate config. (3) Runtime bumps in the same PR: scraper Dockerfile deps stages node:24-bookworm-slim (rebuild sharp, run node --test test/), apps/api/Dockerfile python:3.13-slim (pytest app/backend/tests), frontend stage node:24-alpine (npm run build), console node:24-bookworm-slim (npm run build). (4) Old repos: README.md pointer + archived (GitHub "archive" flag) except rescale-workos (stays live, private, per S0a) and meta-ads-scraper/RescaleOS (stay live until their image ownership flips in a later slice — add the pointer only).
Acceptance. In the monorepo: pytest apps/api/app/backend/tests green on 3.13; cd apps/web && npm run build green on Node 24; cd services/scraper && node --test test/ green on Node 24; cd services/agent-runtime/console && npm run build green; cd services/workos/service && pnpm typecheck && pnpm test green; gitleaks detect on the repo = 0; git log --follow apps/api/app/backend/main.py | wc -l > 1 (history kept). Paste all seven command outputs.
Out of scope. Any behaviour change; CI workflows (A1b).
A1b · CI + governance · M · chore/a/a1b-ci
Read first. Implementation PRD §5; existing workflows (ecomprofits/.github/workflows/docker-build.yml for the SSH/compose pattern, RescaleOS/.github/workflows/docker-build.yml for build/notify); A1a layout.
Deliverables. .github/workflows/ci.yml (PR + main; dorny/paths-filter → jobs contracts (pnpm --filter contracts test + pytest packages/contracts), api (pytest apps/api/app/backend/tests), web (npm run lint && npm run build), scraper (node --test test/), runtime (npm run build), workos (pnpm typecheck && pnpm lint && pnpm test), secrets (gitleaks with infra/secret-scan/gitleaks.toml, always)); .github/workflows/deploy.yml skeleton (build matrix → GHCR for skynet-api, skynet-web only; deploy-staging, smoke-staging, deploy-prod jobs stubbed with if: vars.DEPLOY_STAGING_ENABLED == 'true' etc.; notify Telegram as RescaleOS); branch protection on main via gh api script infra/github/protect-main.sh (required checks = the seven job names, 1 review, linear history, no force push); PR template with Task: <key> / ADR: <n|none>; docs/CI.md.
Acceptance. A PR touching only apps/web runs web + secrets + contracts only (screenshot of the checks list); a PR without green checks cannot merge (screenshot of the blocked merge button); gh api repos/Rescale-Media/skynet/branches/main/protection output pasted.
Out of scope. Real deploys (A6/X7).
A2 · Compose stack + local dev · M · chore/a/a2-compose
Read first. Implementation PRD §4 (services table, ports, staging isolation) and §5; V3 (migrate entrypoint); sidecar compose facts above.
Deliverables. infra/compose/docker-compose.dev.yml (project skynet-dev: postgres:17 with pg_data volume + init SQL creating schemas core, production; migrate (apps/api image, python apps/api/migrate.py); api (uvicorn 8000, env from infra/env/dev.env.example); caddy (infra/caddy/Caddyfile.dev, port 38000) with exactly these dev routes: handle /api/* → api:8000; handle → static apps/web/dist with SPA fallback; unified app only, no mounted legacy UIs or sidecars); infra/compose/compose.prod.yml (project skynet: caddy, api, postgres, migrate [profile ops], backup, synthetic; internal network only; images by digest from .env.release) and compose.staging.yml (project skynet-staging, port 38001, own network and volumes, unified app only); Makefile: make dev (up + migrate + seed workspace/admin via apps/api/core/scripts/seed_dev.py), make dev-down, make logs; docs/runbooks/LOCAL_DEV.md.
Acceptance. Fresh clone: make dev → docker compose -p skynet-dev ps shows every service healthy; curl -s localhost:38000/api/healthz → {"ok":true}; curl -I localhost:38000/production returns 200; runbook merged. Paste ps output.
Out of scope. Real Caddy SSO rules (A3), staging deploy (A6).
A5-lite · Agent runtime reachable + SSO · M · feat/a/a5-runtime-sso
DROPPED (S1-rev): the console stays standalone until S3.
A3 · Caddy for the unified app · S · W2 · feat/a/a3-caddy (S1-rev)
Context. One gateway container for the unified app only. No /scraper, /studio, no aliases, no cross-host proxying — the standalones are links.
Read first. Architecture doc §5 (caddy row), §8 (cookies, CSRF), lock doc §4.1; apps/web build output.
Deliverables. infra/caddy/Caddyfile (env-parameterised): :80; handle /api/* → api:8000 (flush_interval -1, transport http { read_timeout 1h } for SSE); handle → apps/web/dist with SPA fallback; request_body { max_size 512MB }; header X-Request-Id {http.request.uuid} forwarded upstream; rate limit on /api/auth/* (10/min/IP, caddy-ratelimit module built in infra/caddy/Dockerfile); trust X-Forwarded-Proto from 127.0.0.1 only; cookies untouched. skynet-web image = Caddy + built SPA.
Acceptance. infra/caddy/test.sh on the dev stack: / 200 (SPA), /production/anything 200 (fallback), /api/healthz 200, 11th /api/auth/login in a minute → 429, X-Request-Id visible in the api log, 300 MB upload passes / 600 MB → 413, curl -N /api/products/x/sessions/y/progress/stream streams. Paste the output.
Out of scope. TLS (Plesk); anything for the standalone apps.
A6 · Staging stack on server.rescale.media · M · W3 · chore/a/a6-staging (S1-rev)
Context. Staging is the unified app only, on the rescale box, proving migrations, the read layer, the loop and the deploy itself.
Read first. Architecture doc §6; A2 compose.staging.yml; A3b.
Deliverables. /opt/skynet-staging running project skynet-staging on 127.0.0.1:38001, network skynet-staging-net, own volumes; .env.staging from SKYNET_STAGING_ENV_B64 (read-layer role skynet_readonly, R2 staging prefix, Resend/SMTP sandbox, LEGACY_LOGIN_DISABLED=0); vars.DEPLOY_STAGING_ENABLED=true; nightly pg_dump -Fc into /var/www/vhosts/rescale.media/private/pgdump/skynet-staging-<date>.dump (existing sweep); seeded smoke user.
Acceptance. https://staging.app.rescale.media/ loads with TLS; docker compose -p skynet-staging ps all healthy; the smoke gate passes on the next push (run link); the dump exists the next morning (ls -l).
Out of scope. Production (X7); any standalone app.
Superseded S1 text — the S1 "Scraper data-access spike → Gate G1" card, kept as history; the live card is "Intelligence data-path proof + pressure test → ADR-020" below
MOVED to S2 (S1-rev): "scraper data → unified PG for the Intelligence merge" spike + gate G1; text kept below for S2.
Context. Decide whether the scraper can run against PostgREST over unified Postgres meta_ads with R2 media and the unified JWT — with evidence, not opinion.
Read first. Lock doc §5 (the full spec); scraper facts above (18 .rpc() sites / 17 functions, STORAGE_PROVIDER=r2, SCRAPER_API_KEY, admin_session); migrations/full-schema.sql + supabase-schema.sql; lib/media-storage.cjs; admin/lib/supabase.ts, admin/lib/supabase-service.ts, server.js L130–141 (clients with db: { schema: 'meta_ads' }).
Deliverables. (0) Inventory: rg -n "\.rpc\(|\.from\(|storage\.from\(|createSignedUrl|\.channel\(|createClient\(" --glob '!node_modules' services/scraper services/transcriber → docs/spikes/a4-parity.csv (file, line, kind ∈ rpc|table|storage|signed-url|realtime|client, name, key-type ∈ anon|service|user, result ∈ PASS|FAIL|DEFERRED, note); first line of the report states hit count = row count. (1) Dev: restore the scraper DB dump into unified PG as schema meta_ads; create roles anon, authenticated, service_role; run postgrest/postgrest:v12 with PGRST_DB_SCHEMAS=meta_ads, PGRST_DB_ANON_ROLE=anon, PGRST_JWT_SECRET=<spike HS256 secret>; mint a service JWT (role: service_role) with that secret; point SUPABASE_URL at PostgREST and SUPABASE_SERVICE_KEY at the JWT. (2) Exercise every rpc (ads_browse, ads_browse_meta, brands_browse, teardowns_browse, intel_coverage, intel_label_distributions_v2, gold_next_item, gold_sample_draw, gold_scoring_rows, mi_claim_work, refresh_brand_metrics, enqueue_brand_teardowns, enqueue_missing_classifications, enqueue_missing_transcripts, research_bump_runs_seen, research_product_inputs, research_products_ranked) and every .from() path from the admin pages and the 10 crons (run each cron function once by hand: node -e or the existing manual endpoints) → PASS/FAIL per row. (3) Storage: STORAGE_PROVIDER=r2 against bucket meta-ads-media; admin/app/api/media/signed-url returns a working URL; one upload + one download verified by sha256. (4) Admin auth = the V6b middleware against the unified cookie; rg "auth\." admin/ server.js → each hit mapped or 0. (5) Realtime: rg "\.channel\(" → every subscription unused/polled/replaced. (6) One full cron cycle (all 10 schedules) against PostgREST with no new error lines vs a Supabase baseline log.
Acceptance. docs/decisions/ADR-003-scraper-data-access.md merged at the end of the spike wave with the parity table pasted, screenshot diff of the six admin pages (browse, intel, research, market-intel, gold, teardowns), the migration runbook draft (dump → restore → role map → env swap → smoke), and the verdict: pass (every row PASS or DEFERRED-with-owner for provably unreachable paths; rows 3–6 PASS) → A9 cutover a later slice; fail → scraper keeps Supabase, A7' a later slice, the failed rows listed with a fix estimate.
Out of scope. The cutover itself; fixing the scraper beyond what the spike needs.
X3 · Synthetic checks + backup freshness + status page · S · W5 · feat/a/x3-synthetic (S1-rev)
Deliverables. The ops floor as two producers (ADR-012): the api worker's 5-minute cycle (apps/api/app/core/synthetic/checks.py) records one row per check in core.synthetic_checks — /, /api/healthz, /api/auth/jwks through caddy, the workos bridge's /health, one read-layer query (SMOKE_READBACK_CODE in core.performance_snapshots), backup freshness (the newest dump under the api's read-only mount of the backup directory, prod daily/, < 26 h), the pre-deploy folder size (20 GB), host pressure (load > 24 for three cycles, available RAM < 32 GB, swap > 2 GB as a warn, disk free < 100 GB), external hosts by env (SYNTHETIC_EXTERNAL_PROBES, M4's rows) — and alerts on the second consecutive failure to Discord + Telegram, once per incident, with a recovery line; infra/synthetic/check.sh (the curl sidecar, prod + staging) keeps the outside view of the gateway and the bridge and alerts on its own when the api is down; GET /api/status behind SSO (the SPA page waits for its Figma link — open item #33). docs/runbooks/ALERTS.md, docs/runbooks/INCIDENTS.md.
Acceptance. Repository half: docs/ops/x3-acceptance.md (the tests: 22 api + 3 worker + 33 sidecar checks). Live half: docker stop skynet-staging-api-1 → alert within 10 min (screenshots); rename last night's dump → backup-freshness alert; GET /api/status shows both; containers restored.
Amended 2026-09-19 (the owner's decision: production at the terminal go-live). Under the staging write rule (architecture §6), this card's staging alerts are an explicit, narrow approved destination and nothing more: the Discord incoming webhook named by SYNTHETIC_DISCORD_WEBHOOK_URL (falling back to SPEND_GUARD_DISCORD_WEBHOOK_URL when blank) and the Telegram chat named by the TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID pair, the ops channel of docs/runbooks/ALERTS.md §2 (open item #34). Their purpose is alerts only. They are incoming webhooks and a bot used only to post, with no action-capable webhook or automation behind them. Their protection against staging noise is the rule this card built: the worker's ALERT_AFTER = 2 with core.synthetic_alert_state, and the sidecar's SYNTHETIC_ALERT_AFTER with SYNTHETIC_STATE_FILE, so a check alerts once per incident and says ok again once. This card is merged, so the proof that this endpoint is allowed and every other destination is refused before contact is in S27's acceptance.
X4 · Backup restore drill · S · W5 · chore/a/x4-restore (S1-rev)
Deliverables. infra/backup/restore.sh <dump> <target-container> + infra/backup/compare_rowcounts.py (two Postgres URLs, information_schema.tables, count(*) per table for core + production); drill on staging: restore last night's dump into a scratch postgres:17 container, compare, time it; docs/runbooks/RESTORE.md.
Acceptance. Runbook with output (counts equal, minutes); Sead repeats it from the runbook and comments "reproduced".
X5 · Rollback drill + cutover matrix · M · W4 · chore/a/x5-rollback-cutover (S1-rev)
Deliverables. infra/deploy/rollback.sh <sha> (reset checkout to <sha>, pin skynet-api@<digest> + skynet-web@<digest> from that run, up -d, restore the pre-migrate dump if the ledger head is newer) — drilled on staging; docs/runbooks/ROLLBACK.md; docs/CUTOVER.md matrix: old static-ads host → 301 to app.rescale.media/production/*; scraper and console URLs unchanged (links); workos untouched; DNS + TLS on the ecomprofits box; old-host crons: static-ads scrape_worker stopped at go-live, everything else stays; owner + trigger per row; the 14-day read-only window (M5).
Acceptance. Rollback on staging under 15 min (transcript); matrix acknowledged by Vince and Sead on the task.
X1 · Auth/session policy tests · S · W6 · test/a/x1-auth-policy (S1-rev)
Deliverables. e2e/auth-policy.spec.ts (Playwright): cookie flags and the refresh cookie's path scoping as the browser applies it; refresh rotation (old → 401, reuse revokes family); logout → /api/auth/me 401 on the first request after it, and a static-ads ?token= image URL likewise (the "within 60 s" of the contract is the cross-process cache ceiling, not a grace period); CSRF: mutating request with Origin: https://evil.example → 403. A CI job that brings the dev stack up and runs the spec against it on every change to the auth surface, the gateway or the spec — the staging run is the leg for Secure and the cookie Domain, which exist only over TLS with PUBLIC_HOST set. docs/AUTH_SESSION.md final (Skynet-only scope; the ?token= bridge is ADR-001 §6 — ADR-005 is the launch-package contract, and the two code comments citing it for the bridge are corrected by this card).
Acceptance. Spec green in CI (run link) and green against staging (output in docs/ops/x1-acceptance.md); doc merged.
X7 · Production stack prepared on server.ecomprofits.io + go-live checklist · M · W4 · chore/a/x7-prod (S1-rev)
Read first. A0, A2c, A3b; architecture §5, §7, §12.
Deliverables. SKYNET_ENV_B64 secret (prod values, escrowed); one manual workflow_dispatch deploy to /opt/skynet with DEPLOY_PROD_ENABLED still false in the automatic path: stack healthy on 127.0.0.1:38000, limits visible in docker stats; docs/GO_LIVE.md: T-1 (freeze, staging smoke, S12 dry run repeated, X3b pressure test passed), T-0 (stop old scrape_worker → final pg_dump of the old static-ads DB → migrate → restore into production → checksums → up -d → smoke → DNS flip → old-host redirect → comms), T+1 (synthetic green 24 h, dump on the Storage Box), owners, rollback trigger.
Acceptance. docker compose -p skynet ps all healthy on the production box; curl --resolve app.rescale.media:443:<ip> https://app.rescale.media/api/healthz → ok; checklist signed by Agon + Sead.
Amended 2026-09-19 (the owner's decision: production at the terminal go-live). X7 is the terminal go-live card. It runs after the implementation slices, after S3; S4 and S5 may follow it. The Skynet production stack is deployed only here, and nothing in S1, S2 or S3 waits for it. The repository half (the production deploy step, infra/env/prod.env.example, docs/GO_LIVE.md) is S1's go-live readiness; the live half above, from the secret to the signed checklist, is the go-live. T-1 carries two separate required gates: X3b (the app workload) and P-1 (the Intelligence workload and its effect on the ecomprofits pooler). P-1 runs on the release candidate, before the engine and the transcriber are activated and before the final source switch. In production the engine and the transcriber start disabled and stay disabled until P-1 passes. The release-candidate record, shared by P-1, X3b, the deploy and the final migration, is the commit, the image digests, the compose rendering hash, the cap values and the set of enabled services. A change to any of these after P-1 requires a new P-1 run. P-1's usefulness floor, beyond "no OOM, no restart, no cap breach": the scheduled workload completes within its configured interval; the queue or backlog does not grow across the proof window; the retry and error rate stays below a stated threshold; the proxy and API behaviour stays within the intended rate budget. P-1's runbook is the Intelligence activation pressure run (the planning documents' a4-p1-command-sheet.md), re-checked against the release candidate before use. The P-1 result fields go into the X7 go-live record. ADR-020's fail path for a late P-1: "A production pressure result does not reopen the accepted direct-Postgres data-path decision or invalidate completed A9 work. It decides only whether the Intelligence engine and transcriber may be activated on the proposed production release topology. A PASS permits activation under the tested images, caps, concurrency, neighbour set, and release revision. An INVALID result proves no capacity conclusion. Correct the measurement or run configuration, keep the engine and transcriber disabled, and repeat the run against the release candidate. A FAIL means the proposed activation configuration is not approved. Keep the unified application deployable and keep the legacy scraper authoritative. Select and record one safe remedy: lower caps or workload concurrency; change the host or topology while preserving the required database and security boundaries; or defer Intelligence activation. Any changed configuration requires a new passing pressure run before activation. A production pressure result does not authorize dual writes. The final migration and scheduler switch retain one stated source of truth and one rollback point." This fail path and the authority boundary are recorded as the recommended policy; the owner confirms them, and decides the remedy if a FAIL ever occurs. X7 executes the retirement artefacts A19 prepared: the final delta, the scheduler stop, the redirect, the archive and the removal of the old container. The scheduler is enabled in production after P-1 passes. Until that final switch the old scraper's data is authoritative. Staging output is never a production source and no production-facing workflow consumes it. No dual writes. X7 also executes A20's final console disable, redirect and archive and records them in its go-live record. It does this under its own readiness and rollback decision, separate from P-1. P-1 controls Intelligence activation and the scraper's authority switch only, and it is never the console-retirement gate. The production activation of S3's night-shift jobs is X7 work too. The old console and the old scraper each stay authoritative for their own domain until their own X7 switch. The go-live record lists the authorised production destinations (the staging write rule, architecture §6). The controlled change from sandboxed or disabled staging writes to production writes happens under the console switch's own readiness and rollback decision. The go-live acceptance includes the shutdown of the old standalone scraper and keeps the 47 unmeasured inventory rows visible. The S1 live-ad items (the creative-code loop with one real ad, the readback, SMOKE_READBACK_CODE) are go-live readiness evidence here. After the terminal deploy the automatic production path stays disabled unless it is deliberately re-authorised.
M6 · Comms + training pack · S · W6 (S1-rev)
Deliverables. docs/comms/ one page per audience: Bian (nothing changes for RescaleOS; the console stays where it is; Studio merge comes with S3 and needs his agreement), Cyrus + creative (Production lives at app.rescale.media, one login, exports carry the creative code, the package appears on the Launch card), media buyers (nothing changes in ClickUp; the package on the card), ops/research (scraper unchanged, reachable from the shell link), leadership (S1 exit gate met, the slice sequence). Sessions booked.
Acceptance. Pages merged; invites sent (screenshot).
A8 · Scraper ops soak (passive) · S · reviewed at the end of S1
DROPPED (S1-rev): the scraper is not part of the Skynet S1 runtime. Standalone scraper changes are allowed only through bridge track B.
M4 · Cross-box monitoring runbook · S · W6 (S1-rev)
Deliverables. The external hosts as external:* rows on the ops floor — the worker's cycle, not check.sh: ADR-012 §6 (written for X3, after this card) scopes the sidecar to this stack's own gateway and puts every other box on SYNTHETIC_EXTERNAL_PROBES, which X3 built and tested. So: the SYNTHETIC_EXTERNAL_PROBES entries for meta-ads.rescale.media and the console URL (200 — they stay live as standalones) and for the old static-ads host, whose expected status follows the cutover case (CUTOVER.md row 9: 301 when the old app had its own hostname, 200 when it was served under app.rescale.media and the rule is a proxy — a 301 there would loop). Railway workos /health already has its own workos_bridge row and is not duplicated. docs/runbooks/LEGACY_HOSTS.md: what runs where (production box vs rescale box vs Railway), what is probed by which producer, who is responsible for what, what the old host must answer after go-live, which crons stay, and what each red row means. The three documents that call this runbook "not yet written" are corrected with it.
Acceptance. Runbook merged; the staging and repository proof. At X7, the production external:* validation: the external:* rows on /status once the stack is deployed and the two unknown hosts are known (open items #2/#3, #30). The live-host rows are never an S1–S3 blocker (amended 2026-09-19).
Amended 2026-09-19 (the owner's decision: production at the terminal go-live). The production stack is deployed only at the terminal go-live. The acceptance above splits the staging and repository proof, which is M4's S1 acceptance, from the production external:* validation, which X7 runs. The live-host rows never block S1, S2 or S3.
A0 · Box prep on both boxes · S · W0 · box-only (S1-rev)
Context. Production runs on server.ecomprofits.io (48 cores / 250 GB / 1.2 TB free, Plesk + Portainer, production Supabase on the same box); staging on server.rescale.media. Everything the deploy pipeline assumes about the boxes is created here, once, by hand.
Read first. Architecture doc §5, §6, §9, §10; ecomprofits .github/workflows/docker-build.yml (the SSH/compose pattern already used on the production box); project_rescale_plesk_server notes (never edit containers in the Plesk GUI; Portainer = viewer).
Deliverables. On server.ecomprofits.io: mkdir -p /opt/skynet (git clone of the monorepo, branch main, deploy key read-only); docker login ghcr.io with a read:packages PAT; mkdir -p /var/lib/skynet-backups/{daily,pre-deploy} owned by the backup account, mode 750; Plesk vhost app.rescale.media → proxy 127.0.0.1:38000 with Let's Encrypt, HSTS, client_max_body_size 512m (DNS still points at the old host until go-live — issue the certificate via the staged hostname or DNS challenge); a Plesk Scheduled Task (daily 02:30) syncing /var/lib/skynet-backups/daily to the Hetzner Storage Box prefix skynet/; record a baseline docs/ops/box-baseline-ecomprofits.md (nproc, free -g, df -h, docker stats --no-stream, uptime). On server.rescale.media: /opt/skynet-staging clone, GHCR login, vhost staging.app.rescale.media → 127.0.0.1:38001 with TLS, staging dumps go to the existing Plesk private/pgdump sweep. Both: an SSH deploy user with the GitHub secrets SERVER_HOST_PROD/STAGING, SERVER_USER, SERVER_SSH_KEY, SERVER_DEPLOY_PATH_PROD/STAGING set.
Acceptance. Post in the task: ls -ld /opt/skynet /var/lib/skynet-backups/* on prod; curl -I https://app.rescale.media (with --resolve to the box) → 502 from Plesk (vhost exists, nothing behind it yet) and curl -I https://staging.app.rescale.media → same on staging; docker pull ghcr.io/rescale-media/ecomprofits:latest succeeds on both (login works); the baseline doc merged.
Out of scope. Any container; DNS flip (X7).
Amended 2026-09-19 (the owner's decision: production at the terminal go-live). The server.ecomprofits.io half of the deliverables and of the acceptance is X7-only work at the terminal go-live, with no S1–S3 dependency (open items #2 and #3). The server.rescale.media half is A0's S1 work.
A2c · Compose resource limits + Postgres tuning + concurrency caps · S · W2 · chore/a/a2c-limits (S1-rev)
Context. Skynet is a bounded tenant on the production Supabase box: ≤ 12 cores / ≤ 32 GB in total, enforceable in host-side compose.
Read first. Architecture doc §9 (the table and thresholds — this card implements it exactly); Docker compose fields cpus, mem_limit, mem_reservation, pids_limit, restart (not deploy.resources).
Deliverables. In infra/compose/compose.prod.yml (and the same values in compose.staging.yml): api cpus: 6, mem_limit: 12g, mem_reservation: 8g, pids_limit: 512; postgres cpus: 4, mem_limit: 12g, mem_reservation: 8g, pids_limit: 256, command flags -c shared_buffers=3GB -c effective_cache_size=8GB -c work_mem=16MB -c maintenance_work_mem=512MB -c max_connections=80 -c max_wal_size=4GB; caddy cpus: 1, mem_limit: 1g; backup and synthetic cpus: 0.5, mem_limit: 512m, backup wrapped in nice -n 10 ionice -c2 -n7; every service restart: unless-stopped. App env: MAX_CONCURRENT_IMAGE_JOBS=4, MAX_CONCURRENT_STORYBOARD_JOBS=2, MAX_CONCURRENT_EXPORT_JOBS=4, enforced by the job worker's semaphores (apps/api/core/jobs/limits.py) — no unbounded thread fan-out remains in pipeline_runner.py / storyboard_queue.py. docs/ops/RESOURCE_LIMITS.md = the table + how to change it.
Acceptance. docker compose -f infra/compose/compose.prod.yml config shows the limits; on the dev stack docker stats shows the caps; pytest apps/api/tests/core/test_job_limits.py (a 10-job burst never exceeds 4 concurrent image jobs); SHOW shared_buffers on the stack's Postgres = 3GB.
Out of scope. Limits for scraper/runtime services (S2 re-issue).
A3b · Deploy pipeline on the ecomprofits pattern · M · W3 · chore/a/a3b-deploy (S1-rev)
Context. Push to main → build → deploy staging (server.rescale.media) → smoke gate → deploy prod (server.ecomprofits.io) → Telegram. Same pattern ecomprofits uses on the production box, with digest pinning and a whole-file env secret.
Read first. Architecture doc §7 (the exact steps, guarantees, rollback); ecomprofits/.github/workflows/docker-build.yml (SSH via appleboy/ssh-action, compose pull/up, Telegram curl format); RescaleOS DEPLOY.md §2b (env-from-secret validation, .bak-*); A1b's deploy.yml skeleton.
Deliverables. .github/workflows/deploy.yml: build (GHCR skynet-api, skynet-web, tags main-<sha> + latest, outputs API_DIGEST, WEB_DIGEST, GIT_SHA build arg) → deploy-staging (SSH; git fetch && git reset --hard origin/main; decode SKYNET_STAGING_ENV_B64, validate (non-empty, required keys list in infra/deploy/required-keys.txt, no ANTHROPIC_API_KEY=), .env.staging.bak-<ts>, atomic write; write .env.release with IMAGE_TAG, API_IMAGE_DIGEST, WEB_IMAGE_DIGEST; docker compose run --rm migrate; pull; up -d; wait /api/healthz 60 s) → smoke-staging (infra/smoke/run.sh, architecture §7 step 3) → deploy-prod (same steps on /opt/skynet with SKYNET_ENV_B64, the same digests, gated by vars.DEPLOY_PROD_ENABLED; pre-migrate pg_dump -Fc to /var/lib/skynet-backups/pre-deploy/; abort if df --output=avail / < 20 GB) → notify (Telegram, ecomprofits format). Compose files reference ${API_IMAGE_DIGEST} / ${WEB_IMAGE_DIGEST} (image: ghcr.io/rescale-media/skynet-api@${API_IMAGE_DIGEST}), never latest. infra/deploy/rollback.sh <sha> per architecture §7. docs/runbooks/DEPLOY.md.
Acceptance. A push to main runs green through smoke-staging with deploy-prod skipped (flag off) — run link; .env.release on staging shows the digests from that run's build job; rollback.sh <previous-sha> on staging brings back the previous /api/version sha in under 15 min (transcript).
Out of scope. Production enablement (X7).
S8a · Narrow workos bridge: ad-name assembly + attach export package · M · W3 · old workos repo, branch feat/skynet-bridge-s1, deployed to Railway (S1-rev)
Context. S1 needs two things from workos: the assembled v3 ad name for an export, and the export package attached to the already-existing Launch card. No launch list/read/create/update API — that is S8b (S4).
Read first. Lock doc §4.5 (human-owned vs machine-owned fields); workos service/src/app.ts, index.ts (route mounting before serve), modules/cascade/webhooks.ts (registerWebhookRoute shape), modules/naming/assemble.ts (assembleAdName, AD_REQUIRED), clients/clickup.ts (resolveField, setCustomField, getTask, comments), config.ts (EnvSchema), test pattern apply-names.test.ts.
Deliverables. service/src/modules/skynet-bridge/ with registerSkynetBridgeRoutes(app, deps) mounted in index.ts; auth = Skynet service token (RS256, typ=service, verified with SKYNET_JWT_PUBLIC_KEY_PEM, jose). Endpoints: POST /naming/ad-name (AdNameInput → assembleAdName → {ad_name} or {withheld_reason, missing[]}); POST /launches/:id/package (body {creative_code, ad_name, assets:[{url,kind,filename}], manifest_url, source:"skynet"}; verifies the Launch task exists and its linked Creative's code equals creative_code; writes only Creative URL (durable app URL) if empty, posts one comment "Skynet export package" with the asset links + manifest, adds the skynet-package tag; never touches status, assignee or any machine-owned field; idempotent on (task_id, creative_code) via a comment marker). Config: SKYNET_JWT_PUBLIC_KEY_PEM, SKYNET_BRIDGE_ENABLED (default false). Fixtures in service/src/modules/skynet-bridge/fixtures/ (copied into monorepo packages/contracts/launch/ by V7).
Acceptance. pnpm vitest run src/modules/skynet-bridge green (mocked ClickUp: ad name assembled for a complete input, withheld with missing[] for an incomplete one; package attached once, second call no-op; wrong code → 409; bad token → 401); on a sandbox Launch task: curl both endpoints, screenshot the comment + Creative URL. Deployed to Railway with the flag on.
Out of scope. Creating launches; status changes; the native board (S8b/V9, S4).
X3b · Supabase-protection smoke · S · W5 (S1-rev)
Context. Prove Skynet under its caps cannot degrade the production Supabase.
Deliverables. infra/smoke/pressure.sh (+ pressure_report.py, the verdict and the report writer): on the production box (before the DNS flip, X7 stack up), through the production vhost pinned at the box (PRESSURE_RESOLVE, the smoke gate's shape — the auth cookie is Secure and host-bound, so not http://127.0.0.1:38000): ten idle minutes of baseline, then 4 concurrent Explore sessions kept in flight + 2 storyboard renders re-submitted as they finish + a 400 MB upload (a product document — the only route that carries 400 MB through the box) in a throwaway product for 20 min, sampling every 30 s: docker stats for skynet-* and the Supabase containers named by PRESSURE_NEIGHBOUR_CONTAINERS (a required parameter — the repository knows no names), uptime, each Skynet container's cpu.stat / memory.events / OOM + restart counters, and a pgbench select 1 p95 (1 client, a custom script — -S needs tables the read-only role cannot create) against the ecomprofits pooler from a throwaway postgres:17 container. The run is real and paid (--i-accept-the-spend); everything it made is deleted at the end. Report written by the script: docs/ops/pressure-test-<YYYYMMDD-HHMMSS>.md, never overwritten.
Acceptance. Repository half: docs/ops/x3b-acceptance.md (infra/smoke/test-pressure.sh, 39 checks with docker/curl/uptime mocked). Live half: Skynet containers never exceed their caps (no OOM kill, no restart, mean CPU under cpus); Supabase p95 during the run within 10 % of the 10-min baseline before it; host load < 24 — throttling reported, not judged. If not, A2c limits are lowered and the test repeated — no go-live before it passes.
Amended 2026-09-19 (the owner's decision: production at the terminal go-live). The live half above is a final-deploy gate in X7's T-1, run on the release candidate and recorded against its record. It measures the app workload. It stays a separate required gate, distinct from P-1's profile, which measures the Intelligence workload and its effect on the ecomprofits pooler. The repository half stays X3b's S1 acceptance.
X4b · Backup path + sweep validation on the production box · S · W5 (S1-rev)
Read first. Architecture doc §10; A0 (folders, Scheduled Task).
Deliverables. docs/runbooks/BACKUP.md — the writers (the sidecar's nightly skynet-<YYYYMMDD>-<HHMMSS>.dump at 23:00 UTC into /var/lib/skynet-backups/daily/, mode 640, owner = the backup account, pruned by its own prefix + timestamp glob at 20 days; the deploy's pre-deploy-<ts>-<sha>.dump from the SECOND deploy on — the first finds no running Postgres —, now written atomically at mode 640; S12's archive dump), the sweep (A0's rsync --delete of daily/ to the Storage Box's skynet/ at 02:30 box time; the archive under skynet-archive/ by hand), the routine; infra/backup/verify.sh <prod|staging> — the box-side proof of every leg the repository can prove (the root's owner/mode, the newest dump's shape/age/mode/owner and its pg_restore -l TOC through the pgvector image, the sidecar's own prune predicate, pre-deploy/ + archive/), the Storage Box ls only with --storage-box <user@host> --key <path> (the operator's parameters, never stored) and the GET /api/status freshness row only with --api … --resolve … — SKIP otherwise, saying it does not satisfy the card; infra/backup/test-verify.sh (28 checks with docker/ssh/curl mocked on a real tree) in CI.
Acceptance. Repository half: docs/ops/x4b-acceptance.md. Live half: verify.sh prod --storage-box … --key … --api … --resolve … on the production box with every line pasted; the screenshot/ls of the file on the Storage Box; the freshness row green; the three folders' permissions listed.
Amended 2026-09-19 (the owner's decision: production at the terminal go-live). The production stack is deployed only at the terminal go-live (X7). The live half above runs on the production box then, as X7's go-live checklist records it (open item #36). Nothing in S1, S2 or S3 waits for it. The repository half stays X4b's S1 acceptance.
A2e · Every background job in the api container is bounded · L · W6 · feat/a/a2e-bound-api-background-fanout (S1-rev)
Context. Open item #17, discovered by A2c. The job gate bounded the image-producing runs, the clip renders and the exports and left 33 spawn or submit sites outside it: a daemon thread per request for a Localize Video run, the review-only Expand retry, the bulk-recreate launcher, a saved document's embedding and Supermemory ingest, a creative's Supermemory ingest or delete, the storyboard's voice clone, song transcription, shot-writing, reference-analysis and cast stages, and the Intelligence-v2 T1 eval. Two consequences: the thread count grows with the burst (the api's pids_limit is the only backstop, and it fails thread creation rather than queueing work), and the work vanishes with a redeploy (the embedding a save promised never happens and the document reads as indexed; a bulk recreate's run stays running for ever). The outcome this card buys: no background unit of work in the api container runs outside a cap, and none can be lost with the process.
Read first. app/core/jobs/limits.py (the gate, the box-wide ledger, KINDS); services/pipeline_runner.py (the Localize start and restart, retry_pipeline, retry_expand_pipeline, the restart staleness rule); the five storyboard stage runners and storyboard_service's claims (claim_audit_step, _claim_json_state); services/chatcut_queue.py and backend/worker.py (the lease template and the handler shape); embedding_service, supermemory_ingest, t1_eval, recreate_service (the bodies and their spawn sites); pipeline/localize_video.py (the caption thread, its shutdown) and pipeline/utils/media_ffmpeg.py; ADR-008 (the workspace of a unit of work), ADR-009 (the product boundary), docs/ops/RESOURCE_LIMITS.md (the table this card extends).
Deliverables. (1) Two more box-wide kinds on the gate: localize (MAX_CONCURRENT_LOCALIZE_JOBS, 1) for a Localize Video run and background (MAX_CONCURRENT_BACKGROUND_JOBS, 2), each in its own advisory-lock namespace; the Localize run and the review-only retry go through the session-job submit with their own slot names; every cap validated at boot. (2) The step retries take the session's advisory lock, refuse a live row (RetryConflictError → 409, the restart's staleness rule) and flip the row to starting inside the locked transaction. (3) The storyboard auxiliary stages claim, then submit to the background gate through services/storyboard_stage.py; a waiting stage's record carries queue_position and a moving heartbeat_at, which both claim predicates read before started_at; a stage that can never start is written failed; the cast chain gains its own claim. (4) The work that must not vanish is one leased table, production.background_jobs (migration production/006), and one worker handler dispatching on kind to the existing bodies: enqueued on the caller's cursor so the row commits with its write, key-only payloads (the body re-reads current bytes; one pending row per key; a save during a run queues a second that runs again with the newest bytes), workspace_id NOT NULL bound before the body runs, a background slot taken before a row is claimed, a failed attempt retried with a cooldown and exhaustion writing the kind's own record conditionally in the same transaction. (5) The seams: the re-index is one forced job per document of the workspace, admin-only, with a counts GET; the T1 ingest reply adds eval_job_id + eval_job_status; the bulk recreate resumes from its progress and yields its slot while it drains. (6) Inside a Localize run every ffmpeg / ffprobe goes through one chokepoint with a hard per-call timeout (LOCALIZE_FFMPEG_TIMEOUT_S) and a thread pin (LOCALIZE_FFMPEG_THREADS), pinned by an AST source scan over the pipeline package; the shutdown joins the caption thread with no timeout; the run's caps are refused at boot when unreadable. (7) ADR-016; RESOURCE_LIMITS.md extended (no "not bounded" paragraph remains); the compose literals, the env example, compose-limits.py's cap list, CLAUDE.md, LOCAL_DEV.md.
Acceptance. pytest apps/api/app/backend/tests green, including the source scans (no bare spawn, no bare process start in the pipeline package, every stage through the gate, every family enqueues), the gate tests (kinds, namespaces, box-wide admission through two ledgers), the retry claim tests (a live row refuses, a dead one may be retried, the review retry is a background job), the stage tests (claim then submit, the heartbeat keeps the claim, a failed start is recorded), the queue tests against Postgres (the migration, one pending row per key, the lease, cooldown, the reaper with exhaustion, admission), the runner and body tests, the resumable bulk recreate; docs/ops/a2e-acceptance.md; open item #17 closed.
Out of scope. Any web change (a fourth T1 badge, a queued pill beyond starting + queued: — Figma-gated); retiring the per-product Localize routes; workspace_id on bulk_recreate_runs (S5); the storyboard render's own ffmpeg variant calls (X3b territory).
Waits on / Unblocks. A2c (A) / the honesty of the X3b pressure test; the S2 review's resource table.
A3c · The rollback resolves the images the deploy pushed · S · W6 · fix/a/a3c-rollback-image-tag (S1-rev)
Context. Open item #52, found the first time A3b's rollback path was executed (X5's staging drill, 2026-09-08): infra/deploy/rollback.sh 9e37790621b5… died in 0.9 s with Error response from daemon: manifest unknown and changed nothing. The script resolves a target commit through the main-<sha> TAG, but the workflow tags with type=raw,value=main-{{sha}}, which expands to the SHORT sha — measured from the build jobs' own logs of run 34162947895 (gh api repos/Rescale-Media/skynet/actions/jobs/<id>/logs): skynet-api and skynet-web each pushed :latest, :main and :main-c15a432. Meanwhile the deploy writes IMAGE_TAG=main-<full sha> into .env.release, a tag that was never pushed. Nothing else reads that tag — the deploy pins by digest — so the disagreement stayed invisible until the path ran. It is not a staging annoyance: docs/GO_LIVE.md row 8 tells the operator to prove both images with docker manifest inspect …:main-$TARGET (full sha) before a production rollback, so it would have failed mid-cutover.
Read first. infra/deploy/rollback.sh:36-46 (the only reader of the tag) and :54 (the IMAGE_TAG it writes); .github/workflows/deploy.yml:66-70,105-110 (the metadata-action tags) and :222-230,374-380 (the IMAGE_TAG the deploy writes); infra/compose/compose.staging.yml:14,32,107 + compose.prod.yml (images by digest — why the tag has no other reader); docs/runbooks/ROLLBACK.md § 3 table row "Images", § 6 drill, the troubleshooting row; docs/GO_LIVE.md row 8; docs/runbooks/DEPLOY.md (the tags column).
Deliverables. (1) rollback.sh resolves a target given EITHER form: try main-$SHA, then main-${SHA:0:7}, and when both fail say which two references were tried. (2) The workflow's tag set carries the FULL sha so the registry and .env.release state the same thing, without dropping the short one that every image built so far carries. (3) One check that fails without the fix, in CI rather than in prose. (4) The documents that instruct an operator are corrected together — ROLLBACK.md § 3 / § 6 / troubleshooting, GO_LIVE.md row 8, DEPLOY.md's tags column — and none of them may keep saying main-<full sha> while only the short tag exists.
Acceptance. A rollback resolves with a full sha AND with a seven-character one; IMAGE_TAG in a rendered .env.release names a tag that exists in the registry; the new check fails against the pre-fix workflow and passes after it; shellcheck clean; every runbook line that names a tag matches what the build jobs push, quoted from a job log in docs/ops/a3c-acceptance.md.
Out of scope. Running X5's drill itself (the operator's, and its transcript belongs to x5-acceptance.md); the production --restore-db branch, which only go-live can exercise; retiring latest and main as tags (they move on every default-branch build, so the card only records that neither is a rollback target).
Waits on / Unblocks. A3b (A) / X5's staging leg and GO_LIVE.md row 8 — both drill exactly this path.
A3d · The address a rate limit may key on · M · W6 · fix/a/a3d-forwarded-client-address (S1-rev)
Context. Open item #53, measured against the deployed staging stack on 2026-09-08 through the Plesk vhost: with no X-Forwarded-For, twelve POST /api/auth/login gave ten 401 then 429; with X-Forwarded-For: 203.0.113.$i differing per request, twelve gave 401 and no refusal at all. Anyone on the internet had unlimited credential attempts against every account, for the price of one header. Two settings made it so and neither was wrong alone: the vhost APPENDED ($proxy_add_x_forwarded_for), so the caller's value led the chain, and infra/caddy/Caddyfile set trusted_proxies without trusted_proxies_strict, so Caddy's non-strict traversal took the first untrusted address FROM THE LEFT — the forged one. The api's own 20/min backstop read the leftmost value too, so it could not have caught it either. The repository already held the right rule, written for the OTHER edge: docs/CUTOVER.md row 9 tells the old host to replace the header and says why, while docs/runbooks/STAGING.md § 1 step 4 told the operator to append at the edge that faces the internet. The operator corrected the live vhost the same day and the bypass is closed there; this card is everything that stops it returning, on staging and on the production box X7 has not built yet.
Read first. infra/caddy/Caddyfile:16-19,33-41 (the trust boundary and the credential zone); apps/api/app/core/auth/router.py:44-80 (the backstop and _client_key); docs/runbooks/STAGING.md § 1 step 4; docs/CUTOVER.md row 9 and docs/GO_LIVE.md row 9 (the old-host rule, already correct); infra/caddy/test.sh:56-80 (the friendly half, asserted since A3 and run by no CI job); .github/workflows/ci.yml the gateway job (build + caddy validate, no behaviour); infra/smoke/run.sh:48-53.
Deliverables. (1) The rule, written down once: ADR-019, naming the three mechanisms and the single cutover exception. (2) trusted_proxies_strict in the Caddyfile, so an appending edge cannot poison the key even where the edge is not ours. (3) The gateway SETS X-Verified-Client-IP from the address it resolved, replacing any inbound copy, and the api's _client_key reads that or the socket peer and never X-Forwarded-For again. (4) infra/caddy/test-trusted-proxies.sh — the built image, a stub upstream, one trusted and two untrusted sources on a pinned network — in the gateway CI job, which until now proved only that the config parses. (5) The documents that instruct an operator, corrected together: the staging runbook's block (which also carries open item #50 — Plesk REFUSES the current block, since Proxy mode already provides a location / and its own client_max_body_size, and location ^~ / does not avoid the duplicate), and the two cutover rows whose stated mechanism no longer matches the gateway. (6) An opt-in destructive probe in the smoke gate (SMOKE_TRUSTED_PROXY_PROBE=1) that proves it through the real vhost and drains its own bucket before it exits.
Acceptance. The gateway suite passes on the built image and its forged-prefix case FAILS with trusted_proxies_strict removed; the api test fails the moment _client_key reads X-Forwarded-For again; the live probe answers 429 after ten events with Retry-After and an empty body, then 401 once the window passes; the runbook's own two-burst acceptance is stated in the step that configures the vhost; shellcheck clean; the evidence, including the before-and-after bursts, in docs/ops/a3d-acceptance.md.
Out of scope. Retiring the api backstop (it stays a load backstop, above the gateway's 10/min); mTLS, PROXY protocol or a dedicated ingress for the cutover bridge (ADR-019 records why a TTL-scoped trusted address is preferred and when to revisit); running the probe on every deploy — it spends the deploy egress's bucket, so it is opt-in by design.
Waits on / Unblocks. A3 (A) / X7's production vhost, which must inherit the corrected block rather than the one that was measured to be bypassable.
A3e · The wait's clock belongs to the runner · S · W6 · fix/a/a3e-chatcut-clock (S1-rev)
Context. Open item #51. test_chatcut_runner.py failed intermittently in CI only, assert 92 == 91, on five runs across four different cards and in TWO different tests of the file — including one whose entire diff was a markdown file. It was recorded as "a poll-count flake under CI load" and cleared with gh run rerun --failed, which was wrong twice over: the wait's clock is faked, so wall-clock pressure cannot move it, and a rerun hid the cause. THE CAUSE: the fixture patched sleep and monotonic ON THE time MODULE, and chatcut_runner imports that module, so the wait's clock was the whole PROCESS's clock. Two writers then shared it. subprocess.run(timeout=…) waits for a real ffprobe with CPython's own exponential backoff (0.001, 0.002 … 0.05) — measured in this file's own instrumented run — and any thread an earlier test left running sleeps whenever it likes. Worse, now += seconds from two threads can lose an update outright, dropping a whole 20-second lap, which moves the agent-ceiling shortcut one poll LATER — the exact direction and size of the observed failure. The test that failed was never the test at fault.
Read first. apps/api/app/backend/tests/test_chatcut_runner.py (the world fixture's clock, and the two assertions on the ceiling poll); apps/api/app/backend/services/chatcut_runner.py:663-726 (_poll, one sleep per lap) and :826-853 (the watcher's elapsed_s < AGENT_CEILING_S); apps/api/app/pipeline/utils/media_ffmpeg.py (run_tool → subprocess.run(timeout=…), the other writer).
Deliverables. (1) The runner is given its OWN clock object (monkeypatch.setattr(runner, "time", …)) rather than two attributes on the shared module; anything the runner reads from time that the wait does not fake falls through to the real module, so a new call site cannot be silently swallowed. (2) A test that pins the isolation itself — the wait's clock is not the process's clock, an outside sleeper does not move it, and only the runner's own sleep does. (3) The two ceiling assertions state what they mean: one interval per lap (named, so a polluted clock says so in words instead of an off-by-one), the endpoint never read BEFORE the ceiling (the safety property, an inequality), and the exact ceiling poll computed with CEILING division so a future interval that does not divide the ceiling cannot invent a phantom off-by-one.
Acceptance. The isolation test fails with the module-level patch restored and passes with the card's; the file passes; the full api suite passes with the same count as main; the CI api job stops needing a rerun. Evidence in docs/ops/a3e-acceptance.md.
Out of scope. The pre-existing ENCRYPTION_KEY environment failure of test_the_caption_pass_is_on_unless_it_is_turned_off when the file is run standalone on a developer box (identical on main; open item #6's territory).
Waits on / Unblocks. — / a green main that means something, and every card whose CI no longer costs a rerun.
A3g · The worker keeps the group Docker granted · S · W6 · fix/a/a3g-worker-backup-group (discovered on the staging box 2026-09-17 with the owner, open item #62)
Context. Staging's backup_freshness check has written fail with the detail no dump matches every five minutes since the stack went up, and open item #62 read that as a sidecar that never dumps. Measured on the box on 2026-09-17: the sidecar is fine. pgdump/ holds a skynet-staging-<date>-010000.dump for every night since 2026-09-04 (about 2 MB, mode 640, owned by the subscription's 10004:1002), and the one FAILED line (2026-09-15 13:31) is the minute the stack was down and postgres did not resolve. The api container mounts the same directory read-only and Docker adds group_add: ${BACKUP_GID} (1002): PID 1 shows Groups: 0 1002, the ACL mask is r-x, and root or docker exec -u 1000 lists the dumps. But PID 1 is supervisord (apps/api/supervisord.conf, [program:uvicorn] and [program:scrape_worker] both user=user), and when supervisord switches to uid 1000 it rebuilds the supplementary groups from the image's /etc/group, where 1002 does not exist: both processes read Uid: 1000, Groups: 999. The worker that runs the synthetic cycle therefore cannot list a 750 directory owned by that group, glob swallows the permission error into an empty list, and check_backup_freshness reports no dump matches — a sentence about a missing file for a permission it does not have. compose.prod.yml carries the same group_add, so production would alert the same way after go-live. verify.sh never saw it because it runs as the deploy user, not as the worker.
Read first. apps/api/Dockerfile (CMD ["supervisord", …], no ENTRYPOINT; the $HOME note about supervisord's user= at :86-105); apps/api/supervisord.conf:17-35; infra/compose/compose.staging.yml:30-56 (the api service: SYNTHETIC_BACKUP_*, group_add, the :ro mount) and the same block in compose.prod.yml; infra/compose/docker-compose.dev.yml (no BACKUP_GID, must keep starting); infra/backup/pg-backup.sh:34-48 (the stance on a malformed BACKUP_OWNER); infra/backup/test-pg-ready.sh (the shell-test pattern: fake commands on PATH, one case per line); apps/api/app/core/synthetic/checks.py:180-206 (check_backup_freshness: glob.glob returns an empty list on PermissionError) and its tests; .github/workflows/ci.yml (the infra job's change filter, and the shellcheck every infra script step at :406-407); docs/runbooks/BACKUP.md:14-26,72-96; open item #62.
Deliverables. (1) apps/api/docker-entrypoint.sh, run as root before supervisord: when BACKUP_GID is set and numeric, a group with that gid exists inside the container (getent group <gid>, else groupadd -g <gid> under a plain name) and user user is added to it by the name the gid resolves to; when it is unset, nothing is touched; a malformed value exits 1 with a sentence before anything starts; the script ends with exec "$@", so the Dockerfile's CMD is what runs and a command override still works. (2) The Dockerfile gains ENTRYPOINT ["/docker-entrypoint.sh"] and keeps its CMD; no USER line (supervisord still drops to user). (3) compose.staging.yml and compose.prod.yml pass - BACKUP_GID=${BACKUP_GID:?set by the deploy from BACKUP_OWNER} into the api's environment, the same required interpolation group_add uses, so an empty value cannot reach the entrypoint; group_add stays (it still covers PID 1 and docker exec). (4) check_backup_freshness probes os.listdir(backup_dir) before the glob and reports cannot list <dir>: <exception> when it cannot, so the next permission fault is named as one; a unit test that fails on the base. (5) A shell test of the entrypoint with fake getent, groupadd, usermod and a fake command on PATH, one case per outcome; CI runs it on any pull request that touches the entrypoint or the test, and the shellcheck step covers both files (extend the infra job's filter and its find, or wire the test into the api job — name which in the record). (6) docs/runbooks/BACKUP.md: the api's worker needs the group at the PROCESS level, how to read it on the box (/proc/<pid>/status, the Groups: line carries BACKUP_GID), and verify.sh's blind spot. (7) docs/ops/a3g-acceptance.md with the measurements above.
Acceptance. The shell test: unset → no group command runs and the command is exec'd; set → the group is ensured, then the user added, then the command exec'd, in that order; a gid that already exists under another name → that name is used and no groupadd runs; malformed → exit 1 and nothing exec'd. pytest: an unlistable backup directory reports cannot list, not no dump matches (fails on the base); the whole api tier green. docker build -f apps/api/Dockerfile . builds, and docker run --rm -e BACKUP_GID=4242 <image> su -s /bin/sh user -c 'id -G' prints 4242 among the groups (a user switch that rebuilds the groups, as supervisord's does, now finds it), while the same run without BACKUP_GID prints the groups it prints today. shellcheck clean on both new files; the dev stack still starts with no BACKUP_GID. After the merge, on staging: the next synthetic cycles write backup_freshness ok with the dump's age, and the worker's /proc/<pid>/status lists 1002; open item #62 closes on that row.
Out of scope. The sidecar, the directory's ACL and the staging env, all measured correct. Production's env value. The pre-deploy dump path (compose run --rm --entrypoint sh backup runs the sidecar's image, not this one). Any other supervisord change.
Built as (PR #92 → main 0c31f5a, 2026-09-17). Co-plan 1 round → agreed with four changes (keep the Dockerfile's CMD and end the entrypoint with exec "$@"; the :? interpolation in compose; the listdir probe in scope; the CI filter must name the new files). Code 1 review round → 2 must-fixes (a card key in a workflow comment; INCIDENTS.md still told the operator to read the mount as root through exec api, the exact blind spot) and 2 notes (guard an empty command before exec; single-valued group resolution), then confirmed. apps/api/docker-entrypoint.sh runs as root before supervisord: a numeric BACKUP_GID gets a group of that gid inside the container (getent, else groupadd) and user user is added to it by the name the gid resolves to; unset or empty touches nothing; a malformed value, a multi-record gid or an empty command stops the container with a sentence; exec "$@" runs the image's CMD. The Dockerfile gains the ENTRYPOINT and keeps CMD; compose.staging.yml and compose.prod.yml pass BACKUP_GID=${BACKUP_GID:?…} into the api's environment (dev passes its own default); check_backup_freshness lists the directory before it globs and reports cannot list <dir>: <error>; the shell test (15 cases with fake getent/groupadd/usermod and command) runs in the infra CI job with shellcheck over both files; BACKUP.md and INCIDENTS.md say the group is needed at the process level and how to read it as the worker. Proven on the built image: -e BACKUP_GID=4242 … su -s /bin/sh user -c 'id -G' → 999 4242, without → 999; end to end against a real 750 mount owned by gid 4242 the worker's Groups: line carries it and the dump lists where it was Permission denied before. Whole api tier 4317 passed / 11 skipped / 3 deselected, +1 (the cannot list case, which fails on the base with the old sentence). Proven on staging after the deploy (2026-09-17 08:50Z onwards): uvicorn and the worker run as uid 1000 with Groups: 999 1002, and the synthetic cycles write backup_freshness ok, skynet-staging-20260917-010000.dump is 7.8 h old and counting. Open item #62 closed on that row.
Waits on / Unblocks. — / an honest backup_freshness on staging and on production before go-live; open item #62.
S1e · Self-service password change · M · W6 · feat/s/s1e-password-change (S1-rev)
Context. Open item #43, discovered by M6. The Settings page's Change Password control called the legacy PUT /api/auth/me/password: 410 wherever LEGACY_LOGIN_DISABLED=1 (the production template), and where the flag was off it verified and rewrote production.users.password_hash — a hash the core login never reads for an active account, and a random sentinel for every account created by an SSO login — so even its success changed nothing the user could sign in with, and the page showed "Current password is incorrect" for every status. No core route changed a signed-in user's password; the card's two named options (the reset rail; hide) both left the slice with no self-service change. The outcome this card buys: a signed-in member changes — or first sets — their own password, honestly, and every other device is signed out.
Read first. app/core/auth/router.py (_accept, _sign_in, the limiter, _password_matches), app/core/auth/auth_tokens.py (the pending-kind revocation), app/core/auth/deps.py (current_user), app/backend/routes/auth.py (the legacy rails, _refuse_when_legacy_disabled), apps/web/src/pages/system/Settings.tsx + src/lib/api.ts (ApiError, primeCurrentUser), ADR-001 §5, ADR-015 §3/§8, docs/AUTH_SESSION.md §3.
Deliverables. (1) POST /api/auth/password in the core issuer: {current_password?, new_password}, current_user (service tokens 403), same-origin, throttled per USER (5/min keyed on the subject; the limiter generalised to a key + limit); new ≥ 8 → new ≠ current → one core transaction under the row's lock: a row with a hash requires the current password ("Enter your current password" / "Current password is incorrect"), a row without one sets its first (the session is the proof, as a magic link's acceptance is); then token_version + 1, every refresh family revoked, every pending magic and reset link revoked (auth_tokens.revoke_pending_credentials), the token-version cache cleared, and _sign_in — this browser stays in, every other device is out; Cache-Control: no-store. GET /me gains has_password. (2) The SPA control re-pointed: api.changePassword posts to the new route and re-primes the identity; the current-password field is shown only for an account that has one; the catch shows the server's own detail (an ApiError), sends a lost session to /login, and names a network failure; the subtitle states the true session rule. (3) The legacy PUT /api/auth/me/password retired outright (its model, the client), POST /users gated by _refuse_when_legacy_disabled() like the other credential writers, the dead per-address limiter removed. (4) ADR-017 (amends ADR-001 §5); docs/AUTH_SESSION.md §3 + §7; docs/SECURITY.md core routes; apps/api/CLAUDE.md; docs/ops/v1-acceptance.md + m6-acceptance.md notes; docs/runbooks/LOCAL_DEV.md; docs/ops/s1e-acceptance.md.
Acceptance. pytest apps/api/app/backend/tests green, including tests/core/test_password_change.py: the change (hash replaced, tv+1, fresh cookies, no-store, the fresh session live at once, the old access token and every other refresh family dead, the caller's fresh family live, the new password signs in and the old does not), the four current-password cases, the refusal order (a short new password before any bcrypt), the NULL-hash first password through a magic link, the pending magic and reset links dead after the change (410), the credential kinds disjoint from the onboarding family, the per-user throttle (the sixth call 429, another user unaffected, the login budget unspent), the anonymous / service / inactive callers refused, a foreign origin refused, the control's source; test_auth_sso.py: the legacy rail gone, the admin rails 410 under the flag. cd apps/web && npm run build green. docs/ops/s1e-acceptance.md; open item #43 closed.
Out of scope. The login page's collapse of 429/403/5xx into "Invalid email or password" (open item #27 owns the login copy); the admin rails' TeamPage copy (V11); a "password changed" notice mail; the bcrypt cost pinned in one place; the flag's exact-'1' match; the email-case split of the legacy bridge.
Waits on / Unblocks. S1b (S), S1d (S — the same router file; stacked on PR #52 as S1d is on S1c) / an honest settings surface before real users arrive.
Slices S2–S5 — executable specs
Same standard as S1. Waves are per slice (S2-W1 …). A slice's W0 cards depend on the previous slice's exit gate (delivery plan §2), not on a card. Discovery (D) cards are real work: an interview or confirmation with a named role, a written artefact, and a sign-off comment; their output is the input of the build cards that list them.
Discovery cards (all slices)
D21 · Research operator workflow confirmation · A · S · S2-W0
Context. W1–W3 in the workflow map are mixed: the tooling is code-backed, the operator rules are not. S2 builds the native Intelligence pages on those rules.
Deliverables. docs/discovery/s2-operator-rules.md: what makes a competitor worth tracking; cadence per brand/lens; keep/discard rules for ads and families; when a finding becomes a brief; the evidence required before a product is promoted; who seeds research keywords; who approves Promote to product; the rework loop when a ranked opportunity is rejected or deferred; where the loop breaks today. Interview: Agon + the weekly research operator (45 min), plus one screen-share of a real weekly pass.
Acceptance. Doc merged; Agon and the operator comment "confirmed" on the task; A13/A14/A15 cite the rules they implement.
Amended at D21 (2026-09-24). The owner interview took place and the owner confirmed the rules in docs/discovery/s2-operator-rules.md. That confirmation closes the decision gate. The weekly research operator role is not filled, and no weekly pass exists today; the operator's "confirmed" and the screen-share of a real weekly pass are open item #187 and do not block D21.
D22 · Product bridge field confirmation · S · S · S2-W0
Deliverables. docs/discovery/s2-product-bridge-fields.md with Sven + Cyrus: the exact ClickUp Product card fields (LISTS.products: Product ID = naming product number, market economics, status) that Promote to product must create/link, the mapping to the static-ads product model (routes/products.py:43 product, :151 brand identity, :248 looks, :282 documents), and who owns each field afterwards. Table: source · target · owner.
Acceptance. Doc merged; Sven and Cyrus confirmed; A15 cites it.
Amended at D22's draft (2026-09-24). The document is drafted from the code so that Sven and Cyrus confirm it instead of writing it: the two models as they are today (the Skynet product row and routes; the ClickUp Product card's 39 custom fields, its statuses, the counter rescale_service.next_product_number, the bridge's three routes), a mapping table that accounts for every card field and the task status with source, direction at promotion and owner afterwards, nine rules, and eleven decision questions with bounded options and one default each. Until the confirmation, the defaults stand for A15's brief: the Product ID is drawn at Testing by workos and read back (A15's card says "at approval"; question 1 decides and amends A15 if Testing wins); one Skynet product with one card per chosen market; a link record per product and market; promotion writes the card name, Market, Niche, Ad library link, Evidence, Source, Research tool, the assignee and the description, never a machine field, and not Competitor Shop URL (it fires workos intake) unless question 4 says yes; create-or-link by an explicit task id with an idempotency key; the client-file fields and a readiness checklist by role live in Skynet; the lineage edge is research → product. Questions 1, 2, 3, 4, 8, 9 and 10 block A15's build. The card's products.py anchors are :103 (brand identity), :258 (looks) and :312 (documents); A15's tests live under apps/api/app/backend/tests/core/. The implementation details the document does not decide (the link table, the bridge route bodies, the readiness table, the lineage migration) are held for A15's brief.
D23 · Reddit VOC seed confirmation · A · S · S2-W0
Deliverables. docs/discovery/s2-reddit-voc-seed.md with Mathew: the first keyword groups (cravings, blood sugar — confirm wording and subreddits/search scopes), collection cadence, retention (never truncated per group), export columns (post/comment id, subreddit, date, score, text, group, keyword hit), who consumes the export.
Acceptance. Doc merged with Mathew's confirmation; A18 cites it.
Amended at D23 (2026-09-24). Owner-confirmed seed, exact values in docs/discovery/s2-reddit-voc-seed.md: the two groups cravings (17 terms) and blood_sugar (13 terms) with their current descriptions and term lists; the nine enabled subreddit scopes r/loseit, r/WeightLossAdvice, r/EatCheapAndHealthy, r/intuitiveeating, r/diabetes, r/diabetes_t2, r/prediabetes, r/Supplements, r/nutrition (daily, 90-day lookback); the three search scopes reddit:search:cravings, reddit:search:blood_sugar, reddit:search:combined seeded paused; the ten PMS/perimenopause scopes stay disabled; the postpartum group and the run-time postpartum scopes are outside the seed and kept unchanged. Collection stays on public pages through the residential exit. Management (add, pause, resume, soft remove of scopes, groups and terms) is in the Skynet UI under intelligence.voc.manage. Mathew is not a build gate; later edits happen in the managed UI. Acceptance: the doc merged on the owner's answers and delegation.
D23b · Evidence lens seed confirmation · A · S · S2-W0
Deliverables. docs/discovery/s2-evidence-lens-seed.md with Mathew: for each VOC keyword group (first: cravings, blood sugar) the six-lens terms — mechanism, concern, ingredient(s), common solutions — plus the first RescaleOS product the evidence layer is proved on; collection cadence and the monthly re-walk; export columns (PMID, title, journal, year, evidence class, lens, group, plain-language finding, citation); who consumes the export; the claims-policy sign-off (evidence class never authorizes disease copy).
Acceptance. Doc merged with Mathew's confirmation; A18b cites it.
Amended at D23 (2026-09-24). The owner delegated D23b; the lens seed, the verified MeSH descriptors, the per-lens hit counts and the Triphala-first proof are in docs/discovery/s2-evidence-lens-seed.md. First proof product: Aleniva Triphala (blood-sugar and weight evidence match the VOC groups); Aleniva Dandelion second, as the form-mismatch test; Triphala Guggul studies are the combination-mismatch test. Claims policy adds route-mismatch, combination-mismatch and disease-population beside form-mismatch; a disease-population row never authorizes disease copy and cannot by itself substantiate a healthy-population efficacy statement. No Mathew confirmation blocks the build; he may revise the UI-managed seed later.
D31 · Bian coexistence + migration-window agreement · A · S · S3-W0
Context. RescaleOS is Bian's live production loop (NPT/BPT). S3 moves the engine, the loops and the skills onto the stack. Nothing starts without his explicit agreement.
Deliverables. docs/decisions/D31-bian-coexistence.md: the cutover order (runtime on Postgres → loops on the stack → skills in-app → console retired), the coexistence period (both run, which is authoritative), the migration window, the failure path (back to the Mac), what must never be automated, and what changes for Bian day to day.
Acceptance. Signed (comment) by Bian and Agon; A10/A11/A20 cite it.
D32 · Hook + strategy expectations · V · S · S3-W0
Deliverables. docs/discovery/s3-hook-and-strategy.md with Mathew + Cyrus: hook-session gate semantics (who approves, evidence packet, per-asset vs per-batch), what makes the coverage map actionable (which decisions, which existing artefacts they trust, what stays human), the brand-tone band and claim-validation rules for hook seeds; the exact rule for "validated learning" vs "personal taste" and what qualifies as a human-rated output for shared memory; and the six accelerator questions (claude-code-accelerator-digest.md §6): which three artefacts he touches daily; what crosses from personal instinct into shared memory and who approves; losers/dead angles first-class or implied; swipe bank shared or personal; whether "third time = workflow" deserves a productised capture flow; whether "one format first, LFS, blood sugar" is a pilot tactic or the general rule.
Acceptance. Doc merged with both confirmations; V14/V16/V18 cite it.
D33 · Script-card bridge + demand semantics · S · S · S3-W0
Deliverables. docs/discovery/s3-script-bridge.md with Sven + the ops owner: the Script card lifecycle (statuses, who flips), Demand Line provenance rules (demand-week.ts, spawn.ts), QA-gate ownership (qa-gates/*), what the bridge must preserve while ClickUp owns the Scripts list, the failure rules; Friday-call outcome ownership, manual exception paths, and the workaround rules that must survive until ClickUp is read-only.
Acceptance. Doc merged; S16 cites the state map.
D41 · Launch pilot confirmation · S · S · S4-W0
Deliverables. docs/discovery/s4-launch-pilot-confirmation.md with Cyrus + one active media buyer: the exact launch package, the proof-of-launch loop, which ClickUp fields they actually read, what they still rewrite by hand, what delays launches.
Acceptance. Doc merged; S6 and S8b cite it.
D42 · Board authority + mirror-failure confirmation · S · S · S4-W0
Deliverables. docs/discovery/s4-board-authority.md with Sven + the ops owner: authoritative states per list (from the status templates in fields-manifest.json), the field definition for ugcCreators (none exists today), acceptable mirror lag, failure handling (reconcile-cron.ts, qa-gates/*), machine- vs human-owned fields per list (from domain/ids.ts), and the ordered list of the remaining 12 boards after the launch board.
Acceptance. Doc merged with the ordered board list; S6, S26, S18, S19 cite it.
D51 · Billing + retention confirmation · S · S · S5-W0
Deliverables. docs/discovery/s5-billing-retention.md with the ops owner: the manual-invoicing process, meter granularity (model calls, scraper/runtime jobs, bridge operations), invoice-export shape, retention windows per data class, deletion approvals, who may request export/deletion.
Acceptance. Doc merged; S24, S25 cite it.
D52 · Roles + onboarding confirmation · V · S · S5-W0
Deliverables. docs/discovery/s5-roles-onboarding.md with Sven + Mathew: role matrix across Production, Intelligence, Studio, Workspace; admin boundaries; workspace-creation flow (Rescale admin only); invite rules.
Acceptance. Doc merged; S21, S23, V21 cite it.
D53 · Security review scope confirmation · A · S · S5-W0
Deliverables. docs/discovery/s5-security-scope.md: review scope (tenancy, credentials, deletion safety, abuse cases, load envelopes), external vs internal reviewer, sign-off format.
Acceptance. Doc merged; A17, S20 cite it.
S5 — SaaS hardening (specs)
S21 · RLS rollout + tenancy CI for core, production, meta_ads, studio · S · L · S5-W1
Context. Every table already carries workspace_id (S1 rule). S5 turns that into enforced isolation at the database.
Read first. db/migrations/unified/* (all schemas), apps/api/core/auth/deps.py (how the request's workspace is known), apps/api/tests/core/test_isolation.py (S13), Postgres RLS docs; the ecomprofits pattern has_role_on_account() in apps/web/supabase/schemas/03-accounts.sql as prior art.
Deliverables. Migration core/0NN_rls.sql + one per schema: alter table … enable row level security, policies workspace_isolation using current_setting('app.workspace_id')::uuid; the API sets set local app.workspace_id per request/transaction (apps/api/core/db/session.py), jobs set it per workspace loop; a bypass_rls role only for migrate/backup; pytest apps/api/tests/security/test_rls.py (cross-workspace fixtures for all four schemas, direct SQL as the app role → 0 rows); CI required.
Acceptance. Tests green; select count(*) from production.sessions as the app role with a foreign workspace id = 0; existing S13 tests still green.
Out of scope. Billing; UI.
S22 · Per-workspace credentials, keys, quotas + admin APIs · S · L · S5-W2
Read first. core.integration_credentials (S5a), core.providers (V4), core.spend_guards; D51.
Deliverables. core.workspace_quotas(workspace_id, key, limit, window, action ∈ warn|pause) for model spend, scraper jobs, runtime sessions, storage GB; provider keys and residential/R2 settings per workspace (encrypted); admin APIs GET/PUT /api/admin/workspaces/:id/credentials, /quotas; resolver resolve_credentials(workspace, provider) used by every caller; pytest apps/api/tests/core/test_workspace_credentials_and_quotas.py.
Acceptance. Tests green; a workspace with no key of its own cannot use the platform default unless flagged inherit_platform_keys.
A22 · Sidecar + runtime quota enforcement · A · M · S5-W2
Read first. S22 resolver; scraper settings reads, transcriber, agent runtime accounts.ts, workos config, production gateway.py (V8).
Deliverables. Every service resolves workspace-scoped credentials and checks quotas before work: scraper crons per workspace, transcriber batches, runtime sessions, workos bridge calls, production model calls; pause → 402/skip with a logged reason; pnpm --filter scraper test && pnpm --filter agent-runtime test extended; a staged quota-breach transcript for two services.
Acceptance. Transcript attached; no service reads a global key when a workspace key exists.
S23 · Audit log + admin provenance · S · M · S5-W3
Context. Multi-tenant operation needs an unforgeable record of who did what in which workspace; S1–S4 only log locally.
Deliverables. core.audit_log(id, workspace_id, actor_id, actor_kind ∈ user|service|system, action, target_type, target_id, evidence jsonb, created_at); writers for admin actions, gate decisions, bridge mutations, membership changes, impersonation (if any), and the role lifecycle (role.created, role.updated, role.deleted, member.role_granted, member.role_revoked) with evidence holding the previous and new role definition and the previous and new effective permissions; GET /api/admin/audit?…; retention per D51; pytest apps/api/tests/security/test_audit_log.py with an end-to-end chain for a role change.
Acceptance. Tests green; the chain artefact attached.
Out of scope. Impersonation features themselves; external SIEM export.
V21 · Admin / users / roles parity + invite-only onboarding + workspace creation · V · L · S5-W3
Read first. D52; V5 admin; S1b invites; S22/S23 APIs.
Deliverables. Admin area: workspaces (create — Rescale admin only — with name, slug, plan notes); members screen with multi-role assignment; role editor (admin only): system roles read-only, custom roles per workspace — create / clone a system role / edit permissions / delete (blocked while assigned); effective-permissions drawer per member; no direct per-user permission editing; invites assign one or more roles at invite time; credentials + quotas per workspace (masked), audit viewer, invoice exports (S24). Onboarding: invite → accept → first login → workspace switcher. pnpm --filter web test -- admin-users-roles-onboarding.
Acceptance. Walkthrough: create workspace → invite user → assign roles → user sees only their workspace; recording attached.
S24 · Usage metering ledger + invoice export · S · L · S5-W4
Context. Manual invoicing is in scope, so usage must be metered in a stable ledger and exported as invoice-ready monthly totals without Stripe self-serve flows.
Read first. core.usage_events (V4), scraper/runtime job tables, D51.
Deliverables. core.usage_ledger rollups per workspace per day (model spend, scraper jobs, transcriber minutes, runtime sessions, storage GB, bridge ops); GET /api/admin/usage?workspace&month; export CSV/JSON per workspace-month for manual invoicing; pytest apps/api/tests/billing/test_usage_metering.py. No Stripe.
Acceptance. Tests green; one export for a fixture month attached.
Out of scope. Payment collection, subscription checkout, customer-facing billing portals, plan-pricing automation.
A17 · Hardening: load tests, abuse cases, security-review remediation · A · L · S5-W4
Deliverables. Load test of the full stack under S5 quotas (infra/smoke/load.sh); abuse cases (cross-workspace ids in every mutating route, token replay, oversized uploads, runaway jobs); dependency + image scanning in CI; secrets audit; findings → fixes; docs/security/S5-security-review.md (findings, status, evidence).
Acceptance. Review doc merged with every finding closed or explicitly accepted by Agon; load summary attached.
S25a · Workspace export + retention jobs · S · M · S5-W5
Context. Tenants need their data back on request and stale data must age out by policy before deletion is meaningful.
Deliverables. Workspace export (all schemas + R2 prefix manifest) as a signed download; retention jobs per data class (D51) with audit entries; pytest apps/api/tests/security/test_export_retention.py.
Acceptance. Test green; one export bundle for a fixture workspace attached.
Out of scope. Deletion itself (S25b).
S25b · Deletion queue, dry-run, purge across Postgres / R2 / lineage · S · M · S5-W6
Context. Deletion must be complete across Postgres, R2 and lineage, approved, and provable — the last SaaS-hardening control.
Deliverables. Deletion request queue with approval, legal-hold override, dry-run report, purge across every schema + R2 prefixes + lineage nodes, audit entries; pytest apps/api/tests/security/test_delete_purge.py.
Acceptance. Test green; one dry-run deletion report attached.
Out of scope. Legal review of retention law; backups purge (handled by retention policy on the Storage Box).
S20 · SaaS readiness review → 100 % · S · S · S5-W6
Context. The single go/no-go that defines 100 %: every control above verified, every workflow native, nothing pending.
Deliverables. docs/decisions/S20-saas-readiness-review.md: checklist over tenancy (S21), credentials/quotas (S22, A22), audit (S23), admin/onboarding (V21), metering/invoicing (S24), hardening (A17), retention/deletion (S25); every workflow in the source map native; standalones retired; company-ops lists still in ClickUp by design.
Acceptance. Review merged with pass on every line and an empty follow-up list — the definition of delivered.
Out of scope. New scope; anything not already carded.
S2 — Intelligence merge (specs)
Facts every S2 card relies on (verified against meta-ads-scraper): one container, ecosystem.config.cjs runs server.js (Express :8080, all node-cron jobs) + admin/ (Next.js 14, :3000); DB = Supabase schema meta_ads via supabase-js clients with db: { schema } (server.js:130–141, admin/lib/supabase.ts, admin/lib/supabase-service.ts); 18 .rpc() sites / 17 functions (ads_browse, ads_browse_meta, brands_browse, teardowns_browse, intel_coverage, intel_label_distributions_v2, gold_next_item, gold_sample_draw, gold_scoring_rows, mi_claim_work, refresh_brand_metrics, enqueue_brand_teardowns, enqueue_missing_classifications, enqueue_missing_transcripts, research_bump_runs_seen, research_product_inputs, research_products_ranked); tables brands, ads, ad_tags, tags, ad_classifications, ad_transcripts, ad_teardowns, ad_rank_snapshots, admin_users, brand_metrics, brand_label_shares, scrape_jobs, scrape_job_ads, scrape_job_windows, settings, proxies, creative_gold_labels, gold_evaluations, gold_sample_batches, gold_sample_items, mi_scopes, mi_documents, mi_document_scopes, mi_brand_aliases, mi_business_snapshots, mi_fetch_queue, research_niches, research_keywords, research_runs, research_run_queries, research_products, research_product_ads, research_product_scores; media lib/media-storage.cjs (STORAGE_PROVIDER=r2, bucket meta-ads-media, signed URLs via admin/app/api/media/signed-url); crons in server.js: scheduler */30, reaper */10, brand metrics */20, transcription */10, classification */15, teardown */5, Reddit 5,20,35,50 * * * *, Trustpilot hourly, Amazon 6-hourly, retention 40 3 * * *; engines scraper.js (Playwright quick scrape), lib/deep-crawl/*, lib/research/{runner,cluster,score}.cjs, lib/teardown/{worker,prompts}.cjs, lib/market-intel/*, lib/analysis/{worker,taxonomy,gold-eval}.cjs, lib/creative-family.cjs, lib/transcription/*; admin pages (19): / (brands index), /brands/[brandId], /brands/new, /ads, /breakdowns, /intel, /intel/gold, /rank, /research, /research/keywords, /runs, /market-intel, /market-intel/{amazon,trustpilot,reddit,scopes}, /settings, /users, /login; 40 admin API routes under admin/app/api/* (ads tags, auth, brands + schedule + teardowns, crawl, gold, intel, jobs + retry, market-intel aliases/documents/thread/reddit-stats/scopes/run + amazon/trustpilot reviews/run/scopes/stats, media/signed-url, rank + history, research + keywords, scrape, settings, tags, teardowns, users); Express routes in server.js: /health, /jobs*, /scrape, /crawl, /jobs/:id/cancel, /transcripts/{run,stats}, /analysis/{run,stats}, /teardowns/*, /gold/batches*, /research/*, /market-intel/{trustpilot,amazon,ingest}/{run,stats}; env: PORT, SCRAPER_API_KEY, SUPABASE_URL/ANON_KEY/SERVICE_KEY/BUCKET, STORAGE_PROVIDER, R2_ACCOUNT_ID/ACCESS_KEY_ID/SECRET_ACCESS_KEY/BUCKET_NAME/PUBLIC_URL, TRANSCRIBER_URL, DEFAULT_PROXY_GEO, MARKET_INTEL_ENABLED, MARKET_INTEL_AMAZON_ENABLED, MARKET_INTEL_TRUSTPILOT_ENABLED, MARKET_INTEL_AUTHOR_SALT; RPC signatures (for the pg layer): ads_browse(17 filter args, sort_key_arg text='newest', limit_arg int=144, offset_arg int=0) → 33-col table, ads_browse_meta(same 17) → (total_count, undated_in_scope), brands_browse(15 args) → 28-col table, teardowns_browse(brand, status, version, limit=25, offset=0), enqueue_brand_teardowns(brand, version, limit=50, requested_by) → int, enqueue_missing_transcripts(batch_limit=500), enqueue_missing_classifications(version, batch_limit=500), current_analysis_version(), refresh_brand_metrics(), gold_sample_draw(batch), gold_next_item(batch, labeller, lease_minutes=30), gold_scoring_rows(batch), intel_label_distributions(version), intel_label_distributions_v2(version, brand_ids[], start, end, media_type, min_confidence, include_fallback=true), intel_coverage(same 7), mi_claim_work(kind, limit=10, lease_seconds=900) → setof mi_fetch_queue, research_bump_runs_seen(run), research_product_inputs(run), research_products_ranked(run, niche, trend, min_score, search, limit=100, offset=0) → 25-col table; triggers gold_release_claim, preserve_ad_owner_brand, set_updated_at; helpers parse_ad_date, progress_heartbeat; static-ads' duplicate scraper app/backend/routes/scraped_ads.py, services/decodo_client.py, scrape_runner.py, ad_collections.
A27 · A read-only MCP server for external agents · A · M · S5-W6
Context. The old Intelligence database on Supabase carries a llm_reporting schema of nine read-only views and a cluster-wide llm_reader login, built so an external stakeholder's LLM agent could read the ads data; no code of ours calls it, and A4's local legs (2026-09-15) found no call site in the mirror. Open item #75 asked where that pair lives on the unified cluster. The owner decided on 2026-09-17 that it does not: the views and the login are DROPPED at A9's cutover, and external agents are served instead by an MCP server built at the end of the plan. A raw Postgres login for a third party has no credential lifecycle, no audit and no way to say which party sees what; an MCP server puts the same questions behind typed read-only tools, per-party credentials and the app's own permission layer. Consequence, stated on purpose: between A9's cutover and this card no external agent has access to the Intelligence data; the stakeholder is told before the cutover.
Read first. Open item #75 and A4's records (docs/ops/a4-acceptance.md, docs/decisions/ADR-020-intelligence-data-path.md) for what the nine views answered; the scraper mirror's llm_reporting migration for their definitions; A9a (the admin's access rules in the app layer) and S22 (per-workspace credentials, keys, quotas) for the permission and credential seams this card builds on; A19 (the scraper retirement) for the moment the unified data becomes the only source; the api's existing auth (app/core/auth) and the rate-limit posture at the gateway (A3, A3d).
Deliverables. (1) An MCP server in the api, served over hosted HTTP (stdio optional, for a local integration) and behind the gateway, that exposes the questions the nine views answered as typed READ-ONLY tools over the unified Intelligence data, every tool going through the app's permission layer under a dedicated per-party policy (an external principal is not an ordinary app user). (2) Per-party credentials with rotation and revocation, an audit row per tool call (party, tool, arguments' shape, row count, latency), and rate and row caps per party. (3) A registry of which party sees which tools, editable by an admin without a deploy. (4) A short guide for the external party: how to connect, what each tool answers, the caps. (5) docs/ops/a27-acceptance.md.
Acceptance. A test party connects over HTTP with its credential, lists only its tools, and every call returns rows the permission layer allows and nothing else; a revoked credential is refused on the next call; the caps refuse the call past them with a sentence; every call has its audit row; no tool can write; the server exposes no database login of any kind. Whole api tier green.
Out of scope. Write tools of any kind. Any direct database login for a third party. Recreating the llm_reporting views or the llm_reader role on the unified cluster (dropped by decision).
Amended 2026-09-19 (the owner's decision: production at the terminal go-live). The unified cluster never creates llm_reporting or llm_reader. The old standalone copies remain available until X7's source switch and are then retired with the old project. The no-access interval for external agents begins at that X7 switch, not at A9, and ends when this card is accepted. The stakeholder is told before the X7 switch. A19's "the unified data is the only source" holds from that switch.
Waits on / Unblocks. A19 (the unified data is the only source), S22 (the credential seam), A9a (the permission layer) / external agents' access to the Intelligence data; the stakeholder behind open item #75.
A4a · The S2 mirrors refresh to a pinned upstream SHA · A · M · S2-W1 · chore/a/a4a-refresh-s2-mirrors
Context. AGENTS.md:22 promises that services/scraper and services/transcriber are refreshed at slice start with skynet-w0/a1a-import.sh, but that script is a one-shot assembly: it pins SRC_SCRAPER=d842453, exits when its branch exists and reads its rewritten clones from another session's scratchpad. The mirrors at c0ac54a equal the standalone repository at d842453 plus Skynet's own deltas, and the standalone repository has moved on. A4 must prove the port against the current scraper with exact provenance (open item #73).
Read first. skynet-w0/a1a-import.sh; docs/ops/a1a-acceptance.md; git log of services/scraper and services/transcriber after the import (the A1a runtime bumps, the nested CI removal); the 2026-09-09 history rewrite in the session handoff (commit-message and comment scrub); infra/secret-scan/gitleaks.toml.
Deliverables. (1) infra/import/mirrors/: a re-runnable refresh (refresh-s2.sh, a transform specification, the lock s2-mirrors.json — source repository, target SHA, the old boundary and its rewritten counterpart, path mappings, transform and overlay digests, the git-filter-repo version and interpreter), the Skynet overlays as ordered patches, and a test over a synthetic fixture for every stop. (2) The rewrite reproduces the existing import exactly (the rewritten d842453 is the imported boundary commit; old rewritten IDs unchanged), including any content or message scrub the import and the 2026-09-09 rewrite applied, with no secret value committed (run-time inputs by digest). (3) Imported commit messages carry no tool attribution. (4) The refreshed tree equals the pinned upstream after the rewrite plus exactly the declared overlays; the merge is built with --no-ff --no-commit, compared by path/blob manifest and committed only on equality, as one merge commit pinning the source SHA and the manifest digests. (5) Every stop — SHA absent, boundary mismatch, tool or spec mismatch, overlay conflict, manifest mismatch, a secret finding over the tree or the imported range, an attribution hit — leaves no commit and no changed tree. (6) docs/runbooks/MIRROR_REFRESH.md; AGENTS.md:22 names the procedure. (7) Both mirrors refreshed on the branch; docs/ops/a4a-mirror-refresh-acceptance.md with provenance and the measured proofs.
Acceptance. The procedure's test green; the refresh run's proofs in the record (boundary identity, commits added, manifest equality, both scans clean, git log --follow continuity); CI green. The PR merges with a merge commit, not a squash — a squash would flatten the imported history; this exception to the house squash rule is the owner's decision.
Out of scope. Porting anything (A4, A9); the services/agent-runtime and services/workos mirrors.
Built as (PR #79 → main merge commit 32cfffc, 2026-09-14). Plan 2 review rounds; code 6 concerns with re-checks (the core with merge ownership, transform + attribution, overlays + test, docs, generated test inputs, exact stop assertions). infra/import/mirrors/ — refresh-s2.sh + mirror_refresh.py, the lock, the spec, two overlays, the pinned rewrite tool, a 62-case test; the runbook docs/runbooks/MIRROR_REFRESH.md; AGENTS.md names the procedure. The real refresh ran on the branch: meta-ads-scraper@e818b711 (10 commits) → merge f1b8494; boundary d842453 → 92a6d55 identical (122 ids), attribution 43/43 reviewed occurrences / 0 hits, three leak scans clean, manifests equal, git log --follow continuous. Merged with a merge commit (owner decision). Record docs/ops/a4a-mirror-refresh-acceptance.md; follow-ups open items #78, #79, #82.
Waits on / Unblocks. The owner's merge-method decision / A4.
A4b · The mirror-refresh test runs in CI · A · S · S2-W0 · chore/a/a4b-mirror-refresh-ci (surfaced by A4a on 2026-09-14, open item #78)
Context. infra/import/mirrors/test-refresh-s2.sh is what proves A4a's procedure: measured at 32cfffc, 1,358 lines that build 54 fixture clones with no network and report 60 results (45 expect_stop, 4 stops after the branch has moved, 2 stops when another process owns the state, 9 aggregate checks). Nothing runs it. CI only reads it: the infra job's find infra -name '*.sh' -print0 | xargs -0 shellcheck (.github/workflows/ci.yml:407-408) shellchecks the file, and the infra filter key already fires on every infra/** change. Running it needs the lock's exact tools, and mirror_refresh.py cmd_check_tools (:738-774) refuses anything else — Python 3.13.15 by platform.python_version(), git-filter-repo 2.47.0 by package version and module sha256, git 2.43.0 by git version, gitleaks 8.30.1 by gitleaks version. The A4a record says it plainly under "Notes": the test is not in CI yet and the job is a follow-up. Until it runs, a later change to the procedure breaks it unseen until the next slice-start refresh — which is A4c's.
Read first. infra/import/mirrors/test-refresh-s2.sh:1-45 (what the fixture is, the invocation, the tool check it makes first); infra/import/mirrors/mirror_refresh.py:738-774; infra/import/mirrors/requirements.txt (the hash-pinned wheel and the install line); infra/import/mirrors/s2-mirrors.json (tools); docs/runbooks/MIRROR_REFRESH.md §2; .github/workflows/ci.yml — the changes job and its filter keys (:28-136), the infra job (:386-477), the gitleaks install of the secrets job (:636-640), the ci aggregator (:653-669); docs/CI.md (the job table and the aggregator paragraph).
Deliverables. (1) A filter key mirrors in the changes job on infra/import/mirrors/** and the workflow itself — its own key, never the infra key, which fires on every infra/** change and would run the test for an unrelated script. (2) A job mirrors that installs the locked tools and proves them with check-tools before it runs anything: the interpreter at the locked patch version, the wheel with --require-hashes from requirements.txt, gitleaks at the pinned version installed the way the secrets job installs it, and git at the locked version — when the runner image ships another git the job installs the locked one; the lock is never relaxed to match a runner. (3) The job runs bash infra/import/mirrors/test-refresh-s2.sh --python <pinned> --gitleaks <pinned> and fails on any non-zero exit; the four versions and the run's last line (N passed, M failed) stay in the log. (4) The job joins the ci aggregator in both places — the needs list and the result loop — so the one required check covers it and a path-filtered skip still counts as green. (5) docs/CI.md gains the job's row: its filter, what it proves, and why the pins are the lock's and not the workflow's. (6) docs/runbooks/MIRROR_REFRESH.md §2 says CI proves the procedure on every change to infra/import/mirrors/**, so a change there is reviewed against a green run.
Interfaces. Exposes the job name mirrors to the ci aggregator's needs list and result loop. Consumes the lock's tool pins; the job declares no version of its own beyond what it must install.
Acceptance. On a pull request that touches infra/import/mirrors/** the job runs green, and the log carries check-tools' four versions and the test's final count with 0 failed; on a pull request that touches nothing there the job is skipped and ci is green; one deliberate one-line break of the procedure on a scratch branch turns the job red with the test's own FAIL line, recorded and not merged; docs/ops/a4b-acceptance.md with the four versions, the case count and the measured wall-clock time of one run.
Out of scope. Running a real refresh in CI — it needs a full clone of the source and a clean branch, and stays an owner-run procedure; the agent-runtime lock (A4c); the driver's summary wording (A4d); any change to the procedure itself.
Waits on / Unblocks. Nothing / every later change to the refresh procedure, A4c first.
Built as (PR #94 → main 9f74f1c, 2026-09-17). Co-plan 1 round → agreed with one correction (an explicit apt-get update and an exact, fail-closed resolution of one noble build, git and git-man installed together). Code 1 review round → 2 must-fixes, 2 re-checks, then APPROVE; a third change after the first CI run, re-checked. The filter key mirrors names infra/import/mirrors/**, the workflow, and infra/secret-scan/gitleaks.toml (must-fix 1: the test copies that config into every fixture and the procedure reads it for every scan, so a change to it must run the job; a sweep found no other file read from outside the directory). The job runs on ubuntu-24.04 by name, reads the four pins from s2-mirrors.json in its first step and declares no version of its own: the interpreter through uv python install and a seeded venv with the hash-pinned wheel and an assertion on platform.python_version(); gitleaks the way the secrets job installs it, at the lock's version; git from a build that apt itself attributes to the Ubuntu noble archive. Must-fix 2 was that selection: first it keyed on the version's text, which a PPA can imitate (a PPA publishes under the suite name noble too, and a revision shape is only a convention); it now joins each candidate version's own source lines in apt-cache policy git to the release records of the global apt-cache policy and accepts only o=Ubuntu, n=noble, installs git and git-man at that exact build with --allow-downgrades, wraps an apt failure in one sentence that names both versions and says the lock is never relaxed, checks dpkg-query against the selected build, and proves the INSTALLED version's provenance again. check-tools prints its four lines unredirected before the test runs unpiped. Measured on GitHub: the runner carried git 2.55.0 and the step installed 1:2.43.0-1ubuntu7.3 over it; python=3.13.15, git-filter-repo=2.47.0 with the locked module digest, git=2.43.0, gitleaks=8.30.1; 62 passed, 0 failed; the whole job 1 min 37 s (3 min 36 s locally on the shared 4-core box). The test prints 62 results, not the 60 this card's context states: 47 stop cases, 3 helper stops, 4 stops after the branch moved, 2 when another process owns the state, 2 pass cases, 4 aggregate checks, on a script byte-identical to 32cfffc. The deliberate break ran on draft PR #95 against the card's branch (one STOP sentence of refresh-s2.sh reworded; the lock pins no digest of that file and the fixture copies it verbatim): mirrors failed with the test's own line FAIL stop: dirty-tree: … and 61 passed, 1 failed, and the ci aggregator went red with it, which proves the one required check covers the job; the skip ran on draft PR #96 (one sentence in a record file): mirrors skipped, ci green. Both closed unmerged, branches deleted. Found by the first CI run and fixed: infra/scripts/test-image-frontend-gate.py breaks a copy of ci.yml through exact text and fails loudly when a line it edits no longer reads as expected; the new job had been inserted inside three such lines, so infra went red while mirrors was green; the filter key moved ahead of the infra filter and mirrors ahead of gateway in the aggregator's needs list and result loop (list order carries no meaning), the gate's test unchanged with all 19 cases passing. It was not caught before the push because the build box had no yq; the pinned yq v4.53.6 is now in the build scratch path and a change to ci.yml runs the gate's test locally first. The shape-based rewrite of that test sits on A4's branch and reaches main with A4's pull request. The merge commit's own CI and deploy runs were green. Record docs/ops/a4b-acceptance.md; open item #78 closed.
V39 · Every box that scrolls takes a tab stop · V · M · S2-W0 · fix/v/v39-scrollable-regions (surfaced by V34's browser drive, open item #84; carded 2026-09-18)
Context. A box with a height ceiling and a scroll utility, holding nothing focusable, takes no tab stop: text taller than the box moves by mouse or by wheel and by nothing else, and axe reports it as scrollable-region-focusable at serious impact under wcag2a. V34 met one on System → Instructions and fixed it, and that fix stands in the tree at apps/web/src/pages/system/Instructions.tsx:420-426: a <pre> carrying role="region", then tabIndex={0}, then an aria-label naming the tab's template, then the app's own focus ring outline-none focus-visible:ring-2 focus-visible:ring-ring/50, with the reason written above it from :414. It is the only role="region" anywhere under apps/web/src. Measured on main at ba66058: 46 elements in the web tree carry a scroll utility; 25 of those carry a real height ceiling (an h- or max-h- utility); 2 carry a min-h floor and no ceiling (pages/products/ProductMemory.tsx:118 and components/ui/sheet.tsx:44); the remaining 19 carry no height utility at all. Of the 25, three are the Instructions page itself: the two textareas at :385 and :394, which are controls and focus themselves, and V34's box at :424. Fourteen hold their own controls and axe accepts them: the dialog bodies (pages/products/ProductList.tsx:163, pages/products/ProductCreatives.tsx:317, pages/scraped-ads/ScrapedAdsList.tsx:956, pages/scraped-ads/RunScrapeForm.tsx:296, pages/camera-moves/CameraMovesPage.tsx:362), the menus and the select popup (pages/products/SessionsList.tsx:455, pages/localize-video/LocalizeVideoSessionsList.tsx:234, components/ui/select.tsx:84), the two sidebars of nav links (components/layout/Sidebar.tsx:108, shell/pages/admin/AdminLayout.tsx:24), the tile pickers (components/video-storyboard/CharacterPanel.tsx:592 and :1569, pages/intelligence/SourceAdPicker.tsx:102) and the keyword chips, each with its own remove button (pages/scraped-ads/DiscoveryView.tsx:444). The other eight hold text or a picture and nothing else. Each is V34's defect in a different place, and two of the eight are worse than the box V34 fixed: the session panel holds the analysis, the pipeline log and the cost report, so it scrolls on ordinary sessions rather than on long ones; and the lightbox box is built to scroll, its own zoom control reaching 400%, so above fit it scrolls in both axes with no tab stop at all. Two of the eight reach the rule sideways and are no weaker for it: BriefViewer.tsx:239 and FlowKitTestPage.tsx:280 are written overflow-x-auto max-h-…, and an element whose overflow is not visible on one axis computes the other axis to auto, so a ceiling full of pretty-printed JSON scrolls up and down as well as across.
| File | Line | The box | What it holds | When it scrolls |
|---|---|---|---|---|
apps/web/src/components/DocEditHistory.tsx | 48 | mt-2 max-h-72 overflow-auto … | DiffBlock's rendered diff rows | a diff past 18rem, one box per audit entry (:179) |
apps/web/src/pages/system/SystemDocuments.tsx | 390 | <pre … max-h-[70vh] overflow-auto> | the document's text | a document past the viewport |
apps/web/src/pages/products/ProductDocuments.tsx | 694 | <pre … max-h-[70vh] overflow-auto> | the document's text | a document past the viewport |
apps/web/src/pages/products/SessionView.tsx | 1259 | max-h-[500px] overflow-y-auto | the one open panel; text in seven of its eight branches (:1260, :1345, :1381, :1382, :1383, :1384, :1395) | any analysis, log or cost report past 500px |
apps/web/src/components/LongFormOutputPanel.tsx | 188 | max-h-[28rem] overflow-y-auto | one ad's body copy; its Copy button is a sibling above at :178-186 | a body past 28rem |
apps/web/src/components/video-storyboard/ImageLightbox.tsx | 155 | max-h-[70vh] overflow-auto | the picture alone; zoom above at :120-139, Close and Use below at :173-181 | every zoom step above fit, and ZOOM_STEPS at :12 reaches 400% |
apps/web/src/pages/intelligence/BriefViewer.tsx | 239 | <pre … overflow-x-auto max-h-60> | JSON.stringify(patterns, null, 2) alone, behind the "View raw" toggle at :219 | any pattern payload past 15rem, which is every one worth reading |
apps/web/src/pages/flowkit-test/FlowKitTestPage.tsx | 280 | <pre … overflow-x-auto max-h-48> | JSON.stringify(result, null, 2) alone, inside SmokeTest (:247) | any smoke answer past 12rem |
Context, continued. One branch of the session panel is not text: activePanel === 'expand_audit' at :1396 can render MultiAdExpandAuditPanel, which is components/ExpandAuditPanel.tsx:595 and draws a <button> per ad at :617, so in that one state the box holds a control and axe would accept it. One element cannot carry different markup per panel, the other seven branches are text (MultiAdInputPanel, MarkdownContent, ConceptsContent at :2024 and ReviewContent at :2070 hold no button, Button, anchor or onClick), and a scroll region that also holds a control is the shape WCAG asks for rather than a fault, so the region goes on unconditionally and the source says why. Two boxes carry a floor and not a ceiling and are recorded rather than fixed: pages/products/ProductMemory.tsx:118 reads min-h-[700px] overflow-auto and components/ui/sheet.tsx:44 reads min-h-0 flex-1 overflow-y-auto, so each grows to its content instead of scrolling. Neither needs a tab stop today, and giving a box that never scrolls one would be a tab order nobody asked for. The sweep in deliverable (4) reads ceilings, so neither is in it today; the day a max-h replaces a min-h the box enters the sweep unclassified and fails there instead of shipping, which is the whole reason the sweep exists. Two boxes that look the same are sound and stay untouched: pages/system/SystemDocuments.tsx:370 and pages/products/ProductDocuments.tsx:674, both flex-1 overflow-auto mt-2, each holding DocEditHistory, whose CollapsibleTrigger is a real tab stop. One trap, measured in a real browser by V34's fix across four markup variants and carried here because it is the whole reason the markup has three attributes and not two: a <pre>, and every other element whose role prohibits a name, needs role="region" BEFORE the name, or axe trades the serious report for aria-prohibited-attr and the box announces nothing at all.
Read first. apps/web/src/pages/system/Instructions.tsx:414-427 (V34's fixed box: the comment stating why the role comes before the name, and the four attributes this card copies) and apps/web/src/pages/system/Instructions.test.tsx:57-69 (the test shape: findByRole('region', { name }), the tabindex assertion, focus landing); apps/web/src/components/ui/hint-target.tsx (the whole file: useRender, mergeProps, defaultTagName, the render prop that swaps the tag, the ring class, and the doc comment's rule against a tab stop a page does not need) as the shape the new component copies; the eight sites, components/DocEditHistory.tsx:45-73 (DiffBlock) and :158-186 (the entry that must name it, who at :159 and when at :160), pages/system/SystemDocuments.tsx:370-398 (both branches, and the textarea's name at :386), pages/products/ProductDocuments.tsx:674-702 (the same pair, and the textarea's bare name at :688), pages/products/SessionView.tsx:1176-1230 (the button row that names each panel) and :1258-1403 (the box and all eight branches) with components/ExpandAuditPanel.tsx:595-625 (the one branch that draws its own controls), components/LongFormOutputPanel.tsx:173-191 (the Copy button outside the box and ad.cid in scope), components/video-storyboard/ImageLightbox.tsx:12 (ZOOM_STEPS) and :96-182 (the zoom row, the box, the buttons below, and the comment on why the box exists), pages/intelligence/BriefViewer.tsx:50 (showRawPatterns) and :215-243 (the toggle and the raw box), pages/flowkit-test/FlowKitTestPage.tsx:247-286 (SmokeTest, its label prop and the result box) with shell/production-routes.ts:128 (the route the drive already walks); pages/products/ProductMemory.tsx:108-121 and components/ui/sheet.tsx:38-48; apps/web/src/pages/products/a11y.test.tsx:247-320 (the source sweep's shape: SOURCES, findAll, one reviewed inventory per rule with its reason written down, on the stated principle that a property of the class string a file ships is checked on the source, and note that its SOURCES is a flat readdirSync of one folder); apps/web/src/test/controls.ts and apps/web/src/test/controls.test.tsx:1-40 (where a shared test helper lives and how it is proven); the eight test files the sites already own (components/DocEditHistory.test.tsx, components/LongFormOutputPanel.test.tsx, components/video-storyboard/ImageLightbox.test.tsx, pages/system/SystemDocuments.test.tsx, pages/products/ProductDocuments.test.tsx, pages/products/SessionView.test.tsx, pages/intelligence/BriefViewer.test.tsx, pages/flowkit-test/FlowKitTestPage.test.tsx); e2e/production-states.spec.ts:9-62 (what the arrival drive does and does not claim), :65 (AXE_TAGS, defined once and read by noViolations at :222-235) and :416-440; open item #84.
Deliverables. (1) apps/web/src/components/ui/scroll-region.tsx exports ScrollRegion, the one focusable scroll region in the tree, built in HintTarget's shape on useRender with defaultTagName: 'div' and a render prop that swaps the tag where a site needs a pre; it sets role="region" before any name, tabIndex={0}, and the app's focus ring, and its props type REQUIRES aria-label or aria-labelledby, so a region with no name does not compile and the aria-prohibited-attr trap cannot be re-entered by hand; its doc comment states the order and why, and states that it belongs on a box with a height ceiling whose content is text or a picture, never on one that IS a control and never on one that exists to hold its own controls. (2) The eight sites are converted to it, each named for the text it holds and not for itself: DiffBlock takes the entry's actor and instant as a prop and names its panel for that edit, because one panel per entry with one shared name announces eight identical regions; the two document viewers name the document, and ProductDocuments's editing textarea at :688 gains the same name its viewer does so the two branches agree, as they already do on the system page; the session panel names the open panel with the label its own button carries, unconditionally, with a source line saying that the expand-audit branch draws its own buttons and that one element cannot change shape per panel; the long-form box names the ad's body copy by the ad it belongs to; the lightbox box says the picture is scrollable rather than repeating the caption the image inside already carries as its alt; the brief viewer names the raw pattern data its toggle revealed; the smoke row names its result by the label its own SmokeTest already holds. (3) ProductMemory.tsx:118 and sheet.tsx:44 keep their markup and each gain one source line saying the box has a floor and no ceiling, therefore does not scroll, therefore takes no tab stop, and that a ceiling added later puts it into the sweep below, where it fails until somebody classifies it. (4) apps/web/src/test/scroll-regions.test.ts, a source sweep over every .tsx under apps/web/src that is not a .test.tsx, in the shape of the colour sweep in pages/products/a11y.test.tsx:247-320 but walking the tree recursively rather than reading one folder: it finds every element whose class string carries a scroll utility together with a height ceiling, and holds the result equal to one reviewed inventory of 25 entries in which every entry says focusable region (the element carries a tab stop), is itself a control (a textarea, select or input, which focuses itself), or holds its own controls with the control it holds named; a scroll box added anywhere in the web tree fails this test until somebody classifies it, and the inventory is the document that says which of the 25 is which. (5) One test per converted site, in the file that site already owns, in the shape of Instructions.test.tsx:57-69: the box is found by its role and its name, carries tabindex="0", and takes focus. (6) docs/ops/v39-acceptance.md, carrying the before and after counts, the browser check below, and the horizontal family this card measured and did not fix.
Interfaces. Exposes ScrollRegion, which every later scrollable box in the web tree uses instead of a hand-written role and tabIndex, and the scroll inventory in scroll-regions.test.ts, which every later scrollable box joins. Consumes useRender and mergeProps from Base UI and the app's focus-ring tokens, which the tree already carries; nothing new.
Acceptance. npm test in apps/web: the eight new per-site tests, each failing on the base sources and the record saying so by name; the sweep test passing over the real tree with all 25 ceilinged boxes classified, failing when any one classification is removed, and failing on a scratch copy of the tree in which a min-h on ProductMemory.tsx:118 is changed to a max-h. npm run lint, tsc and npm run build clean. A browser check, run once and written into the record, in V34's own method: each of the eight boxes opened with content past its ceiling, the lightbox at a zoom step above fit, the brief viewer's raw toggle pressed, the smoke row run, axe run with the tags production-states.spec.ts:65 defines, and scrollable-region-focusable reported for all eight before the change and for none after; the same check on ProductMemory shows the box growing and never scrolling. e2e/production-states.spec.ts still passes its axe drive, and the record states plainly that the drive does not prove this card: the rule fires only on a box that actually scrolls, six of the eight open only after a click, and the one route the drive does walk with the box on it, flowkit-test, renders that box only after Run is pressed, which the drive never presses.
Out of scope. The horizontal-only family, measured here and not fixed: 19 elements carry a scroll utility with no height utility at all, and axe fires the same rule on a region that scrolls sideways, but whether each one actually scrolls depends on content width at a viewport, which no reading of the source can answer; the bare ones among them are components/ui/markdown-content.tsx:83 (the code block) and :108 (the table wrapper), the same pair inlined at pages/products/SessionView.tsx:2224 and :2242, and the table wrappers at components/ui/table.tsx:13, pages/scraped-ads/BrandsView.tsx:126, pages/scraped-ads/CollectionsView.tsx:76 and :113, pages/scraped-ads/RunsView.tsx:106, pages/scraped-ads/DiscoveryView.tsx:243 and pages/system/Operations.tsx:234. The two sound flex-1 overflow-auto mt-2 wrappers at SystemDocuments.tsx:370 and ProductDocuments.tsx:674. The fourteen ceilinged boxes that hold their own controls and the two Instructions textareas, which the inventory records and the card does not touch. Redesigning any panel, viewer, lightbox or smoke row, and changing what any of them shows. V35's palette and title= work and V36's tooltip work, both merged. Converting the four hand-written focus-ring spans V36 left behind (shell/ShellLayout.tsx:63-64, components/ui/lineage-chain.tsx:113, shell/pages/creatives/CreativePage.tsx:441, pages/products/ProductCreatives.tsx:265) to HintTarget. An eslint rule for this family: whether a scroll box holds a focusable descendant is a property of the rendered tree and not of the JSX a rule reads, 14 of the 25 ceilinged boxes are sound only because of children a rule cannot see, so a rule would report all fourteen and the tree would need fourteen suppressions; the sweep test with its reviewed inventory is what this family gets instead, and the reason belongs in the record.
Waits on / Unblocks. V42: both cards edit the same body block of apps/web/src/components/LongFormOutputPanel.tsx and its test file, and V42's deletions shift the lines this card's sweep lists, so one builder takes V42 first and then this card, as two pull requests; this card is cut from main after V42 merges and reads those lines again there. Nothing else: V34's shape is in the tree at Instructions.tsx:420-426, V35 (e0ee835) and V36 (67bf1a4) have both merged, and V35's own edits to DocEditHistory.tsx are already in main, so this card's edit to that file conflicts with nothing / ScrollRegion and the scroll inventory, which every page that adds a scrollable box after this card joins instead of writing the four attributes again.
Built as (PR #107 → main 16dd547, 2026-09-18). Code 1 review round → three must-fixes (a render element could override the region's role and tab stop; the Status page's sheet body scrolls with only text in it; two diff regions could share a name) and one should, folded in (the lightbox zoom stopped at the box width); re-check → two must-fixes (a custom render component could still override; a sheet could omit its body's classification); second re-check → APPROVE. components/ui/scroll-region.tsx: ScrollRegion in HintTarget's shape, rendering role="region" before a required name (exactly one of aria-label or aria-labelledby), tabindex="0" and the focus ring; render is a tag name, div or pre, and any other value at run time falls back to a safe div, so no caller can drop the role, the tab stop or the name. The eight boxes use it, named for what they hold: each edit's diff (Changes by <who>, <when>, entry N of M), both document viewers (<file> content, and the product page's editing textarea gained the same name), the session panel (named by its button's label from one PANEL_LABEL record, with the expand-audit exception in the source), the long-form body copy, the lightbox picture, the brief viewer's raw data and the smoke row's result. V34's box uses it too, its markup byte-identical to main. Two premises of the card did not hold. sheet.tsx's body does scroll, from the flex layout rather than a class (8054 of 720 px on the Status page), so every Sheet now states its body as { kind: 'region', label } or { kind: 'controls' }; the Status page's check history is a region and the lineage sheets declare controls. The lightbox picture was shrunk to the box by its flex row (1008 px at 400% for a 900 px picture); its width is now set in the scroll box's own container units, 1260 px at 400%, so above fit the box scrolls sideways as well. ProductMemory.tsx gained its comment. src/test/scroll-regions.test.ts parses every .tsx with the TypeScript compiler and holds the 25 ceilinged scroll boxes to one reviewed inventory (9 focusable regions, 2 controls, 14 that hold their own controls); the counts on d1fd3c9 matched the card (46 / 25 / 2 / 19). Each per-site test fails on the base; the sweep fails when a classification is removed and when a min-h becomes a max-h. Browser check in Chromium with the spec's axe tags: scrollable-region-focusable for all eight boxes on the base and none on the branch; the Status sheet was not flagged by axe on the base because the Observed time near its top is a tab stop, and the region adds a named target for the whole body. production-states 80/80. Whole web suite 120 files / 1057 tests (+26); lint, tsc -p tsconfig.app.json, build and eslint src clean. Record docs/ops/v39-acceptance.md; open item #84 closed. Found: the sweep cannot see ceilings set by an inline style or by the layout (open item #102). make dev failed on the old dev volume's password, so the browser check ran the same compose file under its own project name with a fresh env and volume, both removed afterwards.
V40 · A shipped prompt's path comes from the registry · V · S · S2-W0 · fix/v/v40-prompt-path-through-registry (surfaced by V38's review on 2026-09-16, open item #87; carded 2026-09-18)
Context. The Intelligence-Mode concept prompt ships as one file and four readers outside the registry name it. The registry names it once: apps/api/app/backend/services/instruction_registry.py:129 maps iv2-concept-generation to _INTELLIGENCE_PROMPTS_DIR / "concept_gen.md", the directory is built off the app package at :119-121, and shipped_path() (:164-166) is the accessor that resolves a call-site alias on the way in. Two callers build the same path from their own module location instead of asking: apps/api/app/backend/intelligence_v2/generation/concept_gen.py:18-20 and apps/api/app/backend/intelligence_v2/labs_shared/prompt_assembly.py:31-33 each hold a private _PROMPT_FALLBACK_PATH = Path(__file__).resolve().parent.parent / "prompts" / "concept_gen.md" and hand it to load_spec at concept_gen.py:41-42 and prompt_assembly.py:299-300. All three expressions resolve to the same file today and the file is pinned by hash, so nothing is wrong on a running stack. What is wrong is what the pin PROVES: apps/api/app/backend/tests/test_intelligence_instructions_shipped.py:88-101 asserts that the REGISTRY's path is the pinned file and says nothing about the two callers, so a move or a rename would be made in the registry, the pin would follow it, the whole suite would stay green, and both callers would fall through load_spec's swallowed read (apps/api/app/pipeline/utils/file_io.py:41-49) to the operator's missing-instruction sentence (instruction_registry.py:192-202) for every workspace that has never edited the prompt. Two more sites make the same point from the other side: apps/api/app/pipeline/steps/step2_concepts.py:97-101 and apps/api/app/pipeline/steps/step4_review.py:3,27-28 do read the registry, but through the raw INSTRUCTION_FALLBACK_PATHS map with .get(...), which answers None for a key nobody registered rather than failing, and step4_review.py translates the alias step4 into image-review by hand (the alias map is instruction_registry.py:21-26), so the row lookup and the file lookup are two independent spellings of one step that a change to the alias map would split in silence. The house shape already exists and V34 left it at apps/api/app/pipeline/steps/step1_analysis.py:17 and :52 and at step2_concepts.py:105: load_spec("<step>", fallback_path=shipped_path("<the same step>")). One reader sits outside the package: the dev harness apps/api/scripts/run_lab_concept_gen.py:170-171 passes pa._PROMPT_FALLBACK_PATH, a private attribute of another module, so removing the constant without touching the harness breaks it, and the harness is outside the sweep that would have said so.
Read first. apps/api/app/backend/services/instruction_registry.py:1-10 (the module's own statement of why one map exists), :21-26 (STEP_ALIASES, including step4 to image-review), :112-143 (the three shipped directories and INSTRUCTION_FALLBACK_PATHS, the iv2 entry at :129), :146-166 (resolve_step, is_registered, label_for, shipped_path), :192-202 (the sentence an operator reads when neither a row nor a file serves the step); apps/api/app/pipeline/utils/file_io.py:13-50 (load_spec: the registry check, the workspace row, the fallback read whose failures are swallowed at :41-49, and the refusal at :50); the four call sites as they stand, apps/api/app/backend/intelligence_v2/generation/concept_gen.py:1-22 and :40-43, apps/api/app/backend/intelligence_v2/labs_shared/prompt_assembly.py:1-33 and :296-302, apps/api/app/pipeline/steps/step2_concepts.py:1-4 and :94-105, apps/api/app/pipeline/steps/step4_review.py:1-28; the shape to copy, apps/api/app/pipeline/steps/step1_analysis.py:1-4, :17 and :50-53; apps/api/app/backend/services/db_service.py:11-14 (the registry names this module re-exports) with the comment at :2474-2479 that says why, and :2550-2561 (get_instruction, the one dynamic loader call and the only site the AST cannot check); the AST sweep and its three tests, apps/api/app/backend/tests/test_explore_instructions.py:527-610, its root at :529, _literal_loader_calls at :533-554 where passes_fallback is captured at :552 and the expression is thrown away, and the enumerating tests beside it at :482-525; the pin, apps/api/app/backend/tests/test_intelligence_instructions_shipped.py:1-36, :47-69 and :88-101; the harness, apps/api/scripts/run_lab_concept_gen.py:34-64 (the sys.path insert and the # noqa: E402 import block it forces), :73-96 (its disk-only load_spec, patched onto file_io.load_spec at :95 and pa.load_spec at :96) and :168-172; docs/ops/v38-acceptance.md:41-55 (what the pin was built to say, and what it was not); open item #87.
Deliverables. (1) concept_gen.py and prompt_assembly.py take the path from instruction_registry.shipped_path("iv2-concept-generation") at the load_spec call; both _PROMPT_FALLBACK_PATH constants go, and with each one the from pathlib import Path line the module no longer uses (concept_gen.py:7, prompt_assembly.py:11; neither module uses Path for anything else, and prompt_assembly's other occurrences of the word are prose about "Path A" and "Path B"). (2) The two half-corrected sites take the same shape: step2_concepts.py uses the shipped_path it already imports at :1 and drops the function-local INSTRUCTION_FALLBACK_PATHS import at :97, and step4_review.py passes shipped_path("step4"), so the alias is resolved once by the registry, the db_service import at :3 becomes from app.backend.services.instruction_registry import shipped_path as in step1_analysis.py:3, and the comment at :25-26 explaining the hand translation goes with it. After this, every load_spec call under apps/api/app that names a literal step passes shipped_path(<that same literal>), and the one dynamic call at db_service.py:2557-2558 already does. (3) The harness apps/api/scripts/run_lab_concept_gen.py:170-171 reads shipped_path("iv2-concept-generation") and no longer reaches into another module's private attribute, with the accessor imported in the file's post-sys.path block under the same # noqa: E402 as its neighbours; the call itself stays pa.load_spec, which is the binding _patch_load_spec_to_disk replaces at :96, so the disk-only patch still covers it. (4) The rule is enforced by the sweep, not by review: _literal_loader_calls also returns the fallback_path expression, and a new test refuses any literal load_spec call whose fallback_path is not shipped_path (in either spelling, bare or through the registry module) called with the SAME literal the loader was given, naming the file, the line, the step and what the call passes instead; test_the_sweep_finds_the_calls_it_is_meant_to_check (:557-563) is extended so that the expression is captured for every literal call it finds, and the new rule therefore cannot pass by matching nothing. (5) A negative proof that the rule catches what it is for: the checker is exercised over source text holding a hardcoded Path(...) fallback, a raw-map .get("…") fallback and a shipped_path call naming a DIFFERENT literal from the loader's, and refuses all three, so the sweep is proven on text rather than only on a tree that happens to be clean. (6) docs/ops/v40-acceptance.md: the call-site inventory before and after, the file each one resolves to in both states, and the sweep's output on the base sources and on the branch.
Interfaces. Exposes nothing new. Consumes instruction_registry.shipped_path, which becomes the only way a call site under apps/api/app names a shipped instruction file, and the rule that says so lives in the sweep. Unchanged: the registry's map and its entries, the step keys, the aliases, the labels, load_spec's signature and its refusal sentence, the prompt bytes and their pins, and db_service's re-exports.
Acceptance. pytest apps/api/app/backend/tests green on the branch, and the new sweep test FAILS on the base sources naming concept_gen.py:41, prompt_assembly.py:299, step2_concepts.py:98 and step4_review.py:27 by file and line, which is what says the rule reads the tree and not a list; the sweep finds seven literal calls on both the base and the branch, so the rule is proven to be checking the calls that exist rather than an empty set; the three fixture cases in deliverable 5 each refused, with the mismatched-literal case refused for a different stated reason from the hardcoded one; test_intelligence_instructions_shipped.py untouched and still green, which is what says the prompt bytes did not move; a test that the path each of the four call sites now hands load_spec is the same file the pin asserts, so the item's "the three agree" is proven by equality and not by inspection; rg "_PROMPT_FALLBACK_PATH" apps/api returns only storyboard_delivery.py's two ChatCut constants and their tests; rg "INSTRUCTION_FALLBACK_PATHS\.get" apps/api --glob '!**/tests/**' returns nothing, and the remaining non-test mentions of the map are its definition at instruction_registry.py:125, its use inside shipped_path at :166, the db_service re-export at :12 with its comment at :2478, and the docstring line storyboard_llm.py:18, none of them a call site; python -m py_compile apps/api/scripts/run_lab_concept_gen.py clean and the harness imports, so the private reach is gone and nothing replaced it; importing concept_gen and prompt_assembly from a bare interpreter succeeds. Record docs/ops/v40-acceptance.md; open item #87 closed.
Out of scope. The prompt bytes and the hashes pinned over them. The registry's map, keys, aliases and labels. load_spec's signature and the operator's sentence. apps/api/app/pipeline/storyboard_llm.py:68-77, whose INSTRUCTION_FILES names eight registered steps' shipped files a second time off its own ASSETS_DIR (:45), and :204-242, which is a second copy of load_spec's row-then-file logic and never calls load_spec at all: the same defect at eight times the size, in a module with its own loader contract, and its own card; its docstring at :18 names the map in prose and stays. apps/api/app/backend/intelligence_v2/services/_shared.py:19, apps/api/app/backend/services/storyboard_delivery.py:82-84,88-90 and apps/api/app/backend/services/creative_strategist.py:42-44, which name files that are not registered steps or whose disk fallback the registry deliberately does not carry (instruction_registry.py:65-77). apps/api/scripts/render_comparison_concepts.py:30-32, a lineage table that records the fallback in prose and reads nothing. Widening the sweep's root past apps/api/app. The harness's stale user_id parameter at run_lab_concept_gen.py:85, and its patch covering file_io.load_spec and pa.load_spec but not concept_gen's own module-level binding from concept_gen.py:14, which the harness does not reach.
Waits on / Unblocks. Nothing: the registry, the accessor and the sweep all exist on main and this needs no new mechanism to prove / any future move or rename of a shipped prompt, which afterwards is one line in the registry instead of four lines in four modules, and every later card that registers a step or adds a loader call, which the sweep now holds to one shape.
Built as (PR #100 → main 0a69129, 2026-09-18). Code 1 review round → one must-fix (the sweep identified a loader call only by the name load_spec, so an aliased import escaped both the count and the rule), then a re-check → APPROVE. The four call sites pass shipped_path(<the same step>), the shape step1_analysis.py already used: concept_gen.py and prompt_assembly.py lose their private _PROMPT_FALLBACK_PATH and its unused import, step2_concepts.py stops reading the raw map with .get(), and step4_review.py passes shipped_path("step4") so the registry resolves the alias once instead of a hand translation. The dev harness takes shipped_path instead of another module's private attribute and still calls pa.load_spec, so its disk-only patch still covers the call. The AST sweep identifies a loader call by what its callee resolves to through the module's imports and a tree-wide index of re-exports, so an aliased import, pa.load_spec and a keyword step= are all caught (the last was a second gap found while fixing the first), and it refuses any fallback that is not the registry's shipped_path for the loader's own step, with its own sentence for a mismatched step. A negative proof on source text, a positive control, and a move test beyond the card's ask, which points a registry entry at a temporary path and checks each call site follows it at run time. On the base sources the rule named the four call sites; the sweep finds the same seven literal calls on base and branch, so it checks the calls that exist. The review ruled that refusing shipped_path through db_service's re-export is correctly strict. Whole api tier 4355 passed / 11 skipped / 3 deselected on the built tree, and the PR's api job was the tier on the actual base after it was rebased onto A4c's merge. Record docs/ops/v40-acceptance.md; open item #87 closed. The same defect at eight times the size, the storyboard module's own copy of the registry and loader, is open item #95.
V41 · The login page's countdown tests wait for the state they read · V · XS · S2-W0 · fix/v/v41-login-countdown-gate (surfaced by V36's whole-suite run on 2026-09-16, open item #90; carded 2026-09-18)
Context. Three tests in apps/web/src/shell/auth/LoginPage.test.tsx advance a fake clock past a 60 s hold and read the DOM in the very next statement, and nothing waits for the state they assert: :159-162 (the password form's throttle: 30,000 ms, then 31,000 ms, then Sign in must be enabled), :235-236 (the link form's throttle: 61,000 ms, then Send link must be enabled) and :299-301 (send again: 60,500 ms, then enabled). Each starts the clock with vi.useFakeTimers({ shouldAdvanceTime: true }) and advances it with a bare await vi.advanceTimersByTimeAsync(...). The last one failed once. docs/ops/v36-acceptance.md:282-286 records one failure "in the send-again countdown" in V36's first whole-suite run, on a box where nothing else was running, then 21 of 21 for the file alone and a clean second whole run. The record names no assertion and no message; open item #90 is the only source for the button reading disabled at :301. V36's branch sat on main at 2d97b9f (v36-acceptance.md:3), which is V37's merge, so V37's budgets were already in force (testTimeout: 15_000 at apps/web/vitest.config.ts:41, asyncUtilTimeout: 4_000 at src/test/setup.ts:21), and the three tests take about 1 s on an idle box. V37's 26 whole-suite runs on a tree that held this same file never showed the failure (docs/ops/v37-acceptance.md:30-65), and six runs of the file alone beside six busy processes, at a load average of 10.23, passed 21 of 21 each time. The page is not the cause. useCountdown (apps/web/src/shell/auth/LoginPage.tsx:71-101) sets until in the handler (:95-99) and installs its one-second interval in an effect one commit later (:84-92), so the tick that reaches until always lands at or after it, and the seconds left are derived at render (:94). The test is where the order is left to chance. The suite runs without vitest globals (vitest.config.ts:15-42), so Testing Library never registers the global beforeAll hook that turns React's act environment on (@testing-library/react 16.3.3, dist/index.js:41-51). An update from the fake interval is therefore outside act, React prints no warning for it, and it renders later through React's own scheduler, on the real setImmediate that the scheduler captured when it loaded. advanceTimersByTimeAsync (the fake timers bundled in vitest 4.1.11, tickAsync) fires each due timer, waits one real setTimeout, and resolves in that same real turn once no timer is left. Two more things move under it. With shouldAdvanceTime the auto-advance runs in its interval mode, which pauseAutoTickUntilFinished does not pause, so a real 20 ms interval keeps moving the fake clock during the advance. And performance is faked on its prototype, so React's scheduler reads the fake clock. Read from that code, the render that the last tick asks for normally runs before the advance resolves. A stale read needs React to hold that render behind other queued work (the scheduler yields after any commit and after 5 ms of work, shouldYieldToHost in scheduler 0.27.0), and this page's per-tick commits leave no such work, because every effect on the sent page keeps its dependencies from tick to tick. So the one failure is not explained, and this card does not claim to explain it. What is certain is the hazard: each read is right only if an ordering between two libraries holds, and nothing in the test states or waits for that ordering. The rest of the suite already reads through a gate: 43 advance calls live in 13 files under apps/web/src, and every one outside this file that then reads the DOM runs inside act(...) (shell/pages/status/StatusPage.test.tsx:164 and :292-302, components/ui/copy-button.test.tsx:40-43, components/ui/relative-time.test.tsx:47-50, pages/products/ProductCreatives.test.tsx:115-122, and the scraped-ads and storyboard lists), while the rest assert on a mock. A findBy query is not a gate here. ByRoleOptions has no disabled filter, and the button exists, disabled, before and after the hold, so findByRole resolves at once on the stale page. A fourth read is weak for a different reason: :303-305 waits for magicLink to be called twice and then reads the button as disabled, which the in-flight send also makes true (LoginPage.tsx:219 disables on busy || resendIn > 0), so whether it catches a missing second hold depends on which render has landed.
Read first. apps/web/src/shell/auth/LoginPage.test.tsx:57-68 (the reset and the vi.useRealTimers() teardown), :140-163, :224-237 and :256-306 (the three fake-timer tests and the sendALink helper at :257-263); apps/web/src/shell/auth/LoginPage.tsx:34-38 (the two 60 s holds), :71-101 (useCountdown, and its note on why the seconds are derived and not stored), :179-193 (sendLink, which starts the hold after the send returns) and :213-225 (send again, its disabled binding and the "(in N s)" hint at :224); the shapes to copy, apps/web/src/shell/pages/status/StatusPage.test.tsx:286-305 (an async advance inside act under shouldAdvanceTime) and apps/web/src/components/ui/copy-button.test.tsx:29-44 (a waitFor on the asserted state under the same clock); apps/web/vitest.config.ts:15-42 and apps/web/src/test/setup.ts:1-25; docs/ops/v36-acceptance.md:3 and :282-286; docs/ops/v37-acceptance.md:15-65 (the box, the neighbour recipe and what each load shape reproduced); in the installed packages, Testing Library's act-environment hook (@testing-library/react/dist/index.js:41-51), tickAsync and pauseAutoTickUntilFinished in the fake timers vitest 4.1.11 bundles, and shouldYieldToHost in scheduler 0.27.0; open item #90.
Deliverables. (1) Each advance in the three tests runs inside act, in the shape of StatusPage.test.tsx:292 (await act(() => vi.advanceTimersByTimeAsync(...))), the two advances at :159 and :161 included. (2) Each of the three enabled checks waits for the state it asserts through waitFor, in the shape of copy-button.test.tsx:39 and this file's own :304, so the read no longer depends on which library runs first. After this, no read in the file follows a bare advance. (3) The second half of the send-again test proves the second hold. It waits for the "(in N s)" hint (LoginPage.tsx:224) to come back, which renders only while resendIn > 0 and never for a send that is only in flight, and then reads the button as disabled. (4) A bite proof, run on scratch copies, recorded and never committed. With each advance replaced by the synchronous vi.advanceTimersByTime(...), which fires every tick before React renders any of them, the three reads as they stand on main fail on every run, and the reads from deliverables (1) and (2) pass on every run. With the page changed so that only the first send starts a hold, the check from deliverable (3) fails on every run. (5) docs/ops/v41-acceptance.md: each site before and after, the bite proof, the load runs, and a plain statement that V36's one failure was not reproduced and its cause is not established.
Acceptance. cd apps/web && npx vitest run src/shell/auth/LoginPage.test.tsx green at 21 tests. The bite proof in the record, as deliverable (4) states it. The whole web suite green once idle, and in 3 runs of 3 beside the six-process neighbour of docs/ops/v37-acceptance.md:21-28 (V37's final-proof shape), with each run's file and test counts, load average and wall time recorded and not pinned. grep -n "advanceTimersByTime" apps/web/src/shell/auth/LoginPage.test.tsx shows every advance inside act(. npm run lint, npm run typecheck and npm run build clean. testTimeout at vitest.config.ts:41 and asyncUtilTimeout at src/test/setup.ts:21 unchanged, and no test in the file carries a timeout argument of its own. Record docs/ops/v41-acceptance.md; open item #90 closed.
Out of scope. LoginPage.tsx: the page leaves no second on the clock, both holds stay 60 s, and its bindings and copy are untouched. Raising either budget. A further attempt to reproduce V36's failure: V37's 26 whole-suite runs and six file-alone runs beside six busy processes never showed it, so another set that shows nothing proves nothing. The other twelve fake-timer files, which already read through act. Turning React's act environment on for the whole suite in src/test/setup.ts, which would make React warn on every update outside act in every file: how many warnings it raises is not measured, and it is its own item. A lint rule for this shape. e2e/ and the api tiers.
Waits on / Unblocks. Nothing: the tests are on main, no other open card edits them, and this card gates no other card / a web suite whose red means a defect (open item #66(b)), with the last bare advance-then-read gone from it.
Built as (PR #104 → main 28e76e6, 2026-09-18). Code 1 review round → one must-fix (a card key in the file's opening comment), then a re-check → APPROVE. The four advances in the three countdown tests run inside act in the shape await act(() => vi.advanceTimersByTimeAsync(...)), and each enabled check waits for its state through waitFor, so no read in the file follows a bare advance. The send-again test proves the second hold in order: the "(in N s)" hint is on the page after the first send, gone once the hold ends, back after the second send (matched as /^\(in \d+ s\)$/ after Testing Library's normaliser trims the leading space), and only then is the button read as disabled. LoginPage.tsx, both budgets and every other file are untouched, and no test carries a timeout argument. Bite proof on scratch copies, 10 runs per copy with the same result every run: with synchronous advances, the three reads as they stand on main fail and the gated reads pass; with only the advance at :161 synchronous, :162 fails alone; against a page where only the first send starts a hold, the new check fails, and so did main's weaker check on this box. The file: 21 passed. The whole web suite 118 files / 1031 tests idle in 279 s, and 3 of 3 beside the six-process neighbour at load 8.4 to 13.4 in 566, 513 and 507 s. npm run lint, tsc -p tsconfig.app.json --noEmit and npm run build clean; the npx tsc --noEmit this card names checks no file, because apps/web/tsconfig.json is a solution file (open item #100). V36's one failure was not reproduced and its cause is not established. Record docs/ops/v41-acceptance.md; open item #90 closed. Found: 24 source files still carry a card key in a comment, LoginPage.tsx:16 among them (open item #99).
V42 · One copy of each control, and no branch nothing can reach · V · S · S2-W0 · chore/v/v42-one-copy-of-each-control (surfaced by V35, open item #91; carded 2026-09-18)
Context. Two defects under apps/web/src/components/. V35 (PR #91, main e0ee835) converted both files to the design system and left both defects in place. Both are re-measured at ba66058. (a) video-storyboard/CharacterPanel.tsx:940 computes const editUnbuilt = !exists && !skipped inside CharacterEditor (:758), where exists is isBuilt(character) (:911, the helper at :73-77) and skipped is character.status === 'skipped' (:908). It does two things. It sets the Edit button's disabled at :943, and at :1088-1092 it chooses between the bare button and the button wrapped in a Tooltip on a HintTarget that carries the build-first sentence. No render of CharacterEditor can be in that state. The component is private to the module and is rendered from three places, each behind a guard: :2096-2099 returns the unbuilt placeholder unless the character is built or skipped, before the solo editor at :2100-2113; :2120-2124 does the same before the primary editor at :2125-2132; :2156-2158 returns the placeholder unless isBuilt(shown.character), before the member editor at :2160-2183. So exists || skipped holds wherever the editor renders, and editUnbuilt is always false. The disabled at :943 never fires, the hint at :1089-1091 never mounts, and HintTarget (imported at :12) has no other use in the file. The item says four call sites. There are three. The "four places" in the withLightbox comment at :1826-1829 counts return shapes, and it is out of date too: the wrapper now returns from seven places (three placeholders, three editors, and the loading card at :2145). The dead state is older than V35. Before e0ee835 it was inline as disabled={!exists && !skipped} with a title=, and V35 named it and moved its sentence onto a HintTarget. The live version of the control is UnbuiltCharacter's at :713-723: the same Edit character label over a ChevronDown, disabled, with the same sentence on ControlHint, which components/ui/hint-target.tsx:18-21 names as the shape for a control the page switches off. The suite covers the live control and cannot tell the two apart. CharacterPanel.test.tsx:167-178 asserts a disabled Edit button inside a tab stop that carries the sentence, which the dead branch would also satisfy. Both render helpers (:60-76) pass a cast with roster: [] (:17-20), so only the solo route is ever rendered, and the primary and member guards have no test at all. Inside the editor, !skipped && exists at :1098 and :1110 reduces to !skipped under the same guards. Those two conditions are correct and stay. (b) LongFormOutputPanel.tsx builds its copy control by hand twice, at :155-166 for the headline and :174-187 for the body: a Tooltip around a bare <button> (:158-164, :179-185) with an aria-label, a lucide Copy/Check swap, and one panel-wide copied key in state (:67) that copyText (:84-92) sets on a 1,500 ms timer. components/ui/copy-button.tsx:16-48 is the shared control. It does the same job on the design system's Button variant="ghost" size="icon-xs", with the same 1,500 ms revert (:34), and it adds a polite live region that says "Copied" (:43-45), which the panel has never had. The panel does one thing the shared control does not. copyText writes withRtlMarkIfNeeded(text) (:86, the helper at :37-41), which puts U+200F in front of a text that holds more right-to-left letters than left-to-right ones, so a Hebrew ad pastes right to left. CopyButton writes its value as given (:28). No test covers the mark today, so a plain swap would drop it and the suite would stay green. One other card edits this block: V39 (open item #84) converts the body's scroll box at :188, directly under the body's copy button, and adds a test to LongFormOutputPanel.test.tsx.
Read first. apps/web/src/components/video-storyboard/CharacterPanel.tsx:73-77 (isBuilt), :662-731 (UnbuiltCharacter, with the live disabled control at :706-724), :736-756 (CharacterEditorProps), :900-949 (skipped, exists, the comment at :934-939 and the button at :941-949), :1063-1120 (the control strip, its note that edit is gated on existing, the branch at :1088-1092, and the two !skipped && exists conditions), :1670-1760 (the slug state, roster, isPrimary and loadMember, which decide the three routes), :1826-1829 (the withLightbox comment), :2059-2085 (unbuiltView) and :2087-2184 (the three guards and the three renders); apps/web/src/components/ui/hint-target.tsx:6-21 and components/ui/control-hint.tsx; apps/web/src/components/video-storyboard/CharacterPanel.test.tsx:11-20 (the unbuilt fixture and the cast with no roster), :33-43, :60-76 and :167-193; apps/web/src/components/LongFormOutputPanel.tsx:1-6, :17-41, :60-92 and :151-190; apps/web/src/components/ui/copy-button.tsx and copy-button.test.tsx:1-45 (the clipboard stub at :10); apps/web/src/components/creative-code/CodeChips.tsx:28-44 (the shared button in an inline flex row); apps/web/src/components/LongFormOutputPanel.test.tsx; apps/web/eslint.config.js:30-45; open item #91.
Deliverables. (1) CharacterEditor no longer carries a state it cannot be in. editUnbuilt and its comment at :934-939 go. The Edit button renders once at :1088, with no disabled and no hint wrapper. The skipped label at :945 stays, because a skipped primary reaches it. (2) HintTarget's import at :12 goes with the branch. ControlHint and its four uses at :717, :1386, :1474 and :1602 stay. (3) The rule that makes the branch impossible is written where a reader meets it, in the control-strip comment at :1063-1073: this editor renders only for a character that is built or skipped, and the three guards in the wrapper hold it. The withLightbox comment at :1826-1829 states the returns as they are. (4) Tests that pin the rule on every route, in CharacterPanel.test.tsx: the solo route (no roster), the primary route (a roster of two, the primary selected) and the member route (a roster of two, the member's chip pressed, videoStoryboardApi.getCharacter stubbed). On each route an unbuilt character renders the placeholder, known by its "Not built yet" sentence (:695, :698), and no editor, and a built one renders the editor with a live Edit control. A later change that sends an unbuilt character into CharacterEditor on any route fails one of them. (5) LongFormOutputPanel renders CopyButton for the headline and the body with label headline and body, so the names stay "Copy headline" and "Copy body", and with value={withRtlMarkIfNeeded(c.headline || '')} and value={withRtlMarkIfNeeded(c.body_markdown || '')}, so the clipboard gets exactly what copyText wrote. (6) copied (:67), copyText (:84-92) and the imports that fall with them, Copy and Check at :2 and Tooltip at :4, go. withRtlMarkIfNeeded, its two character classes and the note at :17-32 stay. (7) A test on the payload, with the clipboard stubbed as copy-button.test.tsx stubs it: a mostly Hebrew body reaches the clipboard with U+200F in front, and an English body reaches it unchanged. (8) docs/ops/v42-acceptance.md: the reachability argument with the three guards quoted; the copy control before and after, including the payload, the live region, and the check state that is now per button where it was one key for the whole panel; and the bite proofs below.
Interfaces. Exposes nothing new. Consumes CopyButton as the one copy control in the web tree, which takes its call sites from four (CodeChips.tsx:36, DevGallery.tsx:218, CreativePage.tsx:205 and :375) to six.
Acceptance. cd apps/web && npm test green. The route tests and the payload test pin behaviour that main already has, so they also pass on the base sources. Their bite is shown on scratch copies, and the record states each result: with any one of the three guards (:2096, :2120, :2156) removed, the unbuilt case for that route fails; with value={c.body_markdown || ''} in place of the marked value, the payload test fails. The two existing tests in LongFormOutputPanel.test.tsx pass unchanged: the names, the tooltip text, the descriptions on focus, the tab from the headline's control to the body's, and nestedControls(container) empty. CharacterPanel.test.tsx:167-178 still passes, on the placeholder's control. rg "editUnbuilt|HintTarget" apps/web/src/components/video-storyboard/CharacterPanel.tsx returns nothing. rg "copyText|navigator\.clipboard|Tooltip" apps/web/src/components/LongFormOutputPanel.tsx returns nothing, and so does rg "import \{[^}]*\b(Copy|Check)\b[^}]*\} from 'lucide-react'" apps/web/src/components/LongFormOutputPanel.tsx. npx eslint src reports nothing, and no line suppresses a design-system rule. npm run lint, npm run typecheck and npm run build clean. The whole web suite green, with its file and test counts recorded. Record docs/ops/v42-acceptance.md; open item #91 closed.
Out of scope. The placeholder's own control at CharacterPanel.tsx:713-723, which is the live one and stays word for word. The three guards, and which view an unbuilt character gets. The two !skipped && exists conditions at :1098 and :1110, which are correct as written. The rest of CharacterEditor, including the hints at :1386, :1474 and :1602. The approval, download and image-prompt controls in LongFormOutputPanel, and the body's scroll box at :188, which is V39's. The RTL rule itself. Any other hand-built copy of a shared control elsewhere under apps/web.
Waits on / Unblocks. Nothing: V35 is merged, and no open card is ahead of this one in either file / V39, which edits the same Body block of LongFormOutputPanel.tsx and its test file, and whose scroll-box sweep lists two boxes in CharacterPanel.tsx (:592, :1569) below the lines this card deletes. V39 is therefore cut from main after this card merges.
Built as (PR #101 → main 5696d6d, 2026-09-18). Code 1 review round → one must-fix (an item number left in a source comment), then a re-check → APPROVE; the review ruled the per-button check state an acceptable consequence of using the shared control. CharacterEditor loses editUnbuilt, its comment and the HintTarget import: the Edit button is built once and placed once in the control strip, with no disabled and no hint wrapper, and the Edit label for a skipped character stays. The control-strip comment now says the gate is not in the editor: the editor renders only for a character that is built or skipped, the three guards in the wrapper hold that, and an unbuilt character gets UnbuiltCharacter, whose strip carries the switched-off Edit and its reason. The withLightbox comment names the kinds of view it returns instead of a count; the wrapper returns from seven places (three placeholders, three editors and the member's loading card). ControlHint and its uses, the placeholder's own control and the two !skipped && exists conditions are unchanged. LongFormOutputPanel renders CopyButton for the headline and the body, named "Copy headline" and "Copy body", with the right-to-left mark applied to the value at the call site, so the clipboard gets byte for byte what copyText wrote; copied, copyText, the Copy and Check icons and the Tooltip import are gone, and CopyButton has six call sites, up from four. Each button now holds its own check state on its own timer, where one panel-wide key let a copy in one place clear the tick in another, and each announces "Copied" in a polite live region, which the panel never had. Six route tests cover the solo, primary and member routes, an unbuilt and a built character on each; they know the placeholder by its "Not built yet" sentence and the editor by the sentence under its identity lock, and the member route presses the member's chip with videoStoryboardApi.getCharacter stubbed. Two copy tests pin the payload and the announcement. The announcement fails on the base sources. The behaviour main already had is proven by scratch mutations: each of the three guards removed fails its own route's unbuilt case and no other route test, and either value without the mark fails the payload test. The card's three rg checks return nothing on the branch and 5, 10 and 1 lines on main. Whole web suite 118 files / 1031 tests (+8); lint, eslint src (308 files, 0 reports), tsc and build clean. Record docs/ops/v42-acceptance.md; open item #91 closed. Found and filed as open item #96: the cast chip and the build-everyone count decide "not built" from the identity lock alone, against isBuilt's own rule.
S36 · A character build claims before it spends · S · M · S2-W0 · fix/s/s36-claim-before-spend (discovered by S32's build on 2026-09-16, pre-existing on main, open item #88; carded 2026-09-18)
Context. Two presses of Build on the same character both pay for the design text call. apps/api/app/backend/services/storyboard_image_runner.py:1314 asks _refuse_if_sheet_in_flight (defined :1608-1623) before the paid images.design_character at :1321-1327, while the real claim, the pending version row written by svc.create_character_version(..., status="pending"), does not exist until :1733-1742 inside _render_character_sheet. The guard is a read of storyboard_service.pending_character_version_count (:3277-3293: a pending row for that slug younger than PENDING_CLAIM_SECONDS = 300 at :2736), so two requests that arrive together both read zero, both reach the text call, both record its spend at :1328 under stage character (storyboard_service.record_spend:3931-3954), and only the second is refused at :1677 on its way into the render. The comment at :1309-1313 calls the first check the cheap early exit and the second the real claim, and the window between them holds one paid call to production.storyboard.llm (app/pipeline/storyboard_images.py:436, through _character_model_call:123-155). Nothing serialises the two presses: POST /sessions/{id}/character?force=true (app/backend/routes/video_storyboard.py:941-962) is a sync def, so FastAPI runs it on the request threadpool and two presses are genuinely concurrent, it calls ensure_character inline, and it takes no job slot (the only slots in that file are the export lease at :2024 and the two image jobs at :2806 and :2850). The claim's own read then insert is not atomic either, so two presses can also pass :1677 and both INSERT at :1733: no unique index exists on storyboard_character_versions, whose only index is (session_id, id DESC) (db/migrations/unified/production/001_baseline.sql:2239-2292). S32 made every exit between the claim and the settle release the row (:1744-1850, _release_sheet_claim:1626-1652) and deliberately left the claim where it was. The refine path is the same defect one step worse, with no early guard at all: refine_character:1979 reaches _instruct_fields:1900, which pays images.instruct_character at :1936 and records the spend at :1938 under stage character_instruct, and meets its first and only guard at :1677. So two simultaneous refinements also both write the character's fields through update_character_text (:1951-1953) and the second overwrites the first. Redraw (:2073-2123) and the supplied-sheet build paths buy no text call ahead of the render.
Read first. apps/api/app/backend/services/storyboard_image_runner.py:1285-1335 (_invent_character: the early guard, the style read, the text call, the hand-off), :1281, :1412-1413 and :1516-1519 (the three callers that reach it: a cast member, the forced build, the first unforced invent of an invent session), :693-727 (_upload_root, the other version row this module writes, created ready with its key), :1574-1605 (_current_version and its lazy root write), :1608-1623 (the guard and the sentence it raises, images.StoryboardImageError, which _image_guard answers 400), :1626-1652 (_release_sheet_claim), :1655-1742 (_render_character_sheet down to the row it writes), :1744-1850 (S32's window and every settle), :1871-1897 (_no_render_needed), :1900-1965 (_instruct_fields, its paid call and the field write) and :1968-1976 (_is_uploaded_sheet), :1979-2070 (refine_character: the two cheap refusals :2003-2012, the lazy root write :2014, the instruction :2019, the no-render exit :2022-2027, the tweak's reference fetch :2040-2042, the two renders :2043 and :2063), :2073-2123 (redraw, the caller that keeps claiming inside the render path); apps/api/app/backend/services/storyboard_service.py:2736 (PENDING_CLAIM_SECONDS), :3139-3191 (create_character_version and what a row is for), :3193-3221 (set_character_version_status, release_character_claim and why it is conditional), :3224-3247 (the two setters that fill a row after the fact), :3277-3293 (the guard's query), :3395-3397 (starting_points, which lists every parentless row whatever its status, so a claim is already visible in the picker while it is pending), :3735-3775 (adopt_saved_character's claim-then-copy, the one path that already claims before it acts), :3931-3954 (record_spend), and :27-40 with :232 (the module imports no image layer and owns StoryboardError, which is what decides where the refusal is raised); apps/api/app/backend/services/pipeline_runner.py:32-41 and :895-917 (the house shape for this: a namespace constant of its own, an advisory transaction lock, and the read and the write inside one transaction with a typed refusal); apps/api/app/backend/db.py:70-93 (get_conn and get_cursor: one connection committing on exit, which is what makes a transaction-scoped lock right here); apps/api/app/pipeline/storyboard_images.py:123-155 (_character_model_call, which lets the gateway refusals through as themselves), :363-455 (design_character), :526-599 (instruct_character); apps/api/app/backend/routes/video_storyboard.py:910-938 (_image_guard and its 502), :941-962, :1280-1292, :1322-1335 (the three routes); apps/api/app/backend/tests/test_character_claim_release.py:94-236 (the fake service the claim tests drive, which patches create_character_version and pending_character_version_count BY NAME), :237-260 (the four entry helpers) and :567-618 (how the real statement is pinned through a captured cursor); apps/api/app/backend/tests/core/test_background_queue_db.py:1-20 (the imports, threading included), :177-200 (a claim proven against the real database with a second thread) and :204-213 (_storyboard_session, the product and session rows a character claim needs); apps/api/app/backend/tests/core/conftest.py:100-141 (core_pg, the migrated container) and :158-186 (auth_env, which points app.backend.db at it); db/migrations/unified/production/001_baseline.sql:2239-2292 and :2562 (the character_slug column the guard scopes on); open item #88; S32's record.
Deliverables. (1) One atomic claim in storyboard_service: a function that in a single transaction takes pg_advisory_xact_lock on a namespace constant of its own and the session id, re-reads the pending count for that slug, and either inserts the pending row and returns its id or returns nothing. It BLOCKS rather than tries, because the lock is held only for the read and the insert and a waiter must be answered by the count rather than by the lock. It takes the same fields create_character_version takes, so a caller already holding them writes them at the claim and only a caller that does not fills them afterwards. It raises no image-layer error: that module imports none, and the operator's sentence stays where it is raised today. The lock is per session and the re-read is per slug, so two cast members still build at once while two presses at one member cannot both claim. No migration, no new column and no unique index. (2) _invent_character claims BEFORE svc.resolve_style and images.design_character and hands the claim to _render_character_sheet, so the second press is refused with the sentence and the type it is refused with today, raised in the runner through the existing guard helper, and never reaches the text call. (3) _render_character_sheet takes an optional claim. Handed one, it does NOT ask the guard at :1677 again, because the caller's own pending row would otherwise refuse the caller; handed none, it claims through the same function at the point where it creates a row today (:1733-1742), which is where that guard call goes. So the claim is the only place the question is asked, on either path. The row carries the sheet prompt, the identity lock, the identity and the wardrobe the page is about to be drawn with, written at the claim for a caller that has them and filled by one statement that touches the row only while it is still pending for a caller that does not, so redraw, tweak and rework are unchanged in what they write and when. (4) The release window starts at the claim, not at the render: a failure of the style read, the text call, the instruction, the reference fetch or the fill hands the claim back before the exception propagates, through S32's helper. A claim released before any render is REMOVED, conditionally in SQL on that id, the row still being pending and its r2_key still NULL, because the row describes nobody, holding no prompt, no identity and no picture, and starting_points would stand it in the picker as a sheet; a claim released from inside S32's existing window (:1753-1850) keeps S32's behaviour exactly, failed with the failure's own sentence, because that row does describe the person a render was attempted for. (5) refine_character claims after its two cheap refusals (:2003-2012) and after _current_version (:2014), and before _instruct_fields, and hands the claim to both renders; every exit that draws nothing releases it: the short circuit at :2022-2027, any raise from the instruction, and a failure of the tweak's reference fetch at :2040-2042. So a refinement that only moved the fields leaves no attempt behind. (6) _refuse_if_sheet_in_flight's docstring at :1615-1618 says where the question is asked now, which is once per path, at the claim. (7) Tests: a database-backed concurrency proof and unit cases over the fake service for every ordering above. (8) docs/ops/s36-acceptance.md with the measured before and after: what a second simultaneous press pays on main and what it pays on the branch, for build and for refine.
Interfaces. Exposes one claim function that every character path takes before it spends, and the rule that a claim is either filled or released and is never left blank. Consumes the existing guard and its sentence, PENDING_CLAIM_SECONDS, S32's release helper, and the advisory-lock pattern pipeline_runner already uses. No route, payload, schema or operator-facing sentence changes.
Acceptance. pytest against the real database, where core_pg and auth_env live: two threads pressing the build for one session and one slug leave exactly one pending row and one refusal carrying the guard's sentence, and the design text call is entered once, counted through a fake that increments under a lock; the same two threads on two different slugs both claim and both design; the same two threads through refine_character enter the instruction once and write the fields once. Each of those three cases fails on the base sources, the first and the third with two paid calls and the first with two rows. A text call that raises leaves pending_character_version_count at zero and no row at all for that attempt, and an immediate second press is answered on its merits; a gateway refusal raised from the text call keeps its own type and status through _image_guard, and a CharacterModelError still answers 502 with the provider's sentence. Over the fake service, whose patch map gains the claim function: a second build press never reaches images.design_character; a refine press that short circuits at _no_render_needed leaves no version row and no pending count; a refine whose instruction raises leaves neither; a tweak whose reference fetch raises leaves neither; redraw writes exactly what it writes today, one row with its prompt, its lock and its parent. Every case in test_character_claim_release.py still holds with its assertions unchanged, the settled row that is never rewritten included. The whole api tier green. Record docs/ops/s36-acceptance.md; open item #88 closed.
Out of scope. The 300-second claim window and the guard's sentence, both unchanged. The scene-frame path, which claims before it spends already. adopt_saved_character's claim-then-copy (storyboard_service.py:3735-3775), which claims before it acts and buys nothing. That a pending claim stands in the picker as a sheet while it is being drawn, which is today's behaviour and only lengthens by one text call. The supplied-sheet vision read (:850-872, :1206-1232): the reading is stored on the pool entry so a later build reuses it, but two simultaneous presses of the free ensure fan-out on a sheet nobody has read yet do both still pay for it. That is a cheaper call on a different path, and claiming there would put a lock on the one press every session makes; it is stated here rather than folded in and belongs in its own item. The route error contract, which is S33 and merged. Any web change, including whether the panel disables Build while one is in flight. Any schema change, any unique index, and putting the character build behind a job slot.
Waits on / Unblocks. Nothing: S32 and S33, whose release window and error boundary this card extends, have both merged / a cast pressed by several people at once, and any later work that puts another paid call on the character path.
Built as (PR #103 → main d1fd3c9, 2026-09-18). Code 1 review round → two must-fixes (a fill that missed still paid for the render; the claim windows and get_conn did not release on a BaseException) and one should, ruled no change (claim parity with every field of create_character_version: a claim is pending with no page and no outcome by definition, so it takes no status, no r2_key and no provider message, and it takes every descriptive field a caller holds); re-check → APPROVE. claim_character_version in storyboard_service takes pg_advisory_xact_lock on its own namespace constant and the session id, re-reads the per-slug pending count and inserts the pending row or refuses, in one transaction; it blocks rather than tries. fill_character_claim fills a claim only while it is still pending, and withdraw_character_claim removes one handed back before any render, only while it is pending with no r2_key. _refuse_if_sheet_in_flight keeps its name and now takes the claim and returns its id, so the question is asked once per path. Build claims before the style read and the design text call; tweak and rework claim after their two free refusals and _current_version, before the instruction; redraw claims at the render where the row was written before, and writes what it wrote. Every exit that draws nothing removes the claim: the style read, the text call, the instruction or the trait floor, the tweak's reference fetch, the read or the fill, and the refinement that moved only the fields. A fill that matches no row, because the row was settled or removed after the claim, stops before the render with the in-flight sentence and type and leaves that row as it stands. Inside the render window S32's failed settle is unchanged. Every window releases on any BaseException and re-raises the same object, and get_conn rolls back on any BaseException, so the helper ends its own transaction before the connection goes back to the pool (psycopg2's own hand-back rollback was the only thing that had closed it). Measured, two simultaneous presses at one character: on main 2 design calls, 2 renders, 2 rows and 2 spend rows with no refusal; on the branch 1 of each and 1 refusal with the guard's sentence; the same for rework with the instruction call and the field write. Tests: 69 cases over the fake service, 5 against Postgres with real threads (two builds at one character, two builds at two characters, two refinements, and through the route a paused guard answering 402 and a failed call answering 502 with the provider's sentence, each leaving no row and no claim) and 1 for get_conn; every one fails on the base sources, and five existing fakes gained the new statements with no assertion changed. Whole api tier 4441 passed / 11 skipped / 3 failed, the 3 being exactly ci-known-failures.txt; the PR's api job was the tier on the actual base after a rebase onto V42's merge. Record docs/ops/s36-acceptance.md; open item #88 closed.
V43 · One command type-checks the whole web app · V · S · S2-W0 · chore/v/v43-web-typecheck (surfaced while building V41 on 2026-09-18, pre-existing on main, open item #100; carded 2026-09-19)
Context. apps/web/tsconfig.json:1-12 is a solution file: "files": [] at :2 and two project references at :3-6, tsconfig.app.json and tsconfig.node.json. npx tsc --noEmit in apps/web reads that file and checks no file. Measured at main 16dd547 with the TypeScript apps/web installs (5.9.3): npx tsc --noEmit --listFilesOnly lists 0 files and exits 0, while npx tsc -p tsconfig.app.json --noEmit --listFilesOnly lists the 311 files under src (tsconfig.app.json:26, the tests included) and eslint-rules/control-set.d.ts, which src imports, and npx tsc -p tsconfig.node.json --noEmit --listFilesOnly lists vite.config.ts alone (tsconfig.node.json:25). On a scratch solution of the same shape holding one type error, tsc --noEmit exits 0, tsc -p <the project> --noEmit exits 2 and tsc -b exits 1, both naming the error; tsc -b --noEmit behaves as tsc -b, because both real projects already set "noEmit": true (tsconfig.app.json:14, tsconfig.node.json:15). One TypeScript file is in no project at all, apps/web/vitest.config.ts, so no command checks it today; checked once in a scratch project that extends tsconfig.node.json, it type-checks clean together with vite.config.ts. CI already type-checks the rest, through the build and never by name: package.json:8 is "build": "tsc -b && vite build", the web job runs it (.github/workflows/ci.yml:261-262), the e2e job builds the SPA (:728-732), and both images' SPA stage runs npm run build (apps/api/Dockerfile:10, infra/caddy/Dockerfile:10) under the image-frontend job (:836-855), as docs/CI.md:44 states. So a type error already fails CI, but it reads as a failed build, and no script is named for the check (package.json:6-12). The workos mirror already names its script typecheck (services/workos/service/package.json:14) and CI runs it by that name (ci.yml:362). Where the empty command is cited: in the card set, V41's and V42's acceptance text (skynet-task-specs.md:908, :918), both merged, with V41's Built-as noting the gap (:911), and the two import objects that carry them (appendix-b-import.json objects 158 and 159, keys V41 and V42); the builder briefs of 2026-09-16 for V35, V36 and V37; and four records in the repository whose types row is npx tsc --noEmit with exit 0 (docs/ops/v35-acceptance.md:236, v36-acceptance.md:243, v37-acceptance.md:445, v41-acceptance.md:202), each of which also ran npm run build on its next row, so the types those cards claimed clean were checked by the build's tsc -b. No open card cites it. The repository's own guidance steers away from it without naming a replacement: AGENTS.md:27 lists npm run lint && npm run build for apps/web; apps/web/src/README.md:30 says "use it, not tsc --noEmit" and in the same paragraph calls CI's lint non-blocking, which is stale (the step blocks, ci.yml:259-260, docs/CI.md:34); apps/api/CLAUDE.md:185-186 still names the imported path app/frontend. Two places spell out the build's current recipe and go stale when it changes: the comment above the image-frontend job (ci.yml:828, "runs tsc -b && vite build there") and docs/CI.md:44.
Read first. apps/web/tsconfig.json, apps/web/tsconfig.app.json, apps/web/tsconfig.node.json, apps/web/package.json:6-12 and apps/web/vitest.config.ts:1-20; .github/workflows/ci.yml:243-264 (the web job), :726-733 (the e2e job's build), :818-855 (image-frontend and why it exists) and :342-366 (the workos job's pnpm typecheck); apps/api/Dockerfile:1-12 and infra/caddy/Dockerfile:1-12; docs/CI.md:12, :24, :32-34 and :44; AGENTS.md:24-31; apps/web/src/README.md:28-31; apps/api/CLAUDE.md:180-190; docs/ops/v41-acceptance.md:198-210 and docs/ops/v39-acceptance.md:304-307 (the two records that name the gap); open item #100.
Deliverables. (1) apps/web/package.json gains "typecheck": "tsc -b", and build becomes npm run typecheck && vite build, so the type check the build runs and the one a card names are one definition; both Dockerfiles keep RUN npm run build and so keep checking types. (2) apps/web/tsconfig.node.json's include names vitest.config.ts beside vite.config.ts, so every tracked TypeScript file under apps/web sits in a project that tsc -b checks. (3) The web job runs npm run typecheck as its own step, named for types, after npm ci and before the build (ci.yml:258-262), so a type error fails a step that says so. (4) The repository names the web type check one way wherever it names it: AGENTS.md:27 lists npm run typecheck between lint and build; apps/web/src/README.md:30 names npm run typecheck in place of "not tsc --noEmit", and the same paragraph says CI's lint blocks; docs/CI.md:12 (the web row) lists the step, and :44 names the build's type check as npm run typecheck; the comment at ci.yml:828 describes the build as it now reads; apps/api/CLAUDE.md:185-186 points at apps/web and names the script. (5) Outside the repository, applied at merge with the card's own bookkeeping: V41's and V42's acceptance text (skynet-task-specs.md:908, :918) and their two import objects name npm run typecheck in place of npx tsc --noEmit, so no card in the set cites the empty command; their Built-as notes stay as the record of what was run. (6) docs/ops/v43-acceptance.md: what each command checks before and after (the file lists), the bite proofs below, and the new step's first run.
Interfaces. Exposes npm run typecheck in apps/web: the one type-check command, which every card names from this card on and which the build and both images run through npm run build. Consumes nothing new. Unchanged: both projects' compiler options, tsconfig.json, the lint, the test runner, the Dockerfiles and every source file.
Acceptance. cd apps/web && npm run typecheck exits 0 on the branch, and npx tsc -p tsconfig.node.json --noEmit --listFilesOnly lists vite.config.ts and vitest.config.ts. Bite proofs on scratch copies, recorded and never committed, each failing on the base sources for its own reason: on the base, npm run typecheck fails with a missing script; a type error put in a file under src/ leaves npx tsc --noEmit at exit 0 with no output (the solution file lists no files), while the branch's npm run typecheck fails naming the file and line; a type error put in vitest.config.ts leaves the base's npm run build passing its type stage (no project includes the file), while the branch's npm run typecheck and npm run build fail naming vitest.config.ts. npm run lint, npm run build and npm test green. The pull request edits ci.yml, so every job runs (docs/CI.md:24): its web job shows the new step green, and its image-frontend and e2e jobs are green, which is what says the images still build and still check types. rg -n "tsc --noEmit" AGENTS.md apps/web/src/README.md apps/api/CLAUDE.md docs/CI.md and rg -n "tsc -b && vite build" .github/workflows/ci.yml docs/CI.md return nothing. gitleaks detect --no-git --source . --config infra/secret-scan/gitleaks.toml --redact clean. Record docs/ops/v43-acceptance.md; open item #100 closed.
Out of scope. The compiler options of either project, and type-aware lint. e2e/, which has no tsconfig and whose specs Playwright runs without a type check: a real gap, and its own item. apps/web/eslint.config.js and apps/web/eslint-rules/*.js, which are JavaScript that the lint reads. The four records under docs/ops/ that list the empty command, which record what was run, and each of which ran the build as well. The builder briefs of 2026-09-16, which are dated dispatch notes. The workos mirror's own typecheck. The mention of the web's own tsc -b at apps/web/src/shell/production-routes.ts:4, which stays true. The card key in the comment at apps/web/vitest.config.ts:5 and the other keys in apps/web comments, which A35 sweeps.
Waits on / Unblocks. Nothing: every file it edits is on main. A4's pull request #83 edits ci.yml only outside the web job (its filter key, its spike-a4 job, a step in infra and the ci aggregate) and docs/CI.md only in the infra row and a new spike-a4 row, so the two merge in either order, with #83 re-run against main after this merges. A33 and A34 edit docs/CI.md only in its mirrors row. A35 edits .github/workflows/ci.yml, AGENTS.md and docs/CI.md too: the merge order puts this card first, and A35 is rebased after this card if this card merges first. That is a merge order, not a dependency: A35 waits only on A4. / Every later web card, which names one command that checks the code.
Built as (PR #108 → main 1bbf419, 2026-09-19). Code 1 review round → APPROVE; the record's follow-up, the first CI run's numbers, was confirmed. apps/web/package.json gains "typecheck": "tsc -b" and build becomes npm run typecheck && vite build, so the build runs the one check a card names; both Dockerfiles keep RUN npm run build. tsconfig.node.json's include names vitest.config.ts beside vite.config.ts, so every tracked TypeScript file under apps/web sits in a project that tsc -b checks. The web job runs a type-check step (npm run typecheck) after lint and before the build. AGENTS.md, apps/web/src/README.md, docs/CI.md, the comment on the image stage in ci.yml and apps/api/CLAUDE.md now name the check one way. Measured with --listFilesOnly: npx tsc --noEmit checks 0 files on main and on the branch, and npm run typecheck checks 314 (312 in the app project, 2 in the node project). Three bite proofs, each on its own scratch copy: a type error in src/lib/utils.ts passes npx tsc --noEmit on main and fails npm run typecheck on the branch; the same error in vitest.config.ts passes npm run build on main and fails npm run typecheck on the branch; and on the branch it fails npm run build before vite build starts. Lint, build and the whole web suite (120 files, 1057 tests) clean. The first CI run, 35457505373, was green: the type-check step 16 s, the web job 5 min 57 s, and e2e and both image-frontend legs passed. One deviation: the dev command beside the build command in apps/api/CLAUDE.md named the imported app/frontend path too, and it now reads cd ../web. Record docs/ops/v43-acceptance.md; open item #100 closed. Found and filed as open item #109: the check now runs three times in the web job (the step, the build and the gallery build), about 16 s each on the runner.
V44 · A web test that leaves an update outside act fails · V · M · S2-W0 · test/v/v44-act-environment (surfaced while challenging the card for open item #90 on 2026-09-18, open item #94; carded 2026-09-19)
Context. React warns "An update to X inside a test was not wrapped in act(...)" only while the global IS_REACT_ACT_ENVIRONMENT is true. In the installed react-dom 19.2.4 the flag is read only by isConcurrentActEnvironment (apps/web/node_modules/react-dom/cjs/react-dom-client.development.js:16322-16332), and that function has two callers, both warnings: a suspended resource that resolved outside act (:18598, message at :18601) and an update outside act (:18755, message at :18759). The function itself prints "The current testing environment is not configured to support act(...)" (:16330) when the flag is off while an act queue is open. So the flag changes no scheduling. It changes only what React reports. Testing Library 16.3.3 turns the flag on in @testing-library/react/dist/index.js:41-51 only when beforeAll and afterAll exist as globals, and it registers its own cleanup at :26-29 only when afterEach does. At main 16dd547, apps/web/vitest.config.ts:15-42 sets no globals, so vitest 4.1.11 puts none of its test APIs on the global object and both checks are false. apps/web/src/test/setup.ts:2-3 imports cleanup and afterEach and runs the cleanup by hand at :23-25, which is why the missing cleanup was noticed and the missing flag was not. Testing Library's own act (dist/act-compat.js:39-78) sets the flag true for the length of each call and then restores the previous value, which here is undefined. So in all 120 test files the suite runs today (119 under src and one under eslint-rules, vitest.config.ts:19; the item says 118), an update that a test leaves outside act renders later and nothing is printed. That is the defect class behind open items #81 (V37) and #90 (V41): a read that runs before the render it expects. V41's record leaves turning the flag on to this item (docs/ops/v41-acceptance.md:217-218). Measured for this card, lightly: a node probe with the installed React, react-dom and jsdom made one state update from a timer after an act-wrapped render, and printed nothing with the flag unset and one "An update to Counter inside a test was not wrapped in act(...)" with the flag true, with the same DOM both times. src/components/ui/copy-button.test.tsx (2 tests) and src/pages/products/ProductList.test.tsx (4 tests), each run alone as configured and with --globals, printed no act warning in any of the four runs. The library explains why: dist/pure.js:84-104 (asyncWrapper) sets the flag false for the length of every waitFor, findBy and user-event call, and drains with one setTimeout(0) before it restores the flag, so a read gated through those utilities raises nothing. The warning is for what escapes them: a timer or a promise that settles between two synchronous reads, or after the last one. The count across the suite has not been measured; it is this card's first measurement. There are two ways to turn the flag on, and this card takes the first. (a) setup.ts sets the flag, the way the library would. (b) globals: true goes into vitest.config.ts and the hand-written cleanup is removed. Option (b) changes more than the flag: it puts 19 names on the global object in every file (apps/web/node_modules/vitest/dist/chunks/constants.CPYnjOGj.js:16-36), although all 120 files already import what they use from vitest; it turns on the library's cleanup beside the hand-written one; and it makes the type check disagree with the runner, because apps/web/tsconfig.app.json:8 gives the whole of src (:26) only the vite/client types, so a test that forgot an import would run and then fail only tsc -b, and the fix for that, vitest/globals in those types, would also let page code name describe and vi. No other installed dependency looks for test globals, and Testing Library's fake-timer check (dist/pure.js:62-74) reads jest, which vitest does not define. Option (a) keeps the intent in the one file every test loads and stops the flag from depending on the library detecting a global again. Three things in the tree bear on a check that fails a test on the warning. First, 65 test files call vi.restoreAllMocks(), 64 of them inside a beforeEach and one inside an afterEach (src/components/ui/toast.test.tsx:8), which restores any vi.spyOn(console, 'error') a setup file installs. Second, src/components/ui/route-error.test.tsx:15-21 mutes console.error for every test in its file and src/components/ui/form-field.test.tsx:40 mutes it for one test, and a mock implementation never calls what sits beneath it, so no run-time guard can see a warning a blanket mute swallows. Third, src/shell/pages/creatives/CreativePage.test.tsx:234 and :286 spy on console.error with no implementation, pass everything through, and read the calls.
Read first. apps/web/vitest.config.ts:15-42 and apps/web/src/test/setup.ts:1-25. In apps/web/node_modules: @testing-library/react/dist/index.js:18-52 (the cleanup and act-environment hooks and the checks that guard them), @testing-library/react/dist/act-compat.js:33-78 (the setter and getter for the flag, and how act saves and restores it), @testing-library/react/dist/pure.js:62-119 (the fake-timer check, asyncWrapper, which turns the flag off for every wait and drains, and eventWrapper), react-dom/cjs/react-dom-client.development.js:16322-16332, :18592-18603 and :18754-18763, and vitest/dist/chunks/constants.CPYnjOGj.js:16-36. apps/web/tsconfig.app.json. The gate shapes the suite already uses: src/shell/pages/status/StatusPage.test.tsx:303-325 (advances inside act at :309, :311 and :319), src/components/ui/copy-button.test.tsx:29-44 (a waitFor on the asserted state, then an advance inside act) and src/shell/auth/LoginPage.test.tsx (V41's four gated advances at :163, :165, :239 and :307). The console spies: src/components/ui/route-error.test.tsx:13-21 with the test at :54-57 that asserts the boundary logs, src/components/ui/form-field.test.tsx:39-60, and src/shell/pages/creatives/CreativePage.test.tsx:228-245 and :278-290. src/test/controls.ts with src/test/controls.test.tsx (where a shared test helper lives and how it is proven). docs/ops/v37-acceptance.md:15-28 (the box, the six-process neighbour and the shape of a whole-suite run) and docs/ops/v41-acceptance.md. Open items #81, #90 and #94.
Deliverables. (1) apps/web/src/test/setup.ts turns React's act environment on for every test file, the way Testing Library does when it finds the globals (index.js:41-51): true before the file's first test, and the previous value restored after its last test, with beforeAll and afterAll imported from vitest as the file already imports afterEach. The global is declared with its type, so tsc -p tsconfig.app.json checks the assignment and sees no any. A comment above it says why the flag is set by hand: the suite runs without globals, the library sets the flag only when it finds them, and without the flag React is silent about an update outside act. The hand-written cleanup at :23-25 stays, and vitest.config.ts does not get globals. (2) The count comes first and is recorded before any fix. The whole web suite runs once with only deliverable (1) applied, and every act warning it prints is listed by file, test, component and message. The messages counted are the three React prints about act (:18759, :18601, :16330). The count on the base sources is recorded with its reason, not as a measurement: the two "not wrapped" messages need the flag on, so they cannot print; the "not configured" message could print from an act imported from react, and none is imported (the 27 files that import act take it from Testing Library, which sets the flag for the call). (3) Every warning in that count is fixed at its cause, in the test or in the page. In a test: the read waits for the state it asserts (findBy, waitFor, or an advance inside act in the shape of StatusPage.test.tsx:309), or the test awaits the work the page started before it ends. In a page: a page that updates state after it has nothing left to show stops doing so, and gets a test in its own file that fails on the base page. Nothing is silenced: no console.error mock that swallows the message, no onConsoleLog filter, no file that sets the flag back to false, no import from @testing-library/react/pure, and no RTL_SKIP_AUTO_CLEANUP. (4) From then on, an act warning fails the test that raised it, and the failure names the component and the message. The builder chooses the mechanism, and it holds through the three things the context names. A vi.restoreAllMocks() in any hook of any test file does not remove it. A test that expects a console error mutes only the message it expects, through one shared helper under src/test/ that forwards every other message to the guard and keeps the muted calls readable, and a check that runs in npm test refuses any other mute of console.error in a test file (a mock implementation on a console.error spy, or an assignment to console.error) and names its file and line. route-error.test.tsx and form-field.test.tsx:40 move to the helper, and route-error.test.tsx:54-57 still proves the boundary logs its error. A spy with no implementation, as in CreativePage.test.tsx, still passes messages through to the guard. A warning raised after a test's last statement but before its cleanup belongs to that test. (5) docs/ops/v44-acceptance.md: the reading of both libraries and why (a) and not (b); the count on the base sources and its reason, the count with the flag alone (per file and test), and the count on the branch (0); the cause and fix of each warning; the bite proofs; and the whole-suite runs.
Interfaces. Exposes one rule: every web test runs with React's act environment on, an act warning is a failing test, and a test that expects a console error mutes that message and nothing else through the shared helper. The tests of every later web card meet it from their first run. Consumes vitest's hooks, Testing Library's act, waitFor and findBy, and the gate shapes V37 and V41 use. Unchanged: vitest.config.ts, the per-test budget at :41, asyncUtilTimeout at setup.ts:21, the pool, and every test that raises no warning and mutes no console.
Acceptance. npm test in apps/web green. A test proves the flag is set inside a test body, outside any act and any wait: globalThis.IS_REACT_ACT_ENVIRONMENT is true; it fails on the base sources, where it reads undefined. A test proves the guard: a probe component whose state changes from a timer after its render, with no gate, is reported as an update outside act, and the report names the probe; it fails on the base sources, where React prints nothing and no report exists. A test proves the helper: a message it was told to expect is muted and readable, and an act warning raised under it is still reported and names the probe. The check that refuses a blanket mute fails on the base sources and names route-error.test.tsx:16 and form-field.test.tsx:40. Bite proofs on scratch copies, recorded and never committed: each fixed test with its fix reverted fails on the branch with the warning named, and passes in silence on the base; a scratch test that calls vi.restoreAllMocks() and then raises an update outside act still fails; a scratch test that mutes console.error with mockImplementation(() => {}) is refused by the check at its file and line. The whole web suite is green idle once, and then 3 of 3 beside the six-process neighbour of docs/ops/v37-acceptance.md:21-28. Each run's file and test counts, load average, wall time and act-warning count are recorded and not pinned. A warning that shows only under load is a finding under deliverable (3), not a reason to rerun. grep -rn "IS_REACT_ACT_ENVIRONMENT" apps/web/src shows only setup.ts and the proving tests. npm run lint, npx tsc -p tsconfig.app.json --noEmit and npm run build clean (npx tsc --noEmit checks no file in apps/web; see V43). The web job is green on the pull request, which runs npm test. No comment that the card adds names a card, an item or a phase. Record docs/ops/v44-acceptance.md; open item #94 closed.
Out of scope. globals: true and any other change to vitest.config.ts. Raising either budget. Tests that raise no warning and mute no console, including the 27 files that already import act from Testing Library. React development warnings that are not about act, such as a missing key, and all other console output except the two blanket mutes the guard needs narrowed. Mutes of console.warn or console.log. The e2e/ browser specs, which render no React test tree. The api tiers. Reopening V41's unexplained failure: if a warning in LoginPage.test.tsx bears on it, the record says so, and nothing more is done.
Waits on / Unblocks. Nothing: V37 and V41, whose gates this card generalises, have merged, and no open card edits setup.ts or the two muting test files / V45, which builds after this card by build order only and shares no file with it, so its tests run under the flag; V22 and V15, whose native Intelligence pages then have their tests written with the warning on and failing; and every later web card.
Built as (PR #109 → main 7083fd5, 2026-09-19). Code 1 review round → APPROVE, then the CI finding below. React's act environment is on for every web test file from setup.ts, a guard in front of console.error fails the test that raises an act warning, and a test that expects a console error mutes that message alone through one shared helper, muteConsoleError; an act warning is never muted whatever the patterns match, and a source check in npm test refuses any other mute of console.error in a test file. With the flag alone the suite printed 138 act warnings in 42 tests across 24 files, every one an update outside act. All 138 are fixed at their causes, and no page changed. The whole suite after the fix: 122 files, 1063 tests, all passed, 0 act warnings, 284 s. On the way it fixed a focus race in ImageLightbox.test.tsx that this card did not write: the dialog's popup hands the focus to the focus manager, which queues a microtask and then focuses the first reachable control inside an animation frame, so the test read the focus before the frame ran. A scratch run of the test's body at 61 animation-frame delays, 0 to 300 ms in steps of 5, failed 12 to 15 times of 61 as it was and 0 of 61 with the fix, on this branch and on main's setup alike; the same test failed the same way on A4's pull request, whose setup.ts has no flag, so the race is main's and not this card's. The test now waits for the focus. The merge commit's CI and the staging deploy were both green. Record docs/ops/v44-acceptance.md; open item #94 closed.
V45 · Every scroll box is classified, whatever sets its ceiling · V · M · S2-W0 · test/v/v45-style-and-layout-ceilings (surfaced while building V39 on 2026-09-18, open item #102; carded 2026-09-19)
Context. apps/web/src/test/scroll-regions.test.ts (V39, main 16dd547) parses every non-test .tsx under apps/web/src and finds a scroll box by its className alone (:77-80): a scroll utility (SCROLL, :32) together with a height ceiling (CEILING, :36). It holds what it finds to one reviewed inventory of 25 (:112-138, the count at :155), and its header says that a ceiling set by a style or by a parent's layout is not read (:17-19). Re-measured at 16dd547 with the sweep's own rules over the same 151 files: 46 elements carry a scroll utility. 25 carry a class ceiling, as V39 recorded. 2 carry only a min-h floor: components/ui/sheet.tsx:68 and pages/products/ProductMemory.tsx:122 (V39's card had them at :44 and :118). 19 carry no height utility. Two of the 19 set their ceiling in a style, at the lines the item gives. pages/video-storyboard/VideoStoryboardScreen.tsx:912 is the scene rail: overflow-y-auto with style={{ width: railWidth, maxHeight: 'calc(100vh - 16rem)' }} at :913, and a <button> per scene from :915. :973 is the focused scene: overflow-y-auto with style={{ maxHeight: 'calc(100vh - 16rem)' }} at :974, and the scene's own buttons from :1008. Both hold controls, so neither is a defect today. No other element under src sets height or maxHeight in a style next to a scroll utility, and none sets overflow in a style, so a sweep that also reads styles holds 27. Five elements pass a style the sweep cannot read as one object literal: components/video-storyboard/ImageLightbox.tsx:178 (a conditional of two object literals), components/video-storyboard/SavedCharacterThumb.tsx:39 and :47 (a local constant, :36) and components/video-storyboard/StoryboardImage.tsx:82 and :95 (a style prop passed through from its callers). None of them carries a scroll utility today, but a style held in a variable or a prop can carry an overflow or a ceiling that no reading of the element sees. Of the 19 with no ceiling of their own, 14 carry only overflow-x-auto, the family V39 measured and left out (docs/ops/v39-acceptance.md:418-440). An element whose overflow is not visible on one axis computes the other axis to auto (the sweep's own note at :29-31), so any of the 14 inside a bounded column would scroll up and down too. Five more scroll up and down, or would, with no ceiling of their own, and an ancestor decides whether they scroll. (i) The sheet body at sheet.tsx:68: min-h-0 flex-1 overflow-y-auto, inside the popup's flex h-full flex-col at :47, inside a fixed viewport at :43. V39 measured it at 8054 of 720 px on the Status page and answered with SheetBody (:9-22), a required prop with no default that each caller fills (components/creative-code/LineageSheet.tsx:27, shell/pages/status/StatusPage.tsx:133, shell/pages/DevGallery.tsx:177). (ii and iii) The bodies of the two document viewers, pages/system/SystemDocuments.tsx:374 and pages/products/ProductDocuments.tsx:677: flex-1 overflow-auto mt-2, bounded by the max-h-[90vh] flex flex-col of DialogContent at SystemDocuments.tsx:331 and ProductDocuments.tsx:634. V39 found them sound because the trigger of DocEditHistory is a tab stop (v39-acceptance.md:438-440). (iv) components/layout/AppLayout.tsx:10: <main className="flex-1 overflow-y-auto"> under the min-h-screen flex flex-col of shell/ShellLayout.tsx:36, which is a floor, so the main should grow with the page and leave the scrolling to the document. (v) ProductMemory.tsx:122: min-h-[700px] overflow-auto, which V39 measured growing to 17958 px and never scrolling up and down (v39-acceptance.md:345-346), but which is whitespace-pre-wrap, so one long unbroken token makes it scroll sideways while it holds only text (v39-acceptance.md:451-452). None of these 19 is in any inventory. So a third inline-style ceiling, a scroll or ceiling hidden in a style variable, or a new flex-1 overflow-y-auto or overflow-x-auto box in a bounded column, that holds only text would ship without a tab stop and without a failing test. Three comments state the old limit to the next author: sheet.tsx:19-21 and :62-67 say the sweep cannot see the sheet body, and components/ui/scroll-region.tsx:76-77 says every box with a class ceiling is listed "with which of the three it is". What the repo already has for a browser check: e2e/ holds Playwright 1.58.1 and @axe-core/playwright 4.13.0 over axe-core 4.13.0, whose scrollable-region-focusable rule carries the wcag2a tag. e2e/playwright.config.ts has two projects: chromium drives a whole running stack, and gallery (testMatch: '**/ui-gallery.spec.ts') drives a vite preview of a build made with VITE_UI_GALLERY=1, with no login and no api (e2e/ui-gallery.spec.ts:9-10), both at Playwright's Desktop Chrome viewport of 1280 by 720. CI's web job, filtered on apps/web/** and e2e/** (.github/workflows/ci.yml:92-99), builds, serves and runs the gallery project (:265-293), so a leg in ui-gallery.spec.ts is a browser check on every web pull request without a stack. The gallery already opens a Sheet (ui-gallery.spec.ts:67-76, the fixture at DevGallery.tsx:171-181, a controls body over the seven-row chain at :31-48), but no leg measures that a body scrolls. Axe reports scrollable-region-focusable only on a box that actually scrolls, and it reads the markup. V39's record found that Chromium makes a bare scroll container focusable by itself (v39-acceptance.md:366-371), so a keyboard probe in Chromium passes where axe fails, and a browser check measures that the box scrolls (scrollHeight above clientHeight, or scrollWidth above clientWidth) before it trusts axe's silence, and takes its verdict from axe, not from Tab. For page-level boxes, V39's re-check drove the built app under vite preview, answered every api call with page.route, and used no stack (v39-acceptance.md:378-386).
Read first. apps/web/src/test/scroll-regions.test.ts (whole file) and apps/web/eslint-rules/control-set.js (the control names the sweep imports). apps/web/src/components/ui/scroll-region.tsx (the named region and its rules, the inventory sentence at :76-77). apps/web/src/components/ui/sheet.tsx (whole file: SheetBody with its comment at :9-21, the popup, and the body with its comment at :62-67). apps/web/src/pages/video-storyboard/VideoStoryboardScreen.tsx:905-1010. apps/web/src/components/video-storyboard/ImageLightbox.tsx:170-185, SavedCharacterThumb.tsx:30-55 and StoryboardImage.tsx:30-100 (the styles the sweep cannot read as one literal). apps/web/src/pages/system/SystemDocuments.tsx:331-411 and apps/web/src/pages/products/ProductDocuments.tsx:634-718. apps/web/src/components/layout/AppLayout.tsx and apps/web/src/shell/ShellLayout.tsx:33-40. apps/web/src/pages/products/ProductMemory.tsx:108-126, with the comment at :118-121. apps/web/src/shell/pages/DevGallery.tsx:31-48 (the chain fixture) and :165-181 (the Sheet section), with apps/web/src/shell/gallery-route.test.tsx (the unit test that renders the whole gallery). e2e/ui-gallery.spec.ts (whole file), e2e/playwright.config.ts, and e2e/README.md:164 onward ("Two suites, two servers"). .github/workflows/ci.yml:92-99 and :243-305. docs/ops/v39-acceptance.md:305-410 (the browser check, the floors, what Chromium does by itself, the re-check) and :418-454 (the family V39 left out, and this item as V39 filed it). Open item #102.
Deliverables. (1) The sweep reads a ceiling and a scroll from an inline style the same way it reads them from the class string, in any mix of the two. A ceiling is a height, maxHeight, blockSize or maxBlockSize key whose value is not a content-sized keyword (auto, fit-content, min-content, max-content, none), whether the value is a number, a string, a calc() or an expression. A scroll is an overflow, overflowY, overflowX, overflowBlock or overflowInline of auto or scroll. A conditional or logical whose branches are object literals is read branch by branch. VideoStoryboardScreen.tsx:912 and :973 join the ceiling inventory as boxes that hold their own controls, with the controls named (a button per scene; the scene's own buttons), and the count becomes 27. (2) The sweep fails closed on a style it cannot read. Any element whose style is not an object literal or a conditional or logical of object literals (a variable, a prop, a call, a spread) is reported until a reviewed list in the same file names it and says where the value comes from and why it sets no scroll; the builder may instead resolve a same-file constant object literal, and must then prove that reading. Today that is the four sites SavedCharacterThumb.tsx:39 and :47 and StoryboardImage.tsx:82 and :95, or the two StoryboardImage sites alone if the constant is resolved. So moving a style into a constant or a prop cannot take a box out of the sweep. (3) Every element with a scroll (class or style) and no ceiling of its own is held to a second reviewed inventory in the same file: 19 entries today, in source order per file. Each entry says one of four things. grows with its content names the floor or the container that lets it grow (AppLayout.tsx:10, ProductMemory.tsx:122), and says which axis it claims. bounded by the layout names the ancestor that sets the ceiling, and then classifies what the box holds exactly as for a ceilinged box (the two document viewers' bodies, which hold their own controls). its caller says what it holds is for a component whose props require that statement and have no default (the sheet body, through SheetBody). the horizontal family names what the box wraps (a table, a code block) and covers the 14 sideways boxes, whose height follows their content. After this, every element with a scroll is in exactly one inventory (46 today), and a new scroll box anywhere in the tree fails the sweep until somebody classifies it, whatever sets its ceiling. (4) The rule is written where the next author meets it. The sweep's header, in place of :17-19, states the two inventories, the unreadable-style rule, and that the browser checks below prove the layout entries' claims. The comments at sheet.tsx:19-21 and :62-67 and at scroll-region.tsx:76-77 are changed to say what is now true. The comment at ProductMemory.tsx:118-121 is kept if it is still true, or corrected. (5) A committed browser check where no stack is needed. The gallery gets a Sheet whose body is text declared as a region, with content taller than the viewport, and its controls fixture gets content taller than the viewport. e2e/ui-gallery.spec.ts gets a leg that opens each sheet, measures that the body scrolls, runs axe with the spec's tags (:20) and finds no violation, and finds the text body by its role and name with tabindex="0". The measurement comes before the axe run, so the leg cannot pass on a body that stopped scrolling. (6) A browser check that is run once and recorded, never a per-push test, for the page-level entries, in the method of V39's re-check: the branch's build under vite preview, every api call answered by page.route, no stack, at 1280 by 720. Each document viewer opened on a long document, and each storyboard box with enough scenes and frames to pass its ceiling, is measured scrolling, with axe clean. AppLayout's <main> under a page far taller than the viewport, and ProductMemory's box with 300 lines, are measured not scrolling up and down (scrollHeight equal to clientHeight). ProductMemory's box is also driven with one unbroken token wider than the box and measured on the sideways axis. If the check finds a box that scrolls where its entry says it grows, or that holds only text where its entry says controls, the page is fixed and gets a test in its own file that fails on the base page. The box is not reclassified to make the check pass. (7) docs/ops/v45-acceptance.md: the counts before and after, both rules, the two browser checks with the heights, widths and verdict of every box, the bite proofs, and the horizontal family named as listed and not proven, with open item #105 named as where it is fixed.
Interfaces. Exposes the second inventory and the unreadable-style list beside the ceiling inventory in scroll-regions.test.ts, which every later scroll box joins whatever sets its ceiling. Also exposes a gallery leg that proves a layout-bounded body scrolls and passes axe, which every later shared component with such a body copies. Consumes ScrollRegion, SheetBody, the control set, the gallery build and its CI step, and @axe-core/playwright, all of which are in the tree. No page's markup changes unless the one-off check finds that an entry's claim is false.
Acceptance. npm test in apps/web green, with the sweep passing over the real tree at 27 ceilinged boxes and 19 entries in the second inventory, 46 in all. Run with the base file's inventory of 25 and no second inventory, the new sweep fails and names VideoStoryboardScreen.tsx:912, :973 and the 19 boxes with no ceiling of their own, and run with no unreadable-style list it fails and names the unreadable sites, which shows it reads the tree and not a list. Bite proofs on scratch copies of the tree, recorded and never committed. Each of these fails on the branch and passes on the base: a new <div className="overflow-y-auto" style={{ maxHeight: 200 }}> holding text; a new <div style={{ overflowY: 'auto', maxHeight: 200 }}> holding text; a new <div className="min-h-0 flex-1 overflow-y-auto"> holding text; a new <pre className="overflow-x-auto"> holding text; a new element with no scroll utility whose style is a variable; a new element with a scroll utility whose style is a prop. On the branch alone, removing any one classification from either inventory or the list fails. The sweep's own unit test reads a style ceiling written as a number, as a string and as a calc(), reads height: 'auto' as no ceiling, reads both branches of a conditional style, and reads both mixes: a style scroll with a class ceiling, and a class scroll with a style ceiling. npm run test:gallery in e2e green against a preview of the branch's gallery build. The new leg fails on two scratch copies, each for its own reason: with the text fixture's body declared controls, axe reports scrollable-region-focusable; with the text fixture's content cut to one line, the scroll measurement fails before axe runs. The existing whole-page axe leg, the sheet focus leg and gallery-route.test.tsx still pass. The one-off page-level check is in the record as deliverable (6) states it. npm run lint, npx tsc -p tsconfig.app.json --noEmit, npm run build and VITE_UI_GALLERY=1 npm run build clean (npx tsc --noEmit checks no file here; see V43). The web job is green on the pull request, which is where the sweep and the gallery leg run on every push. No comment that the card adds names a card, an item or a phase. Record docs/ops/v45-acceptance.md; open item #102 closed.
Out of scope. Fixing the horizontal family, which is open item #105 and not a condition of this card's acceptance: whether each of the 14 scrolls sideways depends on content width at a viewport. This card lists them so a new one is seen, proves nothing about them, and changes none of them. Changing any box whose claim the browser confirms, including the two storyboard boxes, which hold buttons and take no ScrollRegion. A crawl of every route for scroll boxes, and new legs in the stack-driven chromium project. An eslint rule, for the reason V39 recorded: whether a box is sound depends on what it renders. The shape of SheetBody and its callers. The act environment in the web suite (V44) and card keys in comments (A35).
Waits on / Unblocks. Nothing: V39's sweep, ScrollRegion and SheetBody are on main at 16dd547, and no open card edits scroll-regions.test.ts, DevGallery.tsx or ui-gallery.spec.ts; V44 builds first where both are open, so this card's tests run under the act flag, and whichever merges second re-runs its checks on the first one's merge / V22 and V15, whose native Intelligence pages add scroll boxes that the sweep then holds from their first commit whatever sets their ceiling, and every later shared component whose body takes its ceiling from the layout.
Built as (PR #117 → main e94c298, 2026-09-20). The scroll sweep in apps/web/src/test/scroll-regions.test.ts now reads a ceiling and a scroll from an inline style as well as from the class string, holds every element that can scroll in one of two reviewed inventories, and reports any style it cannot read until a third list accounts for it. A style written as a conditional or a logical is read branch by branch, and everything the file cannot resolve fails closed, so moving a style into a variable or a prop cannot take a box out of the sweep. Over the same 151 non-test files the sweep sees 46 scrolling elements against 25 on the base: 27 with a ceiling of their own, 19 with none, and 4 styles it cannot read, each named with where its value comes from and why it sets no scroll. A hole in the logical reader was found and closed during the build: it read only the right operand of a ||, so a name on the left went through, and the seventh bite proof is that hole before the fix and after it. Six other bite proofs fail on the branch and pass on the base, and three scratch runs prove the sweep reads the tree and not a list. A new gallery leg proves in a browser that a sheet body bounded by the layout scrolls and that axe is silent on it, and a one-off browser run measured every page-level entry of the second inventory against the claim it makes. The gallery suite passed 5 of 5, and lint, both type checks, both builds and the secret scan were clean. Record docs/ops/v45-acceptance.md; open item #102 closed. The fourteen sideways boxes are listed and not proven here, which is open item #105.
V46 · The cast and the Build button share the built rule; the frame gate gives its own readiness reason · V · M · S2-W0 · fix/v/v46-shared-built-rule (surfaced while building V42 on 2026-09-18, pre-existing on main, open item #96; carded 2026-09-19)
Context. Two questions are asked about a character. Built: is there a character to edit, which the cast chips and the Build button answer. Ready: can it be drawn into a scene now, which the frame gate answers, and more strictly. Three readers on the web answer the first from the identity lock alone, against the rule the panel states for itself, and the server's whole-board frame gate, which asks the second, gives every character it refuses a reason that belongs to the first. Measured at main 16dd547, which is after S36 (PR #103) moved the build's claim. isBuilt (apps/web/src/components/video-storyboard/CharacterPanel.tsx:63-76, the item's :66-76) counts a character as built when it is not skipped and has a sheet (r2_key), a lock or any identity field, and its comment says why the lock alone is wrong: a build from a page that could not be read comes back with an empty lock. That state is real: _save_supplied (apps/api/app/backend/services/storyboard_image_runner.py:617-690) stores the sheet's key with whatever lock the read returned (:663-666). The panel uses the rule in four places (the editor's exists at :910, and the three view guards at :2086, :2110, :2146). Three readers do not: the cast chip's unbuilt={!recordFor(m.slug)?.identity_lock} (CharacterPanel.tsx:2003, was :2013), which CastChip draws italic (CastChip.tsx:39, :63); unbuiltMembers (apps/web/src/pages/video-storyboard/VideoStoryboardScreen.tsx:115-120, the filter at :118), which plans the fan-out and counts the button; and buildEveryone's first check, if (!character?.identity_lock) (:480), with the count's primary term at :517-518 (was :517). So a character whose page could not be read opens in the editor as built while its chip is italic, the button counts it (:528-532: "Build 1 character", never "All characters built"), and pressing it sends that character again. The item left one question open, whether that press spends. It does not, read from the code. The fan-out never forces: buildCharacter(slug, false, false) at :493-494 and :501, and buildCharacter() at :481 with force = false by default (:313), which the client sends as ?force=false (apps/web/src/lib/videoStoryboardApi.ts:1421-1424), and the route (apps/api/app/backend/routes/video_storyboard.py:941-962) calls ensure_character(force=False). For a member, ensure_character takes the member path (storyboard_image_runner.py:1396-1400), ensure_cast returns the stored roster at once (:161-163), and ensure_cast_member returns the stored record untouched whenever it is non-empty (:1264-1266), which a sheet with an empty lock is; the function's two paid exits, the supplied-sheet read at :1279 (a vision call at :1230 when the pool entry holds no reading) and _invent_character at :1281 (S36's claim at :1319, a text call at :1334, then a render), both come after that return. For the primary, svc.get_character returns the stored record (storyboard_service.py:4852-4861) and :1402-1404 returns it before ensure_cast, the mode switch or either paid path. A skipped primary is the same: the none-mode write (storyboard_image_runner.py:1456-1459) is a stored record, so its press returns it too. So the press is a wasted request and its reads, a chip and a count that disagree with the editor, and a button that stays enabled and does nothing; it is not a spend. Nothing pins that at the state that matters: the leave-alone test (apps/api/app/backend/tests/test_video_storyboard_e2e.py:1890-1917) covers a primary with a full lock only, and V42's six route tests (CharacterPanel.test.tsx:286-324) cover an unbuilt and a fully built character, never a sheet with an empty lock. The frame gate is right to be stricter, and wrong in what it says. The generate-all preflight (storyboard_image_runner.py:2892-2951) refuses a character that has no record, is skipped, or has no lock (:2930-2931), so it refuses characters that isBuilt counts as built: a sheet with an empty lock, and identity fields with no lock. That stricter refusal stays: which characters the frame gate refuses is not this card's question. But it tells every refused character that it "has not been built yet" and to build it first (:2933-2943), which sends the operator to the fix that works only for a character with no record. For a character whose page could not be read, the per-scene gate already gives the true reason, too little description, fill it in on the character panel (_require_cast_ready, :2698-2704, through character_identity.blocking_reason, apps/api/app/pipeline/character_identity.py:817-849, which answers "it has no identity lock at all." for a record with no identity and no lock), and for a skipped primary it says the session has no character (:2683-2688); but the preflight speaks first (routes/video_storyboard.py:2833), so the operator is sent to a Build button that, for both states, does nothing. One state is refused by the preflight and passed by blocking_reason: a record with identity fields at or above the floor (MIN_TRAITS, character_identity.py:250) and an empty lock. What makes that character ready is saving its fields on the character panel, which derives the lock from them (update_character_text, apps/api/app/backend/services/storyboard_service.py:4792-4808), not a Build press, which returns it untouched. No writer leaves that state today, because every writer derives the lock from the identity (apps/api/app/pipeline/storyboard_images.py:686, :747), but the preflight still refuses it, and with the not-built sentence.
Read first. apps/web/src/components/video-storyboard/CharacterPanel.tsx:63-76 (isBuilt and why the lock alone is wrong), :905-912 (the editor's exists), :1744-1746 (recordFor), :1993-2008 (the cast chips, the flag at :2003), :2080-2123 (the solo and primary guards at :2086 and :2110, and the note at :2107-2109 on why skipped keeps the editor), :2125-2148 (the member's loading card and guard); apps/web/src/components/video-storyboard/CastChip.tsx:31-45 and :63; apps/web/src/pages/video-storyboard/VideoStoryboardScreen.tsx:109-120 (unbuiltMembers and its one-rule note), :307-334 (buildCharacter), :458-504 (buildEveryone: never forced :469-475, the lock check :480, the fan-out :493-494 and :501), :514-532 (the count and the label), :740-747 (the button); apps/web/src/lib/videoStoryboardApi.ts:253-263, :986-993 and :1414-1424 (the unforced contract as the client states it); apps/web/src/lib/castLabels.ts and apps/web/src/lib/sceneCast.ts (where a shared storyboard rule already lives); apps/web/eslint.config.js:9-30 (react-refresh on every .tsx); apps/api/app/backend/routes/video_storyboard.py:941-962, :1020-1098 (get_cast, members served as stored at :1098) and :2830-2858 (generate all: the preflight, then the claims); apps/api/app/backend/services/storyboard_image_runner.py:143-163, :617-690, :1206-1233 (_read_supplied and its vision call), :1236-1282 (ensure_cast_member), :1285-1351 (_invent_character), :1354-1404 (ensure_character), :1445-1460 (the none-mode skipped write), :1895-1912 and :1938-1941 (what a render leaves on the record), :2618-2722 (_require_cast_ready, its refusals at :2666-2704), :2763-2802 (claim_frames), :2892-2977 (preflight_all and claim_all); apps/api/app/pipeline/character_identity.py:250 (MIN_TRAITS), :748-770 (the floor in _validate) and :817-849; apps/api/app/pipeline/storyboard_images.py:672-690 and :740-750 (the lock derived from the identity); apps/api/app/backend/services/storyboard_service.py:4521-4542 (read_character), :4545-4600 (write_character), :4741-4808 (update_character_text, which refuses an empty or skipped record at :4778-4785 and derives the lock from saved fields at :4804-4808) and :4852-4861; apps/web/src/components/video-storyboard/CharacterPanel.test.tsx:11-50 and :286-324; apps/web/src/pages/video-storyboard/VideoStoryboardScreen.test.tsx:1-62; apps/api/app/backend/tests/test_video_storyboard_e2e.py:816-827, :1890-1931 and :2312-2336 (the preflight's existing tests and the leave-alone test); docs/ops/v42-acceptance.md (its :189 records this fault) and docs/ops/s36-acceptance.md; open items #96 and #100.
Deliverables. (1) The built rule, in one module. isBuilt moves out of CharacterPanel.tsx into a module of its own under apps/web/src/lib, beside castLabels.ts and sceneCast.ts, with its rule and its comment unchanged, and beside it the question the fan-out asks, "still to build": not built and not skipped, which is the condition the panel's solo and primary guards already test (:2086, :2110). The panel imports both, and its four uses of isBuilt keep their meaning. (2) The three readers use it. The chip's flag at CharacterPanel.tsx:2003 asks "still to build" of recordFor(m.slug). unbuiltMembers (VideoStoryboardScreen.tsx:115-120) keeps a member when its record is still to build. buildEveryone's first check (:480) and the count's primary term (:517-518) ask the same of the primary. The note above unbuiltMembers (:109-114) and CastChip's unbuilt doc (CastChip.tsx:39) state the rule they now follow. A skipped primary is no longer counted, because the unforced press returns it untouched (storyboard_image_runner.py:1403-1404): on a session created with no character the button stops offering a press that does nothing. After this, VideoStoryboardScreen.tsx reads identity_lock nowhere. (3) The frame gate keeps its question and its refused set, and gives its own reason. preflight_all refuses exactly the sessions it refuses today (a character with no record, a skipped one, or one with no lock), with the scene count it states today, and a character with a lock passes it exactly as today. Its sentence changes, so that each refused character is sent to the fix that works for its state: one with no record is told, as today, that it has not been built yet and to build it; a skipped one gets the no-character sentence; one with no lock that blocking_reason refuses (a sheet with an empty lock, or identity fields below the floor) gets the fill-in sentence, which names what is missing and the character panel; one with no lock that blocking_reason passes (identity fields at or above the floor, which no writer leaves today) is told that its description is there and its identity lock is not, and to save the description on the character panel, which writes the lock. No character that has a record is told to build it. The skipped sentence and the fill-in sentence come from one place: one private function in the runner answers, for a stored record, why it cannot be drawn into a scene or that it can, holding the two sentences that _require_cast_ready raises today (:2683-2688, :2698-2704), moved and not reworded. _require_cast_ready calls it, and so does preflight_all, which keeps its own lock test beside it for the one state the function passes. When characters in one refusal are in different states, each is named with its own reason. (4) The spend answer is pinned on the api side, where it is decided: beside the leave-alone test (test_video_storyboard_e2e.py:1890-1917), an unforced ensure_character on a primary whose record holds a sheet and an empty lock, on a member in the same state (through the member path, with a roster, and with that sheet assigned to the member in the pool), and on a skipped primary each returns the stored record and makes no write to any character record, no vision read, no design call and no render. The no-write assertion is required, because with the reading stored on the pool entry a re-save of the same sheet makes no vision call. (5) Web tests. isBuilt and "still to build" over one table of the seven states the record names (no record, skipped, sheet only, lock only, identity fields only, identity fields and an empty lock, fully ready). In CharacterPanel.test.tsx, a fixture with a sheet, an empty lock and no identity: on the primary and member routes it opens the editor and its chip is upright, while a member with no record keeps an italic chip. In VideoStoryboardScreen.test.tsx, a cast of three (a built primary, a member with a sheet and an empty lock, a member with no record): the button reads "Build 1 character", and pressing it calls videoStoryboardApi.buildCharacter once, unforced, for the member with no record; and two solo sessions, one whose primary has a sheet and an empty lock and one whose primary is skipped: the button reads "All characters built" and is disabled. (6) Api tests on the preflight, one for each refused state that has a record, one mixed, and the two that pass: a scene cast with a member whose record has a sheet and an empty lock is refused with the fill-in sentence naming them; a member with identity fields below the floor and no lock is refused with the fill-in sentence naming the empty fields; a scene that uses the character on a session whose primary is skipped is refused with the no-character sentence; a record with identity fields at or above the floor and an empty lock is still refused, with the sentence that sends the operator to save its description; a scene cast with two members in two of those states names each with its own reason; none of these sentences says the character has not been built; a member with no record keeps the not-built sentence; a legacy character with a lock only, and a full build, pass. (7) docs/ops/v46-acceptance.md: the spend answer with the path above; every writer of a character record and the state it leaves (_save_supplied, the render's two writes, the pickers, adoption, the text edit, the none-mode write); a state table with one row for each of seven states (no record; skipped; sheet only, a sheet with an empty lock and no identity field; lock only, a hand-written lock with no identity field, the legacy character; identity fields only, fields below the floor with no lock and no sheet; identity fields and an empty lock, fields at or above the floor; fully ready, a sheet with fields at or above the floor and the lock derived from them), which shows the two predicates side by side: the built rule (isBuilt and "still to build", which the chip, the count and the fan-out list use) and the frame gate's readiness (the preflight's pass or refusal and the sentence it gives after this card, with blocking_reason's answer beside it); beside them, the server's unforced answer to a Build press and the writer that leaves the state, or that none does; for every row where the built rule and the frame gate differ, the intended difference and why; and, for a character with a sheet and an empty lock, the chip, the count, the fan-out list and the preflight sentence before and after.
Interfaces. Exposes isBuilt and "still to build" from one web module, which the panel, the chip and the screen all read. The runner gains one private per-record function that both frame gates call for their sentences; the preflight keeps its own lock test beside it. Consumes nothing new: the unforced ensure on the server is unchanged, and is now pinned at the state that matters. No route, payload, schema or status changes; the preflight refuses the same sessions and changes only its sentence, for every refused character that has a record.
Acceptance. cd apps/web && npm test green, with the file and test counts recorded; npm run lint, npx tsc -p tsconfig.app.json --noEmit (not the bare npx tsc --noEmit, which checks no file, open item #100) and npm run build clean. pytest apps/api/app/backend/tests green apart from exactly the tests in apps/api/ci-known-failures.txt. Each of these fails on the base sources for its own reason: the upright chip (italic on the base); the cast of three (the base reads "Build 2 characters" and presses the member with a sheet too); the two solo sessions (the base reads "Build character" and is enabled); the preflight tests for the four refused states that have a record and the mixed refusal (the base tells every one of those characters it has not been built yet). The predicate table imports a module the base does not have, so it fails there by import; its bite on the rule is shown on a scratch copy with "still to build" keyed on identity_lock, where the sheet-with-an-empty-lock row fails, and recorded. The unforced-ensure pins pass on the base, because the server already leaves a stored character alone, which is the measured answer; their bite is shown on scratch copies and recorded: with the member's return at storyboard_image_runner.py:1265 keyed on identity_lock, the member case fails, and with the primary's at :1403 keyed on it, the primary case fails, each on a named assertion. V42's six route tests, the preflight's existing tests (test_video_storyboard_e2e.py:816-827, :2312-2336) and every existing _require_cast_ready test pass with their assertions unchanged. rg identity_lock apps/web/src/pages/video-storyboard/VideoStoryboardScreen.tsx and rg "unbuilt=\{!recordFor" apps/web/src/components/video-storyboard/CharacterPanel.tsx return nothing. Record docs/ops/v46-acceptance.md; open item #96 closed.
Out of scope. What counts as built: isBuilt's rule moves and does not change. What counts as ready: the preflight's refused set and blocking_reason do not change; only the preflight's sentences do. The unforced ensure on the server, which is already right and is only pinned. The forced Build and its price. The placeholder and the editor, and which view the guards choose. The per-scene gate's refusals and their words, which gain a second caller and nothing else. The claim guard, which is S36's. Saving to the library, which still asks for a lock (CharacterPanel.tsx:1526) and does not ask whether the character is built. Serving the built answer from the server instead: the rule has one home in the web, and that the server's unforced Build agrees with "still to build" is proven by the table and the pins. The button's wording on a session with no character, which now reads "All characters built" as every session with nothing outstanding does. What generate all does with a character that has a lock and too little description: the preflight passes it as today, each of its scenes is refused inside claim_all and dropped with only a server log (storyboard_image_runner.py:2966-2977), the route then answers that it could not reserve any scene when every scene was dropped (routes/video_storyboard.py:2843-2846), and the notice counts scenes_to_generate rather than scenes_claimed when some were (VideoStoryboardScreen.tsx:752-757); that is open item #103.
Waits on / Unblocks. Nothing: V42 (PR #101) and S36 (PR #103) are merged, S36's claim sits after the unforced return and does not change the answer, and no open card edits these files; S37 changes storyboard_llm.py, which this runner imports, so whichever of the two merges second re-runs the api tier on the first's merge / nothing is gated on it; afterwards the chip and the button stop offering a build to a character whose uploaded page could not be read, which opens in the editor, and the frame gate sends the operator to the fix that works for each character it refuses.
Built as (PR #112 → main 2087842, 2026-09-19). Code 1 review round → APPROVE. The built rule moves into one module the three readers share, apps/web/src/lib/characterBuilt.ts, which holds isBuilt at :12 with its comment and stillToBuild at :36; the cast chip, the Build button's count and the panel all read it, so a character with a sheet and an empty lock is built for all three. The frame gate keeps its own refused set and gives one actionable reason per refused character: _unready_refusal in storyboard_image_runner.py replaces the one sentence the whole board used to get, and the refused set itself does not move, which the tests pin on the base as well as on the branch. The record's seven-state table reads the two questions and the gate together, state by state, from no record and skipped through sheet only, lock only, fields below the floor, fields with an empty lock, and fully ready. Measured: an unforced press on a character that has a record spends nothing and writes nothing, because the button never forces and the server answers a stored record without a call; three pins hold it, and they pass on the base too. Evidence: the base run is 5 failed and 28 passed over the two component files, with the predicate table failing to collect, and the card adds 22 web and 11 api tests. On the tree rebased onto V44, which turns React's act environment on, the web suite is 123 files and 1085 tests, all passed, and the api tier 4452 passed with 11 skipped and 3 deselected. The rebase had one conflict, in showOn. The merge commit's CI and the staging deploy were both green. Record docs/ops/v46-acceptance.md; open item #96 closed. Three findings filed: open item #111 (the gate names an unnamed primary Primary), #112 (the web suite's timeouts under load) and #113 (the panel has no direct save for the fields the remedy names).
V47 · The heading read waits for the page it asserts · V · S · S2-W0 · fix/v/v47-heading-reads-its-own-page (surfaced on main after S37 merged on 2026-09-19, open item #116; carded 2026-09-20)
Context. The winners gallery's link leg clicks the chip and reads the destination's level-1 heading two lines later, with nothing between them that waits for the destination: e2e/winners.spec.ts:258 is await chip.click(), :259 is a toHaveURL on the creative's own address, built from productNumber and code, and :260 is await expect(page.getByRole('heading', { level: 1 })).toContainText(code). The address moves before the page does. The chip is a router Link (apps/web/src/components/creative-code/CodeChips.tsx:2 and :33), so the click calls history.pushState inside the handler while the tree on screen is still the gallery, and the shipped router then wraps its own state update in a transition: BrowserRouter in apps/web/node_modules/react-router/dist/production/chunk-2BDJPJTA.mjs:9997-10002 calls React.startTransition(() => setStateImpl(newState)) unless unstable_useTransitions === false, and apps/web/src/App.tsx:10 passes no such prop (react-router-dom 7.13.2, apps/web/package.json:25). Under a transition React keeps the current tree painted while it prepares the next one, so page.url() is the creative address while the DOM is still the gallery. The Winners gallery has two level-1 headings, not one. routes.tsx:157 puts production under AppLayout, AppLayout.tsx:9 renders the module rail on every page of that module and :10 its main, the rail's brand block has <h1>Production</h1> at Sidebar.tsx:116-118, and the winners page at routes.tsx:190 renders PageHeader (WinnersGallery.tsx:104 and :132) whose <h1> is PageHeader.tsx:13-15. So the rail's heading is under the gallery's own, and the pair is what an unscoped level-1 locator meets. The creative page is a shell route outside that layout (routes.tsx:147), and it carries one: CreativePage.tsx:203 in the loaded state, :117 in the ambiguous one, inside the single main at :137. ShellLayout.tsx:34-88 has no main and no level-1 heading. So an unscoped level-1 locator matches two elements for as long as the gallery is on screen and one element after the swap, and the leg reads it in the window where both are true of the same browser. It failed exactly that way in run 35468400541, attempt 1, job 105964849901, on merge commit 56175db: Error: strict mode violation: getByRole('heading', { level: 1 }) resolved to 2 elements, with Expected substring: "TMi900002-A", naming 1) <h1 ...>Production</h1> and 2) <h1 ...>Winners</h1>, at e2e/winners.spec.ts:260:59. Attempt 2 of the same run passed with nothing changed, which is why the run's own conclusion now reads success. The failure is not a budget that ran out. The assertion's budget is 10 s (e2e/playwright.config.ts:25) and the leg was reported failed after 2.6 s; the surrounding report lines put it between 20:55:08.70 and 20:55:12.07, and the stack's access log in the same job shows the gallery's own read (GET /api/winners 200) at 20:55:09,279 and the creative page's own first read (GET /api/creative-codes/442/TMi900002-A 200) at 20:55:11,440, after the assertion had already thrown. A strict-mode violation ends the assertion at once, so a longer timeout and a retry both buy nothing; the config sets retries: 0 and workers: 1 for the credential budget (playwright.config.ts:13-20) and this card changes neither. Two ways out exist and only one of them is a fix. Scoping the read to the destination's root does not work on its own: during the swap there is exactly one main on the page and it is the gallery's (AppLayout.tsx:10), while the creative page's own root is also a main (CreativePage.tsx:137), so a scoped read finds Winners, fails on the text, and passes only by retrying until the swap lands. That is a retry wearing a scope. Waiting on the destination's own root does work, because one thing only the creative page writes is already there: PageMeta sets document.title to <title> · <brand> in an effect (apps/web/src/components/ui/page-meta.tsx:7-15), and CreativePage renders it inside Page in every state (:138, reached from :86, :116, :146 and :200), so the title turns into <code> · <brand> after the creative route commits and its effect sets the title, and before its register read answers. The gallery writes Winners · <brand> from the same component (WinnersGallery.tsx:103 and :131). The spec already knows this move: creative.spec.ts:248 asserts that title straight after its goto, and this same leg asserts the gallery's at winners.spec.ts:220. The click leg is the one place that skipped it. Measured over the whole tree at 92a360f: e2e/ holds 8 spec files and 19 reads that ask for a heading by role. Fourteen name the heading they want, so they wait on the destination by construction (auth-pages.spec.ts:155, :215, :221, :270, :295, :312, creative.spec.ts:291, :298, :304, :371, admin.spec.ts:167, :169, ui-gallery.spec.ts:17, :32, the last two naming the level as well). One is already scoped to the page region and takes .first() (production-states.spec.ts:486, the region from pageRegion, the flag HIDDEN_TOO at :300). Four ask for a level-1 heading with no name and no scope: creative.spec.ts:251, :285, :355 and winners.spec.ts:260. Exactly one of the four follows an in-app navigation click, and it is the one that failed. The other three follow a page.goto (creative.spec.ts:246, :283, :352), which replaces the document, so the outgoing page is gone before they read and they can never match two; they are not this defect and this card leaves them alone. The markup is not the defect either. The leg runs axe over the gallery at winners.spec.ts:254 with the tags at :50, and it is clean over those two level-1 headings, so nothing here asks the pages to change. Same class as open items #94, #112 and #115: a read that runs before the state it asserts.
Read first. e2e/winners.spec.ts (whole file, and :214-261 closely: the leg, its title assertion at :220, its axe runs at :248 and :254, and the three lines at :258-260). e2e/creative.spec.ts:244-252 (the goto leg whose title assertion at :248 is the shape this card copies). e2e/production-states.spec.ts:296-302 and :480-492 (the scoped, .first() read that stays as it is). e2e/playwright.config.ts (whole file: :13-20 for the serial, retry-free rule and why, :25 for the assertion budget). e2e/README.md:1-60 (what each spec proves, the winners.spec.ts paragraph at :44-59) and :232-279 (Notes, the credential budget and what each spec leaves). e2e/package.json (the runner pin at :11, the two scripts at :6-7). apps/web/src/shell/routes.tsx:140-160 and :185-192 (the creative route outside the module layout, the production route and the winners route inside it). apps/web/src/components/layout/AppLayout.tsx (whole file) and apps/web/src/components/layout/Sidebar.tsx:107-125 (the rail's own level-1 heading). apps/web/src/components/layout/PageHeader.tsx (whole file). apps/web/src/pages/winners/WinnersGallery.tsx:100-140. apps/web/src/shell/pages/creatives/CreativePage.tsx:80-160 and :195-215 (the four Page wrappers, the single main, the skeleton with no heading, the loaded heading). apps/web/src/components/ui/page-meta.tsx (whole file). apps/web/src/App.tsx and apps/web/src/shell/ShellLayout.tsx:33-40. apps/web/src/components/creative-code/CodeChips.tsx:28-40. apps/web/node_modules/react-router/dist/production/chunk-2BDJPJTA.mjs:9990-10005 (the shipped BrowserRouter and its transition). .github/workflows/ci.yml:77-93 (the e2e filter key, with e2e/** at :79 and apps/web/** at :85) and :749-800 (the job that builds the SPA, brings the dev stack up and runs the chromium project). docs/ops/v10a-acceptance.md (the record that first wrote this leg). Open item #116.
Deliverables. (1) The winners leg waits on the destination's own root before it reads anything from it. Between winners.spec.ts:259 and :260 the leg asserts the creative page's document title with the same shape creative.spec.ts:248 uses, a regular expression anchored on the code followed by the separator, which only becomes true after the creative route commits and its effect sets the title, and which the gallery can never satisfy because its own title is Winners · <brand>. This is the wait, not a timeout and not a retry: the assertion that follows it cannot start against the outgoing page. (2) The same leg's heading read is scoped to the page's own root, page.getByRole('main').getByRole('heading', { level: 1 }), so the read means the creative page's own level-1 heading rather than the only level-1 heading in the browser, and a level-1 heading added later to the shell chrome cannot make it ambiguous again. The scope is the guard and the wait is the fix, and the card keeps both because each answers a different failure: the wait answers the race the run hit, the scope answers the next chrome that grows a heading. The read still retries while the skeleton is up (CreativePage.tsx:143-159 renders no heading), which is a wait on the page's content and not on which page is on screen. Nothing else in e2e/ changes. (3) One short comment at the winners leg states the rule in the page's own terms, with no card key, no item and no phase: a read that follows an in-app navigation waits on the destination's own title first and is then scoped to the destination's root, because the Winners gallery carries the module rail's level-1 heading beside its own and a read taken before the swap sees both. (4) e2e/README.md states that rule where the next spec author meets it, in the Notes section beside the serial-run and budget notes, in the same two clauses and with the Winners gallery named as the page that showed it. The rule is about in-app navigation only; a read after a page.goto meets a replaced document and is not covered. (5) docs/ops/v47-acceptance.md: the leg before and after, the run log's sentence verbatim with the two elements it named, the three proofs of the Acceptance paragraph under their own labels, and the two CI runs. The record files the page-side question as its own open item: whether the module rail should own a level-1 heading at all, given that the Winners gallery then has two.
Interfaces. Exposes nothing to other packages. It changes two lines and adds one in a single spec file, and adds one paragraph to e2e/README.md. It consumes what is already on main at 92a360f: PageMeta and its title format, the single main of CreativePage, the main of AppLayout, and the assertion budget and retry rule of playwright.config.ts. No application markup changes, no route changes, no config change, no new test file, and the credential budget the README counts is untouched because the leg signs in exactly as it does today.
Acceptance. Three proofs, each carrying what it actually shows. The structural precondition, proved with no browser and no stack, in apps/web under the web suite: a jsdom render of the winners page inside AppLayout counts 2 for getAllByRole('heading', { level: 1 }), and a render of CreativePage counts 1, which shows the locator is ambiguous on the outgoing page and unambiguous on the incoming one; recorded, never committed. The deterministic locator-ambiguity reproduction, on a scratch copy of the spec, recorded and never committed: with the base spec's heading read moved to the line before chip.click(), the leg fails with the same sentence, strict mode violation: getByRole('heading', { level: 1 }) resolved to 2 elements, naming Production and Winners. It reproduces the ambiguity on demand; it does not reproduce the race, because it never navigates. The race evidence is the CI log of run 35468400541 attempt 1, job 105964849901, quoted in the record with the failing leg's 2.6 s against the 10 s budget and the two api timestamps, together with the router mechanism that explains it: BrowserRouter wraps its listener update in startTransition unless unstable_useTransitions === false (chunk-2BDJPJTA.mjs:9997-10002), and App.tsx:10 does not disable it, so the address changes before the tree does. The record says plainly that no green run proves a race is gone; what the fix proves instead is that the read cannot begin before the destination's own title is set. The fix's own bite, on scratch copies: with the new title assertion pointed at a code the page never shows, the leg fails at that line inside its 10 s budget, which shows the wait is real and not decorative; with the scope removed and the wait kept, the leg passes, which shows the wait alone answers the failure and states honestly what the scope is for. npx playwright test --project=chromium green in the e2e job over all 114 legs, which is the count attempt 1 reported as 1 failed plus 113 passed. npm run lint and npx tsc -p tsconfig.app.json --noEmit clean in apps/web. The e2e job green on the pull request, which the e2e filter key selects because the change touches e2e/** (ci.yml:79), and green on the merge commit. gitleaks detect --no-git --source . --config infra/secret-scan/gitleaks.toml --redact clean. No comment the card adds names a card, an item or a phase. Record docs/ops/v47-acceptance.md; open item #116 closed.
Out of scope. The three other unscoped level-1 reads, creative.spec.ts:251, :285 and :355. Each follows a page.goto (:246, :283, :352), which replaces the document, so the outgoing page is gone before the read and none of them can match two elements; they are not this defect, and scoping them would be an unrelated sweep riding on a one-line fix. production-states.spec.ts:486, which reads a level-1 heading too but is already scoped to the page region and takes .first() on purpose: it is a parameterised drive over the whole Production route list that asserts only that a page which reads nothing on arrival still owes a heading, and it follows a page.goto. The markup itself: the rail's level-1 heading (Sidebar.tsx:116) beside the gallery's own (PageHeader.tsx:13). The axe run at winners.spec.ts:254 with the tags at :50 is clean over it, so it is not an accessibility violation under those tags, and this card changes no page; the record files it as an open item. A PageMeta test: page-meta.test.tsx:7-24 already covers set, change and restore, and the executable coverage of the property this card depends on is the title assertion in the leg itself. A shared navigation helper across the e2e specs, which would be a new interface for one line. Raising expect.timeout or turning retries on, which playwright.config.ts:13-20 forbids for the credential budget and which would not have saved this assertion anyway. The disposable stack per spec file and the credential budget (open item #59), the load-sensitivity items (#112 and #115), and the act-environment item (#94).
Waits on / Unblocks. Waits on nothing: the read is on main at 92a360f, the pages it reads are unchanged by this card, and the leg needs only the dev stack the e2e job already brings up. Its shared surface with any other open V card is e2e/, so if a second card lands in that tree, whichever merges second re-runs the e2e job on the first one's merge commit before it is called green / it takes the one known reddener off main's e2e job, and it sets the rule every later leg follows when it clicks an in-app link: wait on the destination's own title, then read inside the destination's own root.
Built as (PR #119 → main 233f6e1, 2026-09-20). Two lines changed and one was added in e2e/winners.spec.ts, and no application markup, route or config moved. The winners leg now waits on the destination's own document title before it reads anything, and the heading read is scoped to the page's own main. The wait is what answers the race: the chip is a router link, the shipped router wraps its state update in a transition, so the address changes while the gallery tree is still painted, and the gallery carries two level-1 headings, the module rail's and its own. Three scratch copies were run against a real stack and deleted in the same step. The read moved above the click on the base fails with the same strict-mode sentence the CI run printed, naming the same two headings and the same two class strings. The shipped title assertion pointed at a code the page never shows fails at that line inside its 10 s budget, and the title the browser held when it gave up was the destination's own. The wait with the scope removed passes, which says plainly that the scope guards against a level-1 heading added later to the chrome and is not the thing that fixes this defect. A structural precondition was proved in jsdom with no browser and no stack: the gallery renders two level-1 headings and the creative page one, inside its single main. The failure was never a budget that ran out, because a strict-mode violation ends an assertion at once and the config sets no retries. The branch spec itself passed 4 of 4 in 59.0 s, the spec's type check and its leg count were unchanged, and the secret scan was clean. Record docs/ops/v47-acceptance.md; open item #116 closed. Filed as open item #119: the documented dev-stack command writes generated secrets into a git-ignored file that the mandatory tree scan still reads, so a builder who brings the stack up sees six findings that belong to that file alone.
V48 · Every box that scrolls sideways is classified, and the ones that always scroll are proven · V · M · S2-W0 · test/v/v48-horizontal-scroll-family (surfaced while carding open item #102 on 2026-09-19, pre-existing on main, open item #105; carded 2026-09-20)
Context. apps/web/src/test/scroll-regions.test.ts holds a box to a reviewed inventory only when its class string carries a scroll utility and a height ceiling together (:82), so a box that scrolls sideways with no ceiling of its own is in no inventory, takes no tab stop, and leaves the text past its right edge to the pointer. Re-measured at main 92a360f with the sweep's own rules over the 151 non-test .tsx files under apps/web/src: 46 elements carry a scroll utility; 25 carry a class ceiling and are V39's inventory (:112-138, the count at :155); 21 carry no class ceiling; 2 of those 21 carry only a min-h floor (components/ui/sheet.tsx:68, pages/products/ProductMemory.tsx:122), so 19 carry no height utility, which is the number V39 recorded (docs/ops/v39-acceptance.md:418-436); and 14 of the 21 carry overflow-x-auto and nothing else. The 14 are the item's 14, at the same lines; the only lines that moved anywhere in the sweep since 16dd547 are the storyboard pair, now pages/video-storyboard/VideoStoryboardScreen.tsx:925 and :986 (S36, d1fd3c9), which is not in this family. Two boxes that scroll sideways do carry a class ceiling and are already named regions (pages/flowkit-test/FlowKitTestPage.tsx:285, pages/intelligence/BriefViewer.tsx:244). Axe's scrollable-region-focusable, tagged wcag2a, reports a box that scrolls and holds nothing focusable, and Chromium focusing a bare scroller by itself does not answer it (docs/ops/v39-acceptance.md:366-371). Classified by what they hold, the 14 fall into four groups. Six hold text alone and carry the defect the moment they scroll: the code block and the markdown table in the shared renderer (components/ui/markdown-content.tsx:81 and :108), the same two inlined a second time in the session page (pages/products/SessionView.tsx:2247 and :2265), the recreate-runs table (pages/scraped-ads/CollectionsView.tsx:113), and the runs table (pages/scraped-ads/RunsView.tsx:106), whose only control is Cancel and only while a run is active (:198-204), so a page of finished runs is text alone. Five hold a control in every row: pages/admin/CreativeInsights.tsx:254 (the product button, :290, :300), pages/scraped-ads/BrandsView.tsx:126 (Add mapping, :226), pages/scraped-ads/CollectionsView.tsx:76 (Recreate, :95), pages/scraped-ads/DiscoveryView.tsx:243 (the Ads Library link, :286-287) and pages/system/Operations.tsx:234 (the toggle at :263-265 and Delete at :276). One of those five is not sound as written: CollectionsView.tsx:95 disables Recreate on an empty collection, and a disabled control takes no focus, so a list where every collection is empty holds no tab stop at all, the case the ControlHint shape exists for (components/ui/control-hint.tsx:30-36). Two scroll by construction and both hold tab stops. The Instructions tab strip (pages/system/Instructions.tsx:228) is a w-max list of eight fixed labels (:19-28, :232) inside a strip capped at w-[70%] (:227), which the page watches for overflow, marks with chevron buttons (:100-102, :247, :260) and keeps on the active tab (:137). The storyboard version strip (components/video-storyboard/FrameSlots.tsx:430) is a row of w-20 shrink-0 tiles (:449) with gap-2 inside p-2, capped at aspect.gridMaxWidth (:425; 33, 44 or 58 rem at lib/videoStoryboardApi.ts:1127-1129), so the cap shows 5, 7 or 10 versions before it scrolls, and each tile is its own button or, while the button is off, a ControlHint tab stop (:459). The fourteenth is the house Table wrapper (components/ui/table.tsx:13), which cannot be classified once, because what it holds is its caller's. Its six callers, read at 92a360f: shell/pages/status/StatusPage.tsx:318, the history grid, which renders one column per cycle, 288 at the widest window (shell/pages/status/statusView.ts:191), whose px-2 padding alone (:323) is 4,608 px, whose own comment says it scrolls sideways (:316-317), and whose every column head is a <time> hint trigger with a tab stop (:328, components/ui/hint-target.tsx:22-27); StatusPage.tsx:265, a link button per row (:282-284); shell/pages/admin/BindingsPage.tsx:284, a model Select per row (:109); shell/pages/DevGallery.tsx:143, a button in one row of two (:152-156); shell/pages/admin/SpendPage.tsx:259, whose row carries a HintTarget only where the row has a tooltip (:271-273) and is text alone otherwise; and shell/pages/creatives/CreativePage.tsx:505, a nine-column snapshot table whose every cell is text and which holds no control at all (:519-529). So the wrapper covers a box that always holds tab stops and a box that never does, and the item's reading of the other five as boxes that "only could" scroll misses that two of them hold text alone whenever they do. The house already answers a shared wrapper whose content is its caller's: SheetBody takes a required prop with no default that every caller fills (components/ui/sheet.tsx:9-22, filled at components/creative-code/LineageSheet.tsx:27, StatusPage.tsx:133, DevGallery.tsx:177). The named region is ScrollRegion (components/ui/scroll-region.tsx:78-93), which renders a div or a pre (:18), requires exactly one of aria-label and aria-labelledby (:10-12), and owns the role, the tab stop and the ring; its own doc says it belongs on a box with a height ceiling (:73-77), which is the sentence this family widens, and it names scroll-regions.test.ts as the place every scroll box is listed (:76-77). Naming is the work in the shared renderer: MarkdownContent takes only content and has five call sites (pages/intelligence/BriefViewer.tsx:175, pages/products/SessionView.tsx:1404, :1418, :2060, :2104), and the session page keeps a second copy of the same renderer (SessionView.tsx:2226) reached through ConceptsContent (:2047) and ReviewContent (:2093), so a code block's name can only come from what the caller is showing. What the repo already has for a browser check: e2e/ holds Playwright 1.58.1 and @axe-core/playwright 4.13.0 (e2e/package.json), and playwright.config.ts has a gallery project (testMatch: '**/ui-gallery.spec.ts') that drives a vite preview of a build made with VITE_UI_GALLERY=1, with no login and no api (e2e/ui-gallery.spec.ts:9-10), at Playwright's Desktop Chrome viewport of 1280 by 720. CI's web job, filtered on apps/web/** and e2e/** (.github/workflows/ci.yml:92-99), builds the gallery, serves it and runs npm run test:gallery (:253-307, the script at e2e/package.json:7), so a leg in that spec is a browser check on every web pull request with no stack. The gallery already renders a house Table (DevGallery.tsx:141-167), and its whole-page axe leg runs with the wcag2a tag (ui-gallery.spec.ts:20). For the page-level boxes, V39's re-check drove the branch's build under vite preview with every api call answered by page.route and no stack at all (docs/ops/v39-acceptance.md:378-386), which is the method this card reuses.
Read first. apps/web/src/test/scroll-regions.test.ts (whole file, and V45's second inventory as that card leaves it) with apps/web/eslint-rules/control-set.js. apps/web/src/components/ui/scroll-region.tsx (whole file: the name type at :10-12, the tags at :18, and the doc at :45-77). apps/web/src/components/ui/sheet.tsx:9-22 (the required-prop shape this card copies for Table). apps/web/src/components/ui/table.tsx (whole file) and its six callers: apps/web/src/shell/pages/status/StatusPage.tsx:261-345 with shell/pages/status/statusView.ts:189-200, shell/pages/admin/BindingsPage.tsx:65-130 and :280-300, shell/pages/admin/SpendPage.tsx:255-282, shell/pages/creatives/CreativePage.tsx:500-535, shell/pages/DevGallery.tsx:141-181. apps/web/src/components/ui/markdown-content.tsx (whole file) and apps/web/src/pages/products/SessionView.tsx:2040-2300, with the five MarkdownContent call sites. apps/web/src/pages/scraped-ads/RunsView.tsx:100-210, CollectionsView.tsx:70-150, BrandsView.tsx:120-235, DiscoveryView.tsx:240-295, apps/web/src/pages/system/Operations.tsx:230-285, apps/web/src/pages/admin/CreativeInsights.tsx:250-305. apps/web/src/pages/system/Instructions.tsx:95-145 and :220-270. apps/web/src/components/video-storyboard/FrameSlots.tsx:415-470 with apps/web/src/lib/videoStoryboardApi.ts:1120-1132. apps/web/src/components/ui/hint-target.tsx and control-hint.tsx (both whole). e2e/ui-gallery.spec.ts (whole file), e2e/playwright.config.ts, e2e/README.md from "Two suites, two servers", and .github/workflows/ci.yml:92-99 and :253-307. docs/ops/v39-acceptance.md:305-410 (the browser check, what Chromium does by itself, the re-check method) and :418-441 (the family V39 measured and left). Open items #105 and #102, and V45's card.
Deliverables. (1) A third reviewed inventory in scroll-regions.test.ts, for every element whose only scroll utility is on the horizontal axis and which carries no height utility of its own: 14 entries today, in source order per file, each with the file, the element and the box as the class string writes it, exactly as the other two inventories are keyed. These 14 leave the second inventory V45 creates, which then holds 5 (AppLayout.tsx:10, sheet.tsx:68, SystemDocuments.tsx:374, ProductDocuments.tsx:677, ProductMemory.tsx:122), and the sweep's totals become 27 with a ceiling, 5 bounded or growing, 14 horizontal, 46 in all. A new sideways box anywhere in the tree fails the sweep until somebody classifies it. (2) Four classifications for the horizontal inventory, one per entry, and no entry without one. a named region says what text the box holds and requires the element to be a ScrollRegion, checked the way the ceiling inventory already checks it (scroll-regions.test.ts:171-179). holds a control in every row names the control and states the condition that makes it true for every row, including the empty and the failed state of the list. holds a control on every tile is the same for a strip. its caller says what it holds is for a shared wrapper whose required prop has no default, and the entry names the prop. (3) The six text-alone boxes become named regions. The shared renderer's code block renders as ScrollRegion render="pre" and its markdown table as a ScrollRegion, each named from a required name prop the renderer takes and every caller fills, so a region is named for the document it shows and not for the fact that it is a box; the session page's second copy of the renderer takes the same prop from ConceptsContent, ReviewContent and its own three call sites. CollectionsView.tsx:113 and RunsView.tsx:106 become named regions, because the recreate-runs table holds no control at all and the runs table holds one only while a run is active. (4) The house Table takes a required prop with no default, in SheetBody's shape, saying what its rows hold: either the control every row carries, or a name, which makes the wrapper a named region for that caller. The six callers fill it from the reading above: the history grid and the checks table name their per-row and per-column tab stops, the bindings table names its model picker, the gallery fixture names its button, and the spend table and the creative snapshot table take names, because a spend row with no tooltip and every snapshot cell are text alone. The wrapper's own entry is its caller says what it holds. (5) CollectionsView.tsx:76 is fixed, not reclassified: an empty collection's Recreate is off, so the row takes the ControlHint shape that gives an off control a tab stop and says why it is off, the way the storyboard tiles already do (FrameSlots.tsx:459). Its entry then states that every row holds a tab stop in both states. (6) The rule is written where the next author meets it: the sweep's header states the three inventories and that a box scrolling on either axis is held, and scroll-region.tsx's doc stops saying the region belongs only on a box with a height ceiling and says instead that it belongs on any box that scrolls on either axis over text or a picture. No comment names a card, an item, a phase or a wave. (7) A committed browser check where no stack is needed: the gallery gets a house Table wide enough to scroll at 1280 px in both shapes, one whose rows hold a control and one named, and a shared-renderer block with a code line and a markdown table wider than the page. ui-gallery.spec.ts gets a leg that measures each box scrolls (scrollWidth above clientWidth) before it runs axe with the spec's tags, finds no violation, and finds each named box by its role and name with tabindex="0". The measurement comes first, so the leg cannot pass on a box that stopped scrolling. (8) A browser check run once and recorded, never a per-push test, for the three boxes that scroll by construction, in V39's re-check method: the branch's build under vite preview, every api call answered by page.route, no stack, at 1280 by 720. The Instructions strip with its eight tabs, the Status history grid at 288 cycles, and the storyboard version strip with more versions than its cap shows at each of the three aspect ratios, each measured scrolling sideways with axe clean and its tab stops counted. If a box scrolls where its entry says it holds controls, or holds only text where its entry says controls, the page is fixed and gets a test in its own file that fails on the base page; the entry is never rewritten to make the check pass. (9) docs/ops/v48-acceptance.md: the counts before and after, the 14 entries with their classification and the evidence for each, both browser checks with the measured widths and the axe verdict, and the bite proofs.
Interfaces. Exposes the horizontal inventory in scroll-regions.test.ts, which every later sideways box joins; the house Table's required content prop, which every later table fills at its call site; and the shared renderer's required name prop, which every later caller of it fills. Also exposes a gallery leg that proves a sideways box scrolls and passes axe, which every later wide component copies. Consumes ScrollRegion, ControlHint, HintTarget, the control set, the gallery build with its CI step, and @axe-core/playwright, all of which are on main at 92a360f, and the second inventory as V45 leaves it.
Acceptance. npm test in apps/web green, with the sweep passing over the real tree at 14 entries in the horizontal inventory, 5 in the second and 27 with a ceiling, 46 in all. Run with no horizontal inventory, the sweep fails and names all 14 by file and line, which shows it reads the tree and not a list. Bite proofs on scratch copies of the tree, recorded and never committed, each failing on the branch and passing on the base: a new <div className="overflow-x-auto"> holding text; a new <pre className="overflow-x-auto"> holding text; a new <Table> call site with no content prop; a new caller of the shared renderer with no name. On the branch alone, removing any one entry's classification fails, and giving a named region entry an element that is not a ScrollRegion fails. The targeted page tests still pass: RunsView.test.tsx, CollectionsView.test.tsx, BrandsView.test.tsx, DiscoveryView.test.tsx, StatusPage.test.tsx, BindingsPage.test.tsx, SpendPage.test.tsx, SessionView.test.tsx, IntelView.test.tsx and gallery-route.test.tsx, and a new test on the collections list proves the empty-collection row holds a tab stop and fails on the base page. npm run test:gallery in e2e green against a preview of the branch's gallery build; the new leg fails on two scratch copies, once with the named box declared as holding controls, where axe reports scrollable-region-focusable, and once with its content narrowed to fit, where the width measurement fails before axe runs. The existing whole-page axe leg, the sheet focus leg and the state captures still pass. The one-off page check is in the record as deliverable (8) states it. npm run lint, npm run typecheck, npm run build and VITE_UI_GALLERY=1 npm run build clean. The web job green on the pull request. No comment the card adds names a card, an item, a phase or a wave. Record docs/ops/v48-acceptance.md; open item #105 closed.
Out of scope. The 25 boxes with a class ceiling, which V39 classified and V45 recounts, and the 5 bounded or growing boxes, which are V45's second inventory: this card moves the 14 out of that inventory and changes nothing else in it. The two sideways boxes that already carry a ceiling and are already named regions (FlowKitTestPage.tsx:285, BriefViewer.tsx:244). An eslint rule, for the reason V39 recorded: whether a box is sound depends on what it renders. A crawl of every route for scroll boxes, and new legs in the stack-driven chromium project. Rewriting the session page's second copy of the shared renderer into one renderer, which is its own card: this card gives both copies the same name prop and leaves the duplication where it is. Card keys in comments (open item #99).
Waits on / Unblocks. V45: both cards edit scroll-regions.test.ts, and this card's 14 entries leave the second inventory that V45 creates, so this is cut from main after V45 merges and its counts are read from V45's merge commit rather than from 92a360f; both also add a gallery fixture and a leg to DevGallery.tsx and ui-gallery.spec.ts, and no other open card touches those four files / V22 and V15, whose native Intelligence pages add wide tables that the sweep then holds from their first commit, and every later table, which fills the wrapper's content prop at its call site instead of shipping an unclassified sideways box.
Built as (PR #133 → main cd8022a, 2026-09-20). The sweep gains a third reviewed inventory, for every box whose only scroll is sideways and which carries no height of its own, so the three inventories are disjoint by construction and their lengths add up to what the sweep found. A height is now read as a ceiling or a floor, in a class or in a style, and a scroll declaration is read for its axis, so one declaration on the other axis takes a box out of the family. The fourteen boxes the card named are classified one by one under four headings: a named region, holds a control in every row, holds a control on every tile, and its caller says what it holds. Six of them held text alone and are named regions now. The house Table takes a required rows prop with no default, so every caller says whether its rows carry controls or whether the box needs a name, and the shared markdown renderer takes a required name, so a code block or a table inside a document is named for the document and its place in it and no two boxes answer to one name. One row is fixed rather than reclassified: an empty collection switched its only control off, so a list of empty collections held no tab stop at all, and the row now wears the shared hint shape that keeps exactly one stop in both states. The review added a third reading, a class reader that follows a name to what it is declared as, one hop through an import included, and fails closed on what it cannot follow; it resolves all but 23 of the 4,551 class attributes and all 57 spreads, and the 22 sources it cannot follow are colour tables, variant maps and size props, none of which writes an overflow or a height. Six new tests fail on the base with their own sentences, and two more assert an absence on both sides so a later change cannot answer the six by giving every box a tab stop. Two compiler bites prove both required props. A new gallery leg measures four sideways boxes scrolling before it runs axe, and the gallery suite passed 6 of 6. A one-off browser run measured the three boxes that scroll by construction rather than on a long token. Record docs/ops/v48-acceptance.md; open item #105 closed. Two findings filed: open item #135, a 21 by 21 pixel control against the standard's 24 by 24, on every tile that draws it, and open item #136, the session page's own copy of the shared markdown renderer.
V49 · A Select is controlled from its first render · V · S · S2-W0 · fix/v/v49-select-controlled-from-first-render (surfaced while building V44 on 2026-09-19, pre-existing on main, open item #110; carded 2026-09-20)
Context. Base UI decides on a component's first render whether its value is controlled and never revisits it: @base-ui/utils/esm/useControlled.js:13-15 reads controlled !== undefined into a ref, and :17 then returns the internal state for the whole life of the component when that ref is false, so a value prop that arrives later is read by nothing. :19-22 logs "A component is changing the uncontrolled value state of Select to be controlled" from an effect keyed on the controlled value, so it prints once per change while the mismatch stands. The Select passes its value straight in as controlled, with null as the uncontrolled default (@base-ui/react/esm/select/root/SelectRoot.js:41-42, :84-89); the packages are @base-ui/react 1.3.0 and @base-ui/utils 0.2.6, and apps/web/src/components/ui/select.tsx:7 is SelectPrimitive.Root under another name. Four Selects in the tree start undefined: the product picker and the target-locale picker in the shared mapping fields (apps/web/src/pages/scraped-ads/MappingFields.tsx:52, value={productId === '' ? undefined : String(productId)}, and :111, value={targetLocale || undefined}), and the same two again in the custom expand form (apps/web/src/pages/scraped-ads/ScrapedAdsList.tsx:1638 and :1660). Measured at main 92a360f, those four are the whole family: 47 Select roots stand in the non-test .tsx files, all 47 pass a value, and only these four can pass undefined. The defect is larger than the log line. Because the value prop is never read, a value set from anywhere but the popup never reaches Base UI's store, and the store treats a missing value as no selection (@base-ui/react/esm/select/store.js:14-27: value == null is false, and a value that stringifies to the empty string is false too). So the trigger keeps its placeholder state (select/trigger/SelectTrigger.js:227) and the house trigger paints a placeholder muted (select.tsx:42, data-placeholder:text-muted-foreground); the popup puts its check mark on nothing (select.tsx:126-132) and opens with nothing highlighted; and a reset cannot clear a pick. Each of those has a live site. The custom expand form sets its product and its target locale in the render that brings the brand mapping (ScrapedAdsList.tsx:1626-1631), which is exactly the outside-set value the Select ignores, so a pre-filled product shows as muted placeholder text with no check mark beside it in the popup. RunScrapeForm's resetForm puts the mapping fields back to empty (RunScrapeForm.tsx:133, :141, with EMPTY_MAPPING at :41-45) while the dialog stays mounted, so the Select keeps the operator's last pick in its own store. The shared fields are rendered by RunScrapeForm.tsx:840 and by the brand mapping dialog (BrandsView.tsx:491-493, its state at :425), so both forms carry it. The house already writes the fix twice. The two source-locale Selects stay controlled through a sentinel with an option of its own (MappingFields.tsx:79, ScrapedAdsList.tsx:1471, AUTO_DETECT_SENTINEL at constants.ts:5), and the bindings page passes pending ?? current?.id ?? '' (shell/pages/admin/BindingsPage.tsx:109), which is controlled from the first render and still shows its placeholder, because the store reads the empty string as no selection by the same rule. V44 left the message on purpose and said so (docs/ops/v44-acceptance.md:557-559): the guard in front of console.error fails a test only for the three act messages (apps/web/src/test/console-errors.ts:8-12, :50-56) and forwards everything else to the console (:59), and setup.ts:47 installs it. So nothing fails today; V44's whole-suite runs measured the message six times, in two RunScrapeForm.test.tsx tests and two ScrapedAdsList.test.tsx tests, and recorded it as the suite's only console output (v44-acceptance.md:344, :388, :535). The four tests are the two that fill the mapping fields (RunScrapeForm.test.tsx:111 and :139, through fillMappingFields at :47-53, which picks a product and then a target locale, so two messages each) and the expand-form tests that let the mapping arrive (ScrapedAdsList.test.tsx:213-237). The house also already writes the assertion this card needs: components/ui/route-error.test.tsx:38 takes a bare vi.spyOn(console, 'error'), which calls through the guard rather than replacing it (console-errors.ts:62-71), and :49-50 reads its calls and requires that no message starts with Base UI:. A bare spy with no implementation of its own is the one shape src/test/console-mutes.test.ts allows.
Read first. apps/web/src/pages/scraped-ads/MappingFields.tsx (whole file), mappingRules.ts and constants.ts:5. apps/web/src/pages/scraped-ads/ScrapedAdsList.tsx:1460-1500 (the source-locale sentinel) and :1610-1695 (CustomExpandForm, its render-phase defaults and its two Selects). apps/web/src/pages/scraped-ads/RunScrapeForm.tsx:41-45, :88-95, :133-150 and :810-850. apps/web/src/pages/scraped-ads/BrandsView.tsx:420-500. apps/web/src/components/ui/select.tsx (whole file: the root at :7, the trigger's placeholder class at :42, the item's check mark at :109-135). apps/web/src/shell/pages/admin/BindingsPage.tsx:100-130 (the empty-string shape already in the tree). apps/web/node_modules/@base-ui/utils/esm/useControlled.js (whole file), @base-ui/react/esm/select/root/SelectRoot.js:38-95, select/store.js:14-40, select/trigger/SelectTrigger.js:75-115 and :220-232, select/value/SelectValue.js:30-50. apps/web/src/pages/scraped-ads/RunScrapeForm.test.tsx:28-60, :111-137 and :139-152; ScrapedAdsList.test.tsx:205-237. apps/web/src/components/ui/route-error.test.tsx:36-52 (the assertion shape), apps/web/src/test/console-errors.ts (whole file), src/test/setup.ts and src/test/console-mutes.test.ts. docs/ops/v44-acceptance.md:340-346, :384-390, :530-536 and :555-561. Open items #110 and #94.
Deliverables. (1) The four Selects are controlled from their first render and stay controlled: the value is never undefined, in any state of the form, and it is the empty-string sentinel the store already reads as no selection, so the placeholder, the placeholder styling and the check mark all follow the page's own state from the start. One shape across all four, matching what the bindings page and the two source-locale Selects already do, rather than a per-site variant. A keyed remount is not used: it throws away the popup's own state on every pre-fill and hides the state-ownership question rather than answering it. (2) The pages' own SelectValue children and their placeholders are unchanged in what they say; what changes is that the trigger's placeholder state and the popup's selection now agree with them. (3) The reset path clears the picker: after resetForm the product and target-locale Selects show their placeholders, in the trigger's placeholder styling, with no check mark in the popup, because the store now takes the page's value. (4) A comment at the shared mapping fields, in plain words with no card key, item number, phase or wave, saying that these values never go to undefined and why: the picker reads its value only on the first render, so a value that starts absent is never read again. (5) A source sweep in the web suite, in the shape of the ones already there, that reads every non-test .tsx under src and fails on a value prop passed to a Select whose expression can be the literal undefined, by file and line. It passes over the tree at zero hits on the branch and names all four sites on the base, so the defect cannot come back quietly in the next form. (6) Tests that hold the console clean where it was dirty: RunScrapeForm.test.tsx and ScrapedAdsList.test.tsx each read a bare console.error spy that calls through the guard and require that no message begins with Base UI:, in route-error.test.tsx:49-50's shape, in the tests that fill or pre-fill the fields. (7) A test that the pre-fill is a real selection and not only a label: with the brand mapping answered, the custom expand form's product popup has its check mark on the mapped product and its trigger is not in the placeholder state, and a test that the reset takes the pick away in RunScrapeForm. Both fail on the base sources. (8) docs/ops/v49-acceptance.md: the four sites before and after, the sweep's counts on both trees, the console assertions with the message counts each test file produced before and after, and the visible symptoms with what the browser or the rendered markup showed.
Interfaces. Exposes nothing new to other packages. Exposes inside the web app the sweep that keeps a Select controlled from its first render, which every later form is held to, and the empty-string sentinel as the one shape for a picker with nothing picked. Consumes select.tsx, AUTO_DETECT_SENTINEL, the console guard and muteConsoleError as V44 left them on main at 7083fd5, and MappingFieldsValue and its rules unchanged: no prop type, no api call and no submitted value changes.
Acceptance. npm test in apps/web green. The two test files run green with the new console assertions, and each fails on the base sources naming the Base UI: messages it caught: six across the four tests, as V44's runs recorded. The new sweep passes over the branch at zero hits and, run against the base sources, fails naming MappingFields.tsx:52, :111 and ScrapedAdsList.tsx:1638, :1660, which shows it reads the tree and not a list; a scratch copy with a fifth Select passing undefined fails it. The pre-fill test and the reset test each fail on the base and pass on the branch. BrandsView.test.tsx, RunsView.test.tsx and the rest of the scraped-ads tests still pass, and the whole suite prints no Base UI: message at all, which V44's records make the before number to compare against. npm run lint, npm run typecheck, npm run build clean, and gitleaks detect --no-git --source . --config infra/secret-scan/gitleaks.toml --redact clean. No comment the card adds names a card, an item, a phase or a wave. Record docs/ops/v49-acceptance.md; open item #110 closed.
Out of scope. The 43 Select roots that already pass a value from their first render, and the source-locale sentinel, which is correct as written. Making the console guard fail a test on every Base UI message: V44's runs record this Select warning as the suite's only console output, so that change is available and cheap, but it would put every library message the suite has never measured on this card's acceptance; the record files it as its own open item with V44's measurement as the evidence. Any change to what the two forms submit, to mappingRules.ts, or to the api calls behind them. The act environment and the mute rules (V44, open item #94). Card keys in comments (open item #99).
Waits on / Unblocks. Nothing: V44 is on main at 7083fd5 with the guard, the helper and the mute check, and no open card edits MappingFields.tsx, ScrapedAdsList.tsx, RunScrapeForm.tsx or the two test files / every later form in the web app, which takes the sweep and the one sentinel shape from here, and the intelligence and studio pages of S2 and S3, whose pickers are pre-filled from loaded data in the same way the custom expand form is.
Built as (PR #126 → main bd34889, 2026-09-20). The four pickers that could pass undefined now pass the page's own value from their first render, the product id as a string and the locale as it stands, both of which are the empty string when nothing is picked. The picker library decides on the first render whether a value is controlled and never revisits it, so a value set afterwards was read by nothing: the trigger stayed in its placeholder state, the popup's check mark landed on no item, and a reset could not take a pick away. Nothing else moved, and no api call, submitted value or placeholder changes. A sweep in the web suite builds a program from the configuration the type check itself runs, finds every picker root in the tree and reports one that passes no value, one that sits beside a spread it cannot follow, and one whose value has a type that admits undefined. What it reads is the type and not the shape of the expression, because a value moved into a name, taken from a prop or returned by a function is the same defect written differently and only the compiler can answer that; assertions are taken off before the type is asked for, so an assertion cannot answer for the code. The sweep reports 4 on the base and 0 on the branch over 47 picker roots, and two scratch pickers, one passing the literal and one passing a name of that type, are each reported. A fixture of twenty pickers and two elements that are not pickers pins what it reports and what it leaves alone, and the test asserts the line of every picker it read. The two test files that were printing the library's warning now read the console and require silence, and the twelve files run for this card print none. Measured before and after: the pre-fill is a real selection rather than a label, and a reset reads the placeholder where it used to read the raw product id. One departure is recorded: the reset is proved on the shared fields and not through the dialog, because that dialog's popup is unmounted while it is closed, so a test written there passes on the base and proves nothing. Record docs/ops/v49-acceptance.md; open item #110 closed.
V50 · The web job type-checks once, and its compiler cache is a cache · V · S · S2-W0 · chore/v/v50-one-type-check (surfaced while building V43 on 2026-09-19, pre-existing on main, open item #109; carded 2026-09-20)
Context. The web job runs the TypeScript compiler over the whole app three times per run, and each of the three pays full price. The three are the type-check step (npm run typecheck, .github/workflows/ci.yml:273-274), the build step (npm run build, :275-276, whose script is npm run typecheck && vite build, apps/web/package.json:9) and the gallery build (VITE_UI_GALLERY=1 npm run build, :283-284); typecheck is tsc -b (package.json:8). Measured at 92a360f from the job logs of two merge-commit runs, by the step group timestamps and by vite's own "built in" line inside each build step. On run 35471866195 (merge 2087842): type-check 16.49 s, build 18.36 s of which the compiler is 16.98 s and vite 1.33 s, gallery build 17.72 s of which the compiler is 16.40 s and vite 1.28 s. On run 35470865282 (merge 7083fd5): 14.91 s, 16.57 s of which the compiler is 15.32 s and vite 1.20 s, and a gallery build whose compiler part is 15.69 s with vite at 1.21 s. So one check costs 14.9 s to 17.0 s on the runner, the compiler is about 92 % of every one of the three steps, and the two repeated checks cost 31.0 s to 33.4 s per web run against job totals of 342 s and 373 s. Why the repeats pay full price is a configuration fact, not a machine fact. Neither web project sets incremental or composite, although each names a tsBuildInfoFile at line 3 (apps/web/tsconfig.app.json:3, tsconfig.node.json:3), so build mode writes a marker rather than a cache: on this tree node_modules/.tmp/tsconfig.node.tsbuildinfo is 76 bytes and holds only a root file list and the compiler version, with no record of any file. npx tsc -b --dry --verbose at 92a360f calls tsconfig.app.json out of date because its buildinfo is older than an input, and calls tsconfig.node.json out of date because the output file vite.config.js does not exist, although both projects set noEmit: true. Build mode is looking for emitted JavaScript that this configuration never writes, so neither project can be up to date at any moment, on any box. The fix and its safety were both proved in a scratch reproduction of the same two-project shape, driven by the tree's own TypeScript 5.9.3 (apps/web/node_modules/.bin/tsc). With incremental: true added to both projects, which is the confirmed fix, the buildinfo becomes a compiler-tracked, Git-ignored output rather than a marker, and starts carrying file records, 14,243 and 14,242 bytes against 42 and 40 bytes without it; tsc -b --dry --verbose then reports both projects up to date; a second tsc -b costs 0.42 s against the first run's 1.39 s; and touching every input without changing a byte reports "up to date but needs to update timestamps of output files" rather than a re-check, which is the state a fresh checkout leaves and is what makes a cache restored from an earlier run usable at all. A type error is not hidden by any of it: with an error file present, three consecutive tsc -b runs each print error TS2322 and exit 2, and removing the file returns exit 0. composite: true is the wrong knob here, because it forces declaration emit and this configuration forbids emit. Dropping the check from npm run build is also wrong, because two image stages depend on it: apps/api/Dockerfile:10 and infra/caddy/Dockerfile:10 each run npm run build in a stage that copies apps/web alone, the image-frontend job exists to build exactly those stages, and its comment at ci.yml:861-873 and docs/CI.md:45 both state that a file under apps/web which type-references anything outside it passes the web job and breaks only there. So the check stays in build and the repeats are made free instead. One placement detail decides whether a cross-run cache works at all: the buildinfo lives under apps/web/node_modules/.tmp today, and the job installs with npm ci at ci.yml:268, which replaces node_modules, so a cache of a path under node_modules restored before the install is thrown away.
Read first. .github/workflows/ci.yml:253-316 (the whole web job, with the comment at :271-272 saying why the type check has a step of its own) and :855-873 (the image-frontend job and its comment). apps/web/package.json:6-13 (the four scripts). apps/web/tsconfig.json (the solution file, files: [] and two references), apps/web/tsconfig.app.json and apps/web/tsconfig.node.json (both whole; noEmit, the tsBuildInfoFile at line 3, and no incremental). apps/web/vite.config.ts. apps/api/Dockerfile:1-11 and infra/caddy/Dockerfile:1-11 (the two SPA stages that run npm run build). docs/CI.md:12 (the web row, which names all four commands) and :45 (why the image stages check separately). apps/web/src/README.md:30 (the paragraph that states tsc -b follows the two references and that plain tsc against tsconfig.json checks nothing). docs/ops/v43-acceptance.md:120-131 (the table of how many times each step checks, on main and on V43's branch, and the dev-box and runner figures V43 filed this item with). .gitignore (the dependencies and builds block). Open items #109 and #100.
Deliverables. (1) Both web projects set incremental: true beside the tsBuildInfoFile each already names, with a comment at each that says what the file is for and that without this option build mode looks for emitted JavaScript a noEmit project never writes, so the project is out of date at every moment; the comment also says why composite is not used, which is that it forces declaration emit. (2) The buildinfo moves out of node_modules, to a directory of its own under apps/web that the install does not touch, with that path added to .gitignore. The reason is written down where the path is set: npm ci replaces node_modules, so a cached path under it is discarded by the install whatever order the steps run in, and a path outside it makes the ordering stop mattering. (3) The web job caches that directory with actions/cache, with a key over the runner, the Node version, the compiler version as package-lock.json pins it and the contents of the three tsconfig files, and a restore-key prefix so an ordinary source change still restores the previous run's cache instead of starting cold. The save runs whatever the job's result, so a red run still leaves a warm cache for the next one. (4) One full check per run, in the step named for it, and the two builds reuse the compiler-tracked, Git-ignored output it wrote. The named type-check step stays exactly where it is and keeps the failure label the comment at ci.yml:271-272 gives it, so a type error still fails a step named for types and not a build; the build and gallery steps that follow it at ci.yml:273-284 keep npm run typecheck && vite build unchanged, so both image stages and the local command in AGENTS.md:32 keep their check, and what changes is only that their compiler call finds both projects up to date and returns in about a second. No step is removed and no script is rewritten. (5) The measured seconds, before and after, on the same runner: the three step durations, the compiler part and the vite part of each build step read from the "built in" lines, the job total, and the cache's restored and saved size and time. Two runs on the branch, because one proves nothing about a cache: the first writes it, the second restores it, and the log line that names the key it hit is quoted. (6) The bite proofs, local and recorded, each run three ways: on a cold cache, on a cache warmed inside the same run, and on a cache restored from a previous run. A type error in a file under src fails npm run typecheck, npm run build and the gallery build, each with exit 2 and the error printed. A type error in vite.config.ts fails the node project the same way. Deleting the cache directory returns the cold timing, which proves the shorter timing came from the cache and not from the box. (7) docs/CI.md:12's web row says that the type check runs once and that the build steps read its cache, and the record is docs/ops/v50-acceptance.md with every figure above.
Interfaces. Exposes a restored compiler cache in the web job that every later web card's build steps read, and a buildinfo path outside node_modules that any later web tooling can point at. Consumes npm ci, actions/setup-node's own npm cache and the tsc -b solution shape, all unchanged. Nothing about npm run build changes for its callers, so apps/api/Dockerfile, infra/caddy/Dockerfile, the image-frontend job, e2e/README.md:207 and the command in AGENTS.md:32 are untouched and keep the behaviour they have today.
Acceptance. The web job green on the pull request, twice, and the second run's numbers are the acceptance. The two repeated checks fall from the measured 31.0 s to 33.4 s to under 2 s in all, read from the step durations against the vite "built in" lines in the same log, and the job total falls by the difference. The type-check step on the second branch run is measurably shorter than the cold 14.9 s to 17.0 s, with the restore line naming the key it hit; if a restored cache does not shorten it, the record says so with the number and the card keeps only the within-run saving, which is the larger half anyway. npx tsc -b --dry --verbose on the branch, run straight after a passing build, reports both projects up to date, where at 92a360f it reports both out of date with the two reasons quoted in the context. The bite proofs of deliverable (6) all fail as they are meant to, and the same commands on the base sources fail identically, which shows the cache changed the cost and not the verdict. npm run lint, npm test, npm run build and VITE_UI_GALLERY=1 npm run build clean in apps/web. The image-frontend job green, which is the proof that both Dockerfile stages still type-check. No comment the card adds names a card, an item or a phase. Record docs/ops/v50-acceptance.md; open item #109 closed.
Out of scope. The e2e job's image cache and the infra job's pressure step, which are the pipeline's two long poles and are the separate measured card the handoff names first. Per-job timeout-minutes, which no job sets today and which is its own measured card. The unit tests step, at 217 s to 235 s the web job's own long pole, whose reliability under load is open item #112 and whose cost is nobody's card yet. The api job's dependency cache. Splitting tsconfig.app.json so the tests are checked separately from the app, which would change what the check covers. npx tsc --noEmit against tsconfig.json, which checks no file because that file lists none, and which is open item #100.
Waits on / Unblocks. Nothing: the three tsconfig files, the four scripts and the web job are unchanged on main at 92a360f, and no open card edits them. Two open cards touch the same job's later steps without touching these: V45 (#102) adds a leg to the gallery suite the build the component gallery step feeds, and A35 (#99) sweeps comments repository-wide and will meet the two comments this card adds, so whichever merges second re-runs its checks on the first one's merge under rule 1's shared-surface test. Unblocks nothing directly, and gives every later web run its 31 s to 33 s back, which is the headroom rule 4 measures suites against.
Built as (PR #131 → main cab601e, 2026-09-20). Both web projects set incremental: true and move their build info to apps/web/.tscache/, which .gitignore and the root .dockerignore both hold, so no host cache can travel into an image context and let a stage skip a check. Neither project set incremental or composite before, so build mode wrote a marker rather than a cache and judged each project by emitted JavaScript that a noEmit project never writes: both build info files held a root list and the compiler version and nothing else, and the compiler called both projects out of date immediately after a passing check. After the change they carry 1,039 and 272 file records and both report up to date. The web job gains three steps: the compiler version read out of the installed package and put in the cache key, a restore before the check, and a save after the last step that runs the compiler. Restore and save are separate actions rather than the combined one, so a red run still leaves the next run a warm cache, and the save is skipped on an exact hit. No step was removed and no script was rewritten, so both image stages, the image gate job and the local command keep the check they have. Measured on the box: a second type check costs 1.25 s against 60.57 s, and deleting the cache directory brings the cold number back with the same sources, which is the control that says the short runs were the cache and not the weather. Thirteen bite runs prove a type error still fails every step that runs the check, on a cold cache, on a warm one, on a cache packed, deleted, unpacked and met with every input newer than it, and in the node project as well as the app project; the same error on the base sources fails every run and passes when it goes. Lint, the one test file that reads the app project's options, both workflow checks, the comment check and the secret scan were clean. Record docs/ops/v50-acceptance.md; open item #109 closed.
V51 · A whole web suite runs only on a box that can be judged · V · M · S2-W0 · test/v/v51-suite-under-load (surfaced while building V46 on 2026-09-19, open item #112; carded 2026-09-20)
Context. The cause this card commits to is runner availability, not test semantics: the per-test budgets are wall-clock numbers derived at one measured contention, and nothing stops the suite starting on a box that is far past it, so a run nobody could judge is spent anyway and comes back as fourteen defects in ten files that have none. The budgets and their derivation are in the tree and are honest work: apps/web/vitest.config.ts sets testTimeout: 15_000 and its comment states the rule, which is the worst reported test duration measured anywhere in the suite, doubled, rounded up; apps/web/src/test/setup.ts:27 sets configure({ asyncUtilTimeout: 4_000 }) by the same rule. V37 derived both from 26 recorded whole-suite runs and wrote the load down in runnable entities against cores: idle (3 runnable against 4 CPUs, 274 s), busy3 (6 against 4, 364 s to 389 s), busy4 (7 against 4, 409 s to 432 s) and busy6 (9 against 4, 497 s to 530 s), with the neighbour being N copies of a busy loop, and the worst single test in any of them reported 7,166 ms, which doubled and rounded gives the 15,000 ms that is set (docs/ops/v37-acceptance.md:26-48, :317-338, :354-360). What nothing in the suite does is ask, before it spends fifteen minutes, whether the box is inside that contention. V46's pre-rebase run was not: at a one-minute load average of 3.66 read at the start it gave 121 files and 1,079 tests with 10 files and 14 tests timed out in 934 s, and the same ten files re-run minutes later at a start reading of 3.31 gave 47 tests all passing with nothing changed (docs/ops/v46-acceptance.md:289-306). Against the post-rebase run on the same tree at a start reading of 1.21, 123 files and 1,085 tests in 265 s, the whole run stretched 3.5 times; but the ten files that timed out cost 8,112 ms between them on the CI runner in run 35471866195 (568 ms for IngestMetricsDialog up to 1,204 ms for NewSession) and cost 21 s to 58 s each in that run, which is a stretch of 26 to 71 times on the same ten files. A suite whose whole wall moves 3.5 times while ten of its files move 26 to 71 times is not being slowed evenly; it is being starved, and no wall-clock budget derived at one contention survives that. Those three start readings are one-minute load averages, and that is part of the defect rather than a measurement the card can build on: a one-minute average read at the start of a 934 s run is a lagging figure describing the minute before the run, and it is exactly what let that run look admissible. So this card uses one metric everywhere and never converts between two. The metric is the instantaneous runnable count, the numerator of the fourth field of /proc/loadavg, taken per core. It is chosen because it is the unit V37's own shapes are already stated in, because it describes the moment it is read rather than the minute before it, and because it can therefore be sampled repeatedly while a run is in flight, which a one-minute average cannot usefully be. Rule 10's operator habit, waiting until /proc/loadavg is below 2 with docker ps empty, is stated in the other measurement; the card does not convert it into a runnable figure and does not quote it as one, and carries over only its second half, the container check, which is not a measurement at all. Three candidate causes are eliminated by measurement at 92a360f, not by argument. Fake timers are not it: 14 files in the suite call vi.useFakeTimers, and not one of them is among the ten, which are NewSession, Operations, PelvieaseLabPage, Sidebar, BriefViewer, IngestMetricsDialog, WinnersGallery, UploadCreativeDialog, ExploreExperimentsPage and IntelView. The act environment is not it: V44 turned IS_REACT_ACT_ENVIRONMENT on for every file (src/test/setup.ts:42-57) and merged as 7083fd5, after the failing run was taken, and the first whole-suite run that carried it cost 265 s for 123 files where V37's idle run on its own tree cost 274 s, so it added nothing measurable. The worker count is not it: vitest.config.ts sets no pool, maxWorkers, fileParallelism or isolate, so the defaults apply and V37 measured three worker processes on this four-core box (v37-acceptance.md:26-28); cutting that to one would multiply the CI unit tests step, measured at 217 s, 234 s and 235 s on three recent runs, past rule 4's measured headroom of about 280 s in the web job, and the ten files' 26-to-71-times stretch against the run's 3.5 says the contention was not the suite's own three forks. Three answers are rejected for stated reasons, and the card must not drift into any of them. A larger budget is rejected because it was derived from measurement twice and its size is exactly what still catches a genuine hang. A budget scaled by the measured contention is worse, because it weakens the hang detector at the moment contention is highest, which is when a hang is most likely to be misread. A third run-level verdict is rejected too: it would turn a test-timeout flake into a verdict flake, it gives the next run no mechanism to be valid, and a red verdict still freezes every merge under rule 1, so nothing is gained and a new class of red is created. What is left is to stop the unjudgeable run from starting. Where this applies is not everywhere, and the card is explicit about it: CI runners are dedicated machines, the measured evidence says nothing there is near the budgets with the unit tests step at 217 s to 235 s and no timeout in any run read for this card, and a gate that could make a dedicated runner wait or refuse would create the CI failure this card exists to prevent. So CI never admits and never refuses; it samples and logs. The shared four-core dev box is where admission applies, and it puts into code the habit rule 10 already asks an operator to remember, which the V46 run is the cost of forgetting. One drift to correct while in the file: the comment at vitest.config.ts says twelve imported test files set their own budget; at 92a360f it is thirteen files, 53 tests at 20,000 ms and 2 at 30,000 ms.
Read first. apps/web/vitest.config.ts (whole file: the include list, testTimeout and the whole comment that derives it). apps/web/src/test/setup.ts (whole file: the asyncUtilTimeout comment at :11-26 with the call at :27, the act environment at :29-57, and the console guard's afterEach at :59-63). apps/web/src/test/console-errors.ts (what failOnActWarnings and the guard do, because nothing this card adds may swallow them). The ten files, for their shapes and their own budgets: src/pages/products/NewSession.test.tsx, src/pages/system/Operations.test.tsx, src/pages/pelviease-lab/PelvieaseLabPage.test.tsx, src/components/layout/Sidebar.test.tsx, src/pages/intelligence/BriefViewer.test.tsx:124 :143 :160 :183 :215, src/pages/intelligence/IngestMetricsDialog.test.tsx:44 :52 :66, src/pages/winners/WinnersGallery.test.tsx, src/pages/intelligence/UploadCreativeDialog.test.tsx:42 :50, src/pages/explore-experiments/ExploreExperimentsPage.test.tsx and src/pages/scraped-ads/IntelView.test.tsx:66 :82 :93. docs/ops/v37-acceptance.md:15-48 (the box, the neighbour, the four shapes in runnable entities, and their walls), :49-97 (what failed run by run, and the three kinds), :98-120 (what was discarded and why a recorded set and its tree are the same bytes), :305-360 (the budget's derivation and the table of worst durations per shape) and :361-390 (the query wait, and the sentence that the pool and the worker count are untouched and why). docs/ops/v46-acceptance.md:289-306 (the two runs and their start readings, and the paragraph that files this item). docs/ops/v44-acceptance.md (what the act environment costs and what it catches). .github/workflows/ci.yml:253-316 (the web job, the unit tests step at :277-278, which this card does not edit). apps/web/package.json:6-13 (the scripts, and the test script this card repoints). The proc(5) manual page on /proc/loadavg's fourth field, which is the metric this card uses. GitHub's documented default environment variables, for the CI variable every runner sets. session-handoff.md, rules 1, 4, 5 and 10. Open items #112 and #94.
Deliverables. (1) One metric, three figures, each labelled for what it is, in one module beside the tests. The metric is the instantaneous runnable count per core, from the numerator of the fourth field of /proc/loadavg divided by the core count, and every figure in the module, the runner and the record is in it. MEASURED: the derivation contention, 9 runnable against 4 cores, 2.25 per core, inclusive of the suite, which is V37's busy6 and the highest contention at which the 15,000 ms budget was proved to hold. MEASURED: the suite's own contribution, 3 runnable, which is V37's own count of the worker processes the default pool starts on this box, written there as N plus 3 runnable entities against 4 CPUs (docs/ops/v37-acceptance.md:26-28), and is 0.75 per core. Nothing is added to that count. Whether the main process is runnable beside its workers during a run is not something V37 measured, so no figure in this card rests on it; a builder who wants it in the arithmetic takes that measurement and labels it as their own. DERIVED by arithmetic from those two: the most a box may already be carrying and still leave the budget the margin it was proved at is 6 runnable, 1.5 per core. POLICY, and labelled as policy in the module with its rationale and not as a measurement: the runner admits at 1.0 runnable per core, which is 4 on this box, stricter than the derived 1.5 so that two runnable of the proven margin are left unspent rather than the suite starting at the edge of it, and it may be moved only by a recorded measurement. The module says in the same place that rule 10's figure is a one-minute load average, that the two measurements are not interchangeable, and that no conversion between them appears anywhere. (2) Bounded admission control at the web test entry point, before vitest starts, in a new apps/web/scripts directory that npm test points at. It samples the metric every 5 s. When the sample is at or below the admission figure three consecutive times, which is 10 s from the first sample to the third, the suite starts. The interval and the consecutive count are POLICY, stated as such where they are set: the interval is 5 s because the runnable field is instantaneous and reading it costs nothing; the count is three because three quiet readings 5 s apart make a sustained load unlikely where a single reading does not, which is the whole of the claim: sampling an instantaneous metric cannot prove that nothing burst between two samples, and the rule is an admission rule and not a proof of quiet. Ten seconds of delay on a quiet box is under 4 % of the 265 s the suite costs there. Otherwise it waits, sampling on the same interval, for at most 10 minutes. That ceiling is POLICY too, with its rationale beside it: the box's heavy neighbours are the api tier at about 690 s and the e2e stack, so a ceiling longer than 10 minutes would make an operator's suite sit out a whole neighbouring leg instead of telling them to come back. It also carries over rule 10's container half, refusing to admit while docker ps lists a running container, printing a note and treating the box as quiet when docker cannot be reached at all; that half is policy as well, and is labelled so. While it waits it prints each sample, so an operator watching sees why. (3) At the ceiling it refuses to start, with one sentence naming the measured metric and its value, the admission figure, how long it waited and what containers were listed, and exits non-zero. A refusal is a refusal to run and not a suite result, and the sentence says so, so nothing about it can be read as a test verdict. (4) A CI mode, because npm test is what CI invokes at .github/workflows/ci.yml:277-278 and a gate that could make a dedicated runner wait or refuse would manufacture the failure this card prevents. It is detected by the CI environment variable, which GitHub Actions sets to true on every runner, so ci.yml needs no edit and no flag has to be remembered; the runner prints which mode it chose and why on its first line. In CI mode it takes and logs exactly the same samples, then starts vitest unconditionally: admission is not applied, the wait loop does not run, the container check does not run, and a refusal is not reachable. (5) The suite's own exits are unchanged: PASS is 0 and FAIL is 1, vitest decides both, no code is added and no failure is reclassified. Nothing in this card touches a budget, a test, a reporter or the console guard. (6) The contention stays measured for the whole run, in both modes, as evidence and never as a verdict. The entry point keeps sampling every 5 s while vitest runs and prints the admission sample, the median and the peak at the end, pass or fail, so every future record carries the contention its numbers were taken at instead of a one-minute average typed in by hand at the start, which is what both V37 and S37 had to do. Neither the runner's output nor any record may present admission as proof that the run stayed quiet. Admission speaks for the three moments it sampled and for nothing else; the in-flight samples are the only evidence of what the box did afterwards, and only for the moments they were taken. The runner's closing line says that in those words, so a record cannot quote the admission sample as a claim about the run. (7) The rule is written where the next author meets it. The comment in vitest.config.ts that derives the budget gains the sentence that the budget holds only inside a named contention, measured in runnable entities per core, that the entry point now admits a local run only on a box inside it while CI only logs, and that the answer to a busy box is a wait or a refusal to start and never a larger number; and its count is corrected to the thirteen files, 53 tests at 20,000 ms and 2 at 30,000 ms, measured at 92a360f. (8) The admission decision is a pure function with its own unit test file, taking the samples, the admission figure, the consecutive count, the interval, the elapsed time, the ceiling, the container list and the mode, and answering start, wait or refuse. (9) The before and after, under a deliberate load, in V37's method: a neighbour started from a script that records its pids, the metric before it starts, every sample and the wall time, climbing its ladder and recording every rung until at least one file passes ten times its runner figure. On the base, at that rung, the suite starts and spends its wall and comes back with timed-out tests, which is the defect. On the branch, at the same rung from the same tree, the suite does not start; it waits, prints its samples, and starts and passes when the neighbour is killed inside the ceiling, or refuses with its sentence when the neighbour is left running past it. Both legs recorded, with the metric and never a load average. (10) Rule 4 applies in its stricter half, because this card changes the suite's entry point: the whole web suite runs locally, and the record carries its wall and its admission samples. (11) docs/CI.md:12's web row says what npm test does before it starts and that CI only logs, apps/web/src/README.md:30 says the same in one clause, and the record is docs/ops/v51-acceptance.md.
Interfaces. Exposes the one metric with its three labelled figures, the admission function and its three answers, the two modes, and an npm test that prints the contention of every run it makes in either mode, which every later web record quotes. Consumes vitest.config.ts's budgets and src/test/setup.ts's query wait unchanged, the console guard and the act environment unchanged, /proc/loadavg, which is read and never written, docker ps, which is read and never acted on, and the CI environment variable, which is read and never set. npx vitest run still works and still bypasses admission, deliberately, and the record says so: the gate is on npm test, and a card running one file wants no gate. .github/workflows/ci.yml is not edited by this card. No test file changes, no budget moves, and no exit code is added to the suite.
Acceptance. npm test in apps/web green on a quiet box, 123 files and every test passing, admitted after its three samples with the 10 s of admission delay recorded against the run's wall, and the admission samples, median and peak in the record beside the sentence that says what each of them does and does not speak for. The CI web job green on the pull request, with the unit tests step inside the 217 s to 235 s it costs today and therefore inside rule 4's measured 280 s of headroom, and its log carrying the CI-mode line, the samples and no wait. The two load legs of deliverable (9) recorded from the same tree and the same neighbour: the base spends its wall and reports timed-out tests, the branch waits and then either runs green or refuses with its sentence, and both outputs are quoted in full. The admission function's unit tests cover every boundary and each fails on the base sources, where the function does not exist: a box at the figure for three consecutive samples starts; a box one runnable above it waits; two samples at the figure followed by one above it waits, so a dip cannot admit; elapsed time at the ceiling with the box still busy refuses, and the sentence names the metric, its value, the figure and the wait; a container listed with a quiet box refuses to admit; docker unreachable with a quiet box admits and prints its note; and, the CI-mode case, a simulated environment with CI set and every sample far above the figure and a container listed still answers start, with no wait and no refusal, which is the proof that a busy dedicated runner cannot be blocked. The suite's exits are proved unchanged: a scratch copy with one failing assertion exits 1 on a quiet box, and a scratch copy with a test that awaits a promise that never settles still times out and exits 1, which is the proof that nothing here weakened the hang detector. npm run lint, npm run typecheck and npm run build clean. No comment the card adds names a card, an item or a phase. Record docs/ops/v51-acceptance.md; open item #112 closed.
Out of scope. Raising any budget, and scaling any budget by the measured contention, both rejected in the context with the reason: the first was derived from measurement twice, and the second weakens the hang detector exactly when contention is highest. A third suite verdict or a third exit code, rejected for the same reasons: it makes a verdict flake out of a test flake, gives the next run no mechanism to be valid, and still freezes the train under rule 1. Any admission, wait or refusal in CI, which deliverable (4) makes unreachable. Converting between a load average and a runnable count anywhere, in the code, the comments or the record. Any rerun or retry, which rule 5 forbids outright. Changing the pool, the worker count, fileParallelism or isolate, for the measured reason in the context. Admission control on any other suite, including the api tier and the e2e job, which have their own walls and their own operator rules and are each their own card. Making the suite faster, which is real work and is nobody's card: the CI run's phases are transform 3.72 s, setup 13.85 s, import 30.08 s, tests 87.02 s and environment 85.58 s inside 234.12 s, so over half the wall is jsdom environments and module imports rather than tests, and the record files that as a new open item with those figures. Any change to the ten files, none of which has a defect that this run showed.
Waits on / Unblocks. Nothing: V37's budgets, V44's act environment and the console guard are all on main at 92a360f, and no open card edits vitest.config.ts, src/test/setup.ts, apps/web/package.json's test script or the web job's unit tests step. Two open cards meet this one's surface without sharing a file: V50 (#109) edits the same job's earlier steps, the same package.json scripts block and the tsconfigs, and V45 (#102) adds files under src/test, so whichever of the three merges after another re-runs its checks on that merge under rule 1's shared-surface test, and V50 and this card share apps/web/package.json outright, so the second of those two is always rebased. Unblocks every later card whose record quotes a whole-suite web run taken on this shared box, because from this card on a local run either happened on a box inside the contention its budgets were derived at or did not happen at all, and it says which, in one metric, on its own output.
S37 · The storyboard steps read their prompts through the one loader · S · M · S2-W0 · fix/s/s37-storyboard-prompts-through-registry (surfaced while challenging the card for open item #87 on 2026-09-18, open item #95; carded 2026-09-19)
Context. The storyboard module keeps its own copy of the instruction registry and its own loader. Measured at main 16dd547, where every line the item cites is where it was at ba66058. apps/api/app/pipeline/storyboard_llm.py:68-77 holds INSTRUCTION_FILES, which maps the eight registered storyboard steps to their shipped files off the module's own ASSETS_DIR (:45), while apps/api/app/backend/services/instruction_registry.py:131-138 names the same eight files off _STORYBOARD_PROMPTS_DIR (:122-124). Both maps name the same eight files at 16dd547, and all eight are present in pipeline/video_storyboard_assets/, so nothing is wrong on a running stack. What is wrong is what V40 fixed for one prompt, at eight times the size: a move or a rename is made in the registry, and this module keeps reading its own paths with the suite green, because V40's sweep (apps/api/app/backend/tests/test_explore_instructions.py:532-999) sees only loader calls and the storyboard makes none (the sweep lists seven literal calls and one computed call, db_service.py, at 16dd547, and none in storyboard_llm.py). load_instruction (storyboard_llm.py:204-230) and load_shipped_instruction (:233-242) are a second copy of load_spec's row-then-file logic (apps/api/app/pipeline/utils/file_io.py:13-50), and the copy exists for one reason, stated at :205-210: it returns (content, "db" | "file"), and the source decides the retry. run_with_fallback (:245-345) retries once with the shipped file only when a workspace row served the first attempt (:296-307), because a file-sourced answer that cannot be parsed has nothing better to try and a second call would only pay twice. The source also lands on every StageResult (:192-199) and in the split route's audit (apps/api/app/backend/routes/video_storyboard.py:402-404), and apps/api/app/backend/services/storyboard_song_runner.py:93 loads the pair once and hands it to every concurrent split (storyboard_llm.py:275-287), a hand-off test_video_storyboard_song_split.py:285-310 pins. load_spec returns the text alone, so the shared loader has to report the source before the copy can go. The copy has four defects. (a) load_shipped_instruction catches only FileNotFoundError (:239), so a shipped file that cannot be read or is not UTF-8 raises a raw traceback, where load_spec (file_io.py:41-49) and the registry's read_shipped_text (instruction_registry.py:169-182) treat all three alike; and a missing file gets its own sentence, Shipped instruction missing at <path> (:240-242), not the operator's sentence the registry owns (instruction_registry.py:192-202). (b) load_instruction catches every exception around the row read (:227), so a broken query or a pool that cannot connect reads as "no workspace row" and serves the shipped file in silence; load_spec lets it through. (c) The comment at :47-48 says the step keys are registered in db_service.INSTRUCTION_STEPS; they are registered in instruction_registry.INSTRUCTION_STEPS (:28-79), which db_service re-exports. (d) Found while reading the retry, not in the item: the retry's shipped read (:311) runs after the first attempt was paid for, and when it raises, the error carries no attempts (the default at :182-189), so that paid answer never reaches the ledger attempts exists to feed (:162-173). Four facts shape the change. Tests in five files patch llm.load_instruction or llm.load_shipped_instruction by name (test_video_storyboard_split_boundary.py, test_storyboard_spend_ledger.py, test_video_storyboard_reference.py, test_video_storyboard_motion_history.py, test_video_storyboard_song_split.py; for example test_storyboard_spend_ledger.py:101 and test_video_storyboard_song_split.py:302-303), so both names must stay the module's entry points. test_video_storyboard_m1.py:44-56 fakes the row by patching app.backend.db.get_cursor, which reaches the copy only because it imports the name inside the function (:211); file_io binds it at import (file_io.py:9), and the house fake patches file_io.get_cursor (test_explore_instructions.py:122). The storyboard names its step through a variable, which V40's rule does not check (_fallback_refusal, :705-727, runs over literal calls only, :687-702) and which test_the_only_non_literal_loader_key_is_the_service_helper (:991-999) refuses until it is listed. And the api job in CI runs with no database (.github/workflows/ci.yml:190-211 starts no Postgres, while conftest.py:136-163 binds a workspace for every test), so any test that reaches the copy's row read unpatched meets a connection failure that (b) turns into the shipped file today. Two readers outside the package use the copy: apps/api/scripts/smoke_storyboard_m1.py:87,98 and apps/api/scripts/smoke_storyboard_variants.py:59,61, the second through INSTRUCTION_FILES. Four comments in apps/api/app/pipeline/camera_moves.py (:39-40, :107, :1466-1468, :1573-1579) say the camera registry has the same shape as load_instruction, including a fallback when the table is unreachable. Not measured: how many tests across the tier pass only because of (b); four files run on the base (test_video_storyboard_m1.py, 53 passed; test_character_sheets_flow.py, 151 passed; test_storyboard_image_runner_gateway_refusals.py with test_video_storyboard_prompt_runner.py, 29 passed) printed the swallow's warning 0 times.
Read first. apps/api/app/pipeline/storyboard_llm.py:1-23 (the module's promise: editable instructions, the shipped file as fallback, one retry), :45-77 (ASSETS_DIR, the comment at :47-48, the eight step keys and INSTRUCTION_FILES), :149-189 (StoryboardLLMError, and what attempts is for at :162-173), :192-199 (StageResult.instruction_source), :202-242 (the two loaders), :245-345 (run_with_fallback: the hand-off :275-291, the no-retry :296-307, the retry's shipped read :309-311, the two-attempt raise :325-332, the merged usage :333-345); apps/api/app/backend/services/instruction_registry.py:1-10, :28-79, :112-143 and :146-202 (resolve_step, is_registered, label_for, shipped_path, read_shipped_text, is_usable_override, missing_instruction_message); apps/api/app/pipeline/utils/file_io.py:1-50; apps/api/app/backend/services/db_service.py:2551-2562 (the other computed-step call, get_instruction); apps/api/app/backend/services/storyboard_song_runner.py:88-112; apps/api/app/backend/routes/video_storyboard.py:396-420 and :2974-2990 (two callers that catch StoryboardLLMError and ledger its attempts); apps/api/app/backend/tests/conftest.py:136-163; .github/workflows/ci.yml:190-211; apps/api/app/backend/tests/test_explore_instructions.py:118-125, :489-495 (the treatment the computed call site gets), :532-547, :623-702 (how the sweep finds and resolves a loader, the spelling fallback in _is_loader at :653-662), :705-771 (the literal-only rule and its tests), :774-900 (the proofs on text) and :991-999; apps/api/app/backend/tests/test_video_storyboard_m1.py:44-56 and :256-319; apps/api/app/backend/tests/test_video_storyboard_motion_history.py:1202-1222; apps/api/app/backend/tests/test_video_storyboard_song_split.py:260-310; apps/api/app/backend/tests/test_storyboard_spend_ledger.py:95-110; the two smoke scripts at the lines above; apps/api/app/pipeline/camera_moves.py:36-43, :105-107, :1464-1468 and :1570-1595; docs/ops/v40-acceptance.md (its :373-380 records defects (a) and (b)); open item #95.
Deliverables. (1) One loader that says where the text came from. file_io gains the function the storyboard needs (for example load_spec_with_source), returning the text with "db" when a usable workspace row served it and "file" when the shipped file did, with load_spec's registry check, workspace scope, blank-row rule, file-read handling and refusals exactly; load_spec returns the same text with its signature, return and every refusal unchanged, so no existing caller changes. (2) load_instruction(step) keeps its name, signature and (content, source) return, and its body is one call to that loader with fallback_path=instruction_registry.shipped_path(step). The loader's ValueError (an unregistered step, or neither a usable row nor a readable file) reaches the stage as StoryboardLLMError carrying the loader's own sentence and no attempts, since nothing was sent, so every caller that already catches that type handles it unchanged. A database failure is not caught and reaches the caller as itself, as it does for every other step. It is not converted, because StoryboardLLMError means the model could not be used and its callers' fallbacks exist for that; for the split, converting would put a sentence-split board in front of an operator whose edit was never read. (3) load_shipped_instruction(step) keeps its name and str return and reads through instruction_registry.read_shipped_text. An unregistered step raises StoryboardLLMError with the same unknown-step sentence load_spec gives (file_io.py:28-29); a registered step whose file is absent, unreadable or not UTF-8 raises StoryboardLLMError with missing_instruction_message. It stays the retry's one read of the shipped text and the smoke scripts' entry point. (4) The retry keeps what it paid for: when the shipped read at the retry raises, the error carries the first attempt's usage on attempts, in the shape :302-307 uses, and its sentence says both things that happened: the edited instruction's answer could not be parsed, and the shipped file could not be read to retry. (5) INSTRUCTION_FILES and ASSETS_DIR go from storyboard_llm.py. run_with_fallback and every stage still reach the loader through the module's own load_instruction and load_shipped_instruction, so every name patch in the tests keeps its seam. The comment at :47-48 names instruction_registry; the module docstring at :17-22 names the shared loader; the comment at :214-215, which carries a card key (open item #99), goes with the body it describes. The module imports cleanly on its own and from both smoke scripts, with no import cycle through file_io. (6) The readers outside the module follow: smoke_storyboard_variants.py:61 names the file through instruction_registry.shipped_path; test_video_storyboard_motion_history.py:1213 asserts the registry's path; _mock_instruction_row (test_video_storyboard_m1.py:44-56) patches the name the shared loader reads, in the shape of test_explore_instructions.py:122. Any other test that turns out to have reached the loader with no database, and passed only because of the swallow, gets a fake row or a patched loader, never a catch in the source. (7) V40's sweep covers the module. It counts the new loader wherever it is called and however it is spelled, as it counts load_spec, re-exports and the spelling fallback in _is_loader (:653-662) included. A rule for computed steps: a loader call whose step is not a literal passes fallback_path as shipped_path (the registry's, bare or through the module) called with the same step expression, compared by its tree, which db_service.get_instruction (db_service.py:2557-2558) already does; a computed call with no fallback_path, a built path or shipped_path of another expression is refused, each for its own stated reason. The two public loaders in file_io share their body without that delegation counting as a call site: after the change the set of files with a computed loader call is exactly backend/services/db_service.py and pipeline/storyboard_llm.py, and test_the_only_non_literal_loader_key_is_the_service_helper asserts that set. The storyboard steps get the treatment the service helper has (:489-495): a test that enumerates every STEP_* key storyboard_llm defines and asserts each is registered, labelled and readable through the registry. (8) No third map: a rule in the same file refuses any string literal under apps/api/app, outside instruction_registry.py and the test tree, that equals the file name of a registered shipped instruction, naming the file and the line, proven on fixture text as V40's rule is (:774-900). (9) The four camera_moves.py comments state the camera registry's own rule, which still serves the seed when the table is empty or unreachable (:1581-1595), and stop saying the instruction loader does the same. (10) docs/ops/s37-acceptance.md: each loader path before and after (a row, a blank row, no workspace, no file, an unreadable file, a file that is not UTF-8, a database failure, a retry with the shipped file gone), with the base's own answers shown on a scratch copy of the base with llm.INSTRUCTION_FILES pointed at the same targets (the raw IsADirectoryError or UnicodeDecodeError, Shipped instruction missing at, the empty attempts); the sweep's output on the base and the branch; the count of the swallow's warning over the whole api tier on the base; and the tests that needed a fake once the swallow went, by name.
Interfaces. Exposes the source-reporting loader in file_io, which the sweep holds to the same rules as load_spec, and the rule that a computed-step loader call passes shipped_path of its own step expression. storyboard_llm.load_instruction and load_shipped_instruction keep their names and returns; INSTRUCTION_FILES and ASSETS_DIR leave storyboard_llm. Consumes instruction_registry.shipped_path, read_shipped_text and missing_instruction_message. Unchanged: load_spec's signature, return and refusals; the registry's map, keys, aliases and labels; the prompt bytes; StageResult; the retry-once rule and the source it keys on; StoryboardLLMError's meaning; the gateway's refusals passing through as themselves. One behaviour changes on purpose: a database failure during the instruction read surfaces as itself instead of running the shipped text.
Acceptance. pytest apps/api/app/backend/tests green apart from exactly the tests in apps/api/ci-known-failures.txt. Each new loader test points the registry's own entry (monkeypatch.setitem on instruction_registry.INSTRUCTION_FALLBACK_PATHS), which is the one seam both trees share, with its row fake installed at both file_io.get_cursor and app.backend.db.get_cursor so the base meets the same rows, and fails on the base sources for its own reason: a storyboard step's entry pointed at a temporary file makes the first call and the shipped retry both send that file's text (the base sends its own file both times); an entry pointed at a directory, and one pointed at bytes that are not UTF-8, give StoryboardLLMError with the registry's sentence, empty attempts and no model call (the base calls the model with its own file); an entry pointed at a path that does not exist gives the registry's sentence (the base calls the model); an unregistered step given to load_shipped_instruction gives the unknown-step sentence; a cursor that raises reaches the caller as that exception with no model call (the base calls the model with the shipped text); a row whose answer cannot be parsed, followed by an entry pointed at an unreadable file, gives StoryboardLLMError with one entry on attempts equal to the first call's usage and a sentence naming both failures (the base retries with its own file and makes a second call). The sweep finds a computed loader call in storyboard_llm.py (on the base none), and the computed-file set is exactly db_service.py and storyboard_llm.py, with no call counted in file_io.py. The computed-step rule passes db_service.get_instruction on the base and the branch, accepts load_spec(step, fallback_path=shipped_path(step)) in the spellings V40's fixtures use, and refuses no fallback, a Path(...) fallback and shipped_path(other) on fixture text, each for its own stated reason. The no-third-map rule names storyboard_llm.py:69-76, eight lines and nothing else, on the base, and nothing on the branch. The sweep's literal count is seven on the base and the branch. The source tests in test_video_storyboard_m1.py:256-319 and test_video_storyboard_song_split.py:285-310 keep their assertions: a row gives "db", no row and a blank row give "file", a garbled edit retries once with the shipped file, and a garbled shipped answer does not retry. rg "INSTRUCTION_FILES|ASSETS_DIR" apps/api/app/pipeline/storyboard_llm.py and rg "INSTRUCTION_FILES" apps/api return nothing. python -m py_compile clean on both smoke scripts, and each imports without running its __main__ block. test_intelligence_instructions_shipped.py untouched and green, and no file under pipeline/video_storyboard_assets/ in the diff. Record docs/ops/s37-acceptance.md; open item #95 closed.
Out of scope. The prompt bytes and their pins. The registry's map, keys, aliases, labels and sentence. load_spec's signature and refusals. apps/api/app/pipeline/storyboard_images.py:53 and :274-279, which read storyboard_character.md and storyboard_character_read.md off their own directory: neither is a registered step, so neither is editable or in the registry's map. The ChatCut preambles' own fallbacks (storyboard_delivery.py:82-90), which the registry deliberately does not carry (instruction_registry.py:65-77). The camera registry's own row-or-seed rule (camera_moves.py:1570-1595); only its comments change. ensure_cast's broad catch (storyboard_image_runner.py:183-194), which still turns a failed roster read, a database failure from the loader included, into an empty cast that records why. A cache of the loaded instruction, which storyboard_llm.py:282-287 says why not. Widening the sweep's root past apps/api/app, so the smoke scripts are fixed by hand and not by a rule. The historical records that describe the old loader (docs/ops/v8-acceptance.md, v29-acceptance.md, v40-acceptance.md). V14's prompt layers.
Waits on / Unblocks. Nothing: V40 is merged (PR #100, main 0a69129), which set the rule and built the sweep this extends, and no open card edits these files; V46 touches storyboard_image_runner.py, which imports this module, so whichever of the two merges second re-runs the api tier on the first's merge / V14 (S3-W3), which moves workspace instructions into prompt layers and afterwards has one loader to change instead of two, and any later move or rename of a storyboard prompt, which becomes one line in the registry.
Built as (PR #114 → main 56175db, 2026-09-19). Code 2 review rounds → APPROVE. The branch was rebased onto A33's main and re-run before the merge. The storyboard module keeps no map and no loader of its own: INSTRUCTION_FILES and ASSETS_DIR are gone, and both public loaders read through the registry and share one private _read_instruction, which holds the registry check, the workspace scope, the blank-row rule and the file read in one place. file_io.load_spec_with_source is the shared source-aware loader, and both refusals are file_io.InstructionRefused, a subclass of ValueError, which load_instruction turns into a StoryboardLLMError carrying the loader's own sentence. The sweep gains rules: it counts the new loader, accepts a computed step whose fallback is shipped_path of the same expression, refuses a call with no fallback_path or a path built at the call site, and refuses any second map of instruction files (it found eight names on the base and none on the branch). Evidence: the two instruction modules are 28 failed / 90 passed on the base sources, and the whole api tier is 4495 passed / 11 skipped / 3 deselected on the branch against 4441 on the base. One behaviour changes on purpose: load_instruction used to swallow every exception around the row read and fall back to the shipped text, and a failed read now propagates unchanged, so only the loader's own refusal is caught. Nothing is billed, and the run can be repeated once the database is back. Through ensure_cast the operator still reads "the cast could not be read", which is open item #114 and no caller changes here. The merge commit's CI failed once on the same pre-existing web race, open item #116, and passed on the re-run; the staging deploy was green. Record docs/ops/s37-acceptance.md; open item #95 closed.
S38 · A refusal names only what the operator has been shown · S · S · S2-W0 · fix/s/s38-name-what-was-seen (surfaced while reviewing V46 on 2026-09-19, pre-existing on main, open item #111; carded 2026-09-20)
Context. Measured at main 92a360f. character_cast.UNNAMED_PRIMARY is the literal "primary" (apps/api/app/pipeline/character_cast.py:56), the slug a session with no roster resolves its one character to: resolve_scene_cast returns [primary_slug(cast)] for a scene that uses a character when the roster is empty (:305-341, the fallback at :338-339), and primary_slug returns UNNAMED_PRIMARY when no member carries the primary role (:243-254). It is an internal identifier. The product never shows it, because roster_of returns [] for a session with no roster (:230-235), so the route that builds the panel's chips never has it to label (apps/api/app/backend/routes/video_storyboard.py:1051-1059). Four refusal sentences in the runner put it in front of the operator all the same, because they name a character through character_cast.display_name, which falls back to part_name when the record carries no name (character_cast.py:111-142), and part_name("primary") is "Primary", measured. The four: the thin-description sentence in _why_not_drawable (apps/api/app/backend/services/storyboard_image_runner.py:2642-2646), which both frame gates read, the per-character no-record reason in _unready_refusal (:2948-2951), its lock-missing reason (:2958-2962), and the sheet-pool refusal in ensure_character (:1509-1518), whose who comes from display_name(primary_slug(cast)) with no record at all. A solo session whose uploaded page could not be read is therefore told Primary has too little description to render from — ..., and a solo session with an unassigned page is told 1 reference sheet(s) were uploaded, but none is assigned to Primary. The branch beside them avoids exactly this and its comment says why: the no-record raise in _require_cast_ready tests the slug by hand and says This scene includes the character instead, because on a session with no roster primary is an internal slug and not a name anybody has seen (:2698-2709, the comment at :2699-2702). The whole-board gate's head does the same, mapping UNNAMED_PRIMARY to the character and printing every other slug raw (:2935-2937). A third form exists two screens up, 'That character' as the subject of the removed-library-row refusal (:1482-1484). So the rule is already agreed and written down three times, and the four sentences that name a character do not follow it. The fix belongs where the question is asked and not in each sentence, and it cannot be display_name itself: UNNAMED_PRIMARY is an ordinary slug string, so a roster whose primary really is slugged primary has a member the operator can see in the panel, and part_name is the right answer for them. The thing that tells the two apart is the roster, which character_cast.entry_of already reads (:256-260) and which both callers hold, _require_cast_ready at :2673 and preflight_all at :3000. V46 filed this under what it saw and did not do (docs/ops/v46-acceptance.md:315-325).
Read first. apps/api/app/pipeline/character_cast.py:50-60 (UNNAMED_PRIMARY and its note), :111-142 (display_name and part_name, with the reason each exists), :230-260 (roster_of, member_ids, primary_slug, entry_of) and :305-341 (resolve_scene_cast). apps/api/app/backend/services/storyboard_image_runner.py:1490-1520 (the sheet-pool refusal), :2618-2647 (_why_not_drawable), :2650-2745 (_require_cast_ready, the cast read at :2673, the no-record branch and its comment at :2698-2715) and :2916-2963 (_unready_refusal, the head at :2935-2937 and the two reasons at :2948-2962), with preflight_all's cast read at :3000. apps/api/app/backend/routes/video_storyboard.py:1040-1059 (the chip label and part, the one place display_name faces the panel). apps/api/app/backend/tests/test_character_sheets_flow.py:2490-2500 and apps/api/app/backend/tests/test_video_storyboard_e2e.py:1050-1082 (what pins display_name and part_name today) and :2454-2460 (test_generate_all_still_names_the_unnamed_primary). docs/ops/v46-acceptance.md:66-105 (the reasons as they print) and :315-325. Open item #111.
Deliverables. (1) One answer to "what may a refusal call this character", in character_cast, beside display_name and part_name and documented against both. It takes the cast as well as the slug and the record, and it answers in three steps: the character's own name when one has been built, the part the roster gives the slug when the roster holds it, and a phrase for a slug the roster does not hold, which is every internal fallback and today is only UNNAMED_PRIMARY. The phrase is the character, the words the no-record branch and the whole-board head already use, so the product says one thing. The docstring says that display_name stays the right answer where the roster is what is being drawn, names the chip route as its caller, and says that this function is the right answer wherever a sentence is about to be shown to an operator who may never have seen a roster. (2) The four sentences ask it. _why_not_drawable takes the cast and uses it for its subject; _unready_refusal takes the cast and uses it for both of its per-character reasons; the sheet-pool refusal uses it in place of display_name(primary_slug(cast)). (3) A subject that starts a sentence is capitalised where the sentence is built, by one shared expression in the runner rather than by a second stored form of the phrase, so the thin-description reason reads The character has too little description to render from — ... on a roster-less session and Rosie has too little description to render from — ... on a built one, with nothing else in either sentence moved. The sheet-pool sentence takes the phrase in its object position unchanged. (4) The no-record branch's hand test and its comment stay exactly as they are: its two sentences differ in more than their subject, and rewording the named one is a change nothing asked for. The comment gains nothing and loses nothing. (5) docs/ops/s38-acceptance.md: every sentence in the runner that names a character, whether it now goes through the one answer, and the printed output of each of the four in both states, a roster-less session and a roster of two.
Interfaces. Exposes one naming answer in character_cast that every later operator-facing sentence about a cast member uses. Consumes roster_of, entry_of and part_name, unchanged. display_name, part_name and primary_slug keep their signatures and their answers, so the chip route and both tests that pin them are untouched. _why_not_drawable and _unready_refusal each gain the cast as a parameter, which both callers already hold.
Acceptance. The api tier green in the api job with the quarantine list deselected as CI does it (.github/workflows/ci.yml:216-221). Each of these fails on the base sources for its own reason: on a roster-less session whose page read below the floor, the per-scene gate's refusal contains The character has too little description to render from and does not contain Primary, where the base says Primary; the same board through the whole-board gate gives the same subject, where the base says Primary; a roster-less session with one unassigned uploaded page is refused with none is assigned to the character, where the base says to Primary; and on a roster of two, the same three sentences name Son and Rosie exactly as they do on the base, which is the pin that the change is about the unshown slug and not about naming in general. On the branch alone, a roster whose primary is genuinely slugged primary is named Primary, because the roster holds it and the operator has seen it in the panel. test_generate_all_still_names_the_unnamed_primary (test_video_storyboard_e2e.py:2454-2460) and the display_name pins (test_character_sheets_flow.py:2494-2499, test_video_storyboard_e2e.py:1080) pass unchanged. No comment the card adds names a card, an item or a phase. Record docs/ops/s38-acceptance.md; open item #111 closed.
Out of scope. The remedy the thin-description sentence names, which is S39 (open item #113) and lands on top of this one. Which characters either gate refuses, which is S40 (open item #103) and lands last. Rewording the two no-record sentences or making the whole-board head print a part instead of a raw slug, both of which change text V46's tests pin for reasons this item does not touch. Any web change: all four sentences live in the server and nothing in apps/web holds a copy, measured by searching the tree for their text. The removed-library-row subject 'That character' (:1482-1484), which names a row the operator picked on the create form and is not an unshown slug.
Waits on / Unblocks. Nothing: _why_not_drawable, _unready_refusal and the sheet-pool refusal are all on main at 92a360f as V46 left them / S39 (open item #113) and S40 (open item #103), in that order, which both edit the same sentence and are cut from main after this merges; and every later refusal that has to name somebody on a session that never ran a roster call.
Built as (PR #120 → main 7efc5bf, 2026-09-20). character_cast gains one answer to what a sentence may call a character, and the four refusal sentences in the frame runner ask it. A session that never ran the roster call still has one character, and the cast resolves it to an internal slug whose readable form is a word the product never prints, because the cast panel builds its chips from a roster that session has none of. So an operator was told about a character they could not go and look at. The rule cannot live in the display name, because a roster whose lead genuinely carries that slug has a member the operator can see and the part is the right answer for them; what tells the two apart is the roster, so the new answer takes the cast and decides from it. The subject is capitalised in the runner and never stored twice, and the phrase itself is lower case because it also reads mid sentence. The display name, the part name and the primary slug keep their signatures and their answers, and the chip route and the two tests that pin them are untouched. Every sentence in the runner that names a character is tabulated with the reason it does or does not go through the new answer, and three that name a slug in the container log are marked as never read by an operator. Seven new tests fail on the base, each with its own sentence, and two more are pins that pass on both sides: a roster the operator can see is named exactly as before, and the sheet-pool sentence names a lead the roster holds. The base run of the two touched files is 7 failed and 167 passed; the branch runs the five character and storyboard files at 429 passed. No web file changed, and the secret scan was clean. Record docs/ops/s38-acceptance.md; open item #111 closed. Filed as open item #120: three operator sentences beside these name a cast member by the raw slug, and they never went through the display name, so they are not sites of this item.
S39 · The frame gate's remedy names the writer that exists, and prices it · S · S · S2-W0 · fix/s/s39-remedy-names-the-path (surfaced while building V46 on 2026-09-19, pre-existing on main, open item #113; carded 2026-09-20)
Context. Measured at main 92a360f. Two refusals send the operator to a save on the character panel. The thin-description sentence, which both frame gates read, ends Fill those in on the character panel and generate again. Nothing was spent. (apps/api/app/backend/services/storyboard_image_runner.py:2642-2646). The whole-board gate's lock-missing reason ends Save the description on the character panel, which writes the lock from it, and generate again. Nothing was spent. (:2958-2962). A third, in the writer itself, refuses a direct lock edit on a character that has fields with Edit the field you want to change instead. (apps/api/app/backend/services/storyboard_service.py:4817-4822). The panel splits on one line: legacy is a character with no identity fields at all and structured is its opposite (apps/web/src/components/video-storyboard/CharacterPanel.tsx:838-839). On a legacy record the advice is true. The panel draws the raw editor for it, an Identity lock box and a Sheet prompt box with a Save text button that patches the raw lock (:1216-1302, the button at :1275-1287), the server accepts a raw lock for exactly that record (storyboard_service.py:4817-4823), and the panel says what it costs: Editing text costs nothing and renders nothing. (:1295-1297). On a structured record it is false. The fields are drawn read-only (:1094, the grid at :1108-1160 and its comment at :1146-1152), there is no save and the panel states that as a rule with its reason: NO SAVE BUTTON, and that is the point. The fields are what the character IS, shown so it can be checked; changing one goes through the instruction box, which moves the words and redraws the picture from them in the same act. (:1185-1188), and the raw editor is withheld from it for the same reason, written out at :1216-1228. The state the gate refuses is reached both ways. A page that read as nothing leaves a sheet and no fields, which is legacy; a page that read below the floor leaves fields and a short lock, which is structured, and V46's table holds the first and the runner's own read path leaves the second (apps/api/app/pipeline/storyboard_images.py:704-723 and :738-747, stored at storyboard_image_runner.py:663-666). So one sentence covers two record shapes with two different writers and names the wrong one for the shape that is read-only. The writer that does exist for a structured record is the instruction box under Edit character, the control headed What should change? with a tweak and rework toggle and an Apply button (CharacterPanel.tsx:1305-1340), and it reaches the same place a save would: _instruct_fields applies the note to the fields and stores them through update_character_text, THROUGH THE SAME WRITER A HAND EDIT USES, which re-derives both strings together and enforces the same trait floor (storyboard_image_runner.py:1994-2060, the comment at :2035-2042, the floor's own refusal at :2049-2059). The decision, committed: the fix is the sentence, not a direct save. The panel's read-only fields are not an omission. They are the invariant that the words and the picture cannot describe different people, and a direct save would write fields and re-derive both strings (storyboard_service.py:4793-4812) while the sheet on screen still showed the person the old fields described, which is the drift the rule was written to remove. The instruction path closes that gap in one act by construction. It is also not free, and the current sentence is silent about it: the note is a paid text call recorded under character_instruct (:2032), a fraction of a penny by the panel's own line (:1385-1389), and a render follows only when the picture has to change (_no_render_needed, :1965-1991, the panel's Free unless the picture has to change. at :1377-1383). The refusal's Nothing was spent. is about the generation and stays true. V46 filed this and noted that no writer leaves the lock-missing state today (docs/ops/v46-acceptance.md:153-170 and :315-325).
Read first. apps/api/app/backend/services/storyboard_image_runner.py:2618-2647 (_why_not_drawable), :2916-2963 (_unready_refusal), :1965-1991 (_no_render_needed), :1994-2060 (_instruct_fields, its writer comment at :2035-2042 and the floor refusal at :2049-2059) and :2073-2190 (refine_character, the claim before the paid call at :2110-2118 and the free exit at :2156-2161). apps/api/app/backend/services/storyboard_service.py:4770-4845 (update_character_text, the derive at :4793-4812, the lock refusal at :4817-4823). apps/api/app/backend/routes/video_storyboard.py:1256-1277 (the patch route, which spends nothing) and :1280-1292 (the refine route). apps/web/src/components/video-storyboard/CharacterPanel.tsx:830-845 (legacy and structured), :1094-1160 (the read-only field grid and its comment), :1163-1192 (the derived lock, its errors and the no-save rule), :1216-1302 (the legacy raw editor, why it is withheld from a structured character, the Save text button at :1275-1287 and its price line at :1295-1297) and :1305-1400 (the instruction box, its modes, its Apply and its price line). apps/web/src/lib/videoStoryboardApi.ts:1454-1469 (what the patch accepts and what the server refuses on a structured character). apps/api/app/pipeline/character_identity.py:369-376 (is_structured) and :817-849 (blocking_reason). docs/ops/v46-acceptance.md:66-105, :112-121 and :153-170. Open items #113 and #111, with S38 (open item #111), which lands first.
Deliverables. (1) The remedy names the writer this record actually has, decided from the record the sentence is already holding. _why_not_drawable asks character_identity.is_structured on the record's identity: a record with fields is sent to Edit character on the character panel to say what to add, and a record with none is sent to the panel's Identity lock box to write one and save it. Nothing else in either sentence moves, so the subject and the reason that names the empty fields are exactly what they are today. (2) The price is in the sentence for the path that has one. The structured remedy says that the note costs a text call and that a redraw follows only if the picture has to change, in the panel's own terms, and it keeps Nothing was spent. for the generation, which is what that clause has always been about. The legacy remedy says the save costs nothing and renders nothing, which is what the panel says. (3) One wording, one place. The whole-board gate's lock-missing reason (:2958-2962) stops naming a save and names the writer through the same expression the shared sentence uses, so the two cannot drift, and it takes the same is_structured branch on the record it is holding rather than assuming a shape. It must ask, because the preflight's own test refuses every empty lock whether or not the record has fields (:3012-3013), and whether that reason is reached at all rests on an internal branch of blocking_reason, which answers an identity-less record from its stored lock alone (apps/api/app/pipeline/character_identity.py:840-843). A sentence that assumed the record were structured would be a second copy of that branch's conclusion, kept in a different file, with nothing to fail if the branch moved. (4) The writer's own refusal agrees. update_character_text's Edit the field you want to change instead. (storyboard_service.py:4817-4822) names Edit character, because a structured character has no field editor and that sentence has been pointing at one. (5) The panel changes nothing. Its read-only fields, its withheld raw editor, its three comments and its two price lines are correct and stay as they are, and this card's record says so with the reason, so the next reader does not take the sentences as evidence that a save is missing. (6) docs/ops/s39-acceptance.md: the two record shapes, the writer each one has and what it costs, the printed refusal for each, and the decision with the panel's own lines as its argument.
Interfaces. Exposes nothing new. Both frame gates keep reading one sentence from _why_not_drawable, and the whole-board gate keeps its head and its per-character shape. Consumes character_identity.is_structured, already imported by the runner. No route, no type and no component changes, so nothing in apps/web is touched and no client is affected.
Acceptance. The api tier green in the api job with the quarantine list deselected as CI does it (.github/workflows/ci.yml:216-221). Each of these fails on the base sources for its own reason: a record with fields below the floor is refused by the per-scene gate with a sentence that names Edit character and the note's cost and does not contain Fill those in, where the base names a save that record has no control for; the same record through the whole-board gate gives the same remedy in the same words, asserted by comparing the two messages rather than by repeating the text, which is the pin that they come from one place; a record with a sheet and no fields is refused with the Identity lock box and costs nothing, where the base gives it the same sentence as the structured record; the lock-missing reason on a structured record names Edit character and on an identity-less one names the Identity lock box, both where the base says Save the description on the character panel for either, which is the pin that it asks rather than assumes; and a patch that sends a raw identity_lock for a structured character is refused naming Edit character, where the base names a field to edit. On the branch alone, the skipped sentence and both no-record sentences are byte-identical to the base, which is the pin that only the remedy moved. V46's gate tests pass with their remedy assertions updated to the new text and every other assertion in them unchanged (apps/api/app/backend/tests/test_video_storyboard_e2e.py:2512-2582). npm test in apps/web green and unchanged, because nothing under apps/web is edited. No comment the card adds names a card, an item or a phase. Record docs/ops/s39-acceptance.md; open item #113 closed.
Out of scope. A direct field save on the panel, refused for the reason in the context and recorded with it: it would write fields and re-derive both strings while the sheet on screen still showed the old person, which is exactly what the panel's rule removes. Making the instruction path free, or short-circuiting it for a character below the floor: it is a model call and it is priced as one, and _instruct_fields already hands the claim back and charges no render when nothing an image model can see moved. Which characters either gate refuses, which is S40 (open item #103). The subject of the sentence, which is S38 (open item #111) and lands first. Any link from the storyboard screen's refusal banner to the panel, which is a web change and a question of its own. The panel's read-only fields, its withheld raw editor and the wording of its own price lines, all of which are measured correct.
Waits on / Unblocks. Waits on S38 (open item #111): both edit the one sentence in _why_not_drawable, #111 its subject and this one its remedy, so this is cut from main after that merges / S40 (open item #103), which folds the whole-board gate's lock-missing reason into the shared rule and whose tests assert the finished wording, so it is cut from main after this one; and every later refusal that has to tell an operator where a character is written.
Built as (PR #121 → main 5314a54, 2026-09-20). One expression decides which writer a refusal names, and both frame gates read it. Two refusals sent the operator to a save on the character panel, and only one of the two record shapes has one. A character with fields has no editor for them, because the fields are drawn read only and the instruction box is the only control that changes them; a character with no fields keeps the hand-written lock that the raw editor and its save button write, and on that shape the old advice was true and stays true in the new words. The remedy now names Edit character for the first shape and the Identity lock box for the second, and the writer's own refusal on a raw lock sent to a record that has fields was reworded the same way. The new expression asks the record rather than assuming it, because which records reach the whole-board gate's lock-missing reason is decided inside another function, and a sentence that assumed the record were structured would be a second copy of that branch kept in another file with nothing to fail if the branch moved. Nothing else in either sentence moves, and the subject stays what the card before it made it. Nothing under apps/web is edited: the read-only fields, the withheld raw editor and the two price lines are correct as they are, and the record says so plainly, so a later reader does not take these sentences as evidence that a save is missing. Neither sentence prices the control it names, because the panel and the api already disagree about that and a refusal that took a side would be a third answer in a third file. Six new tests fail on the base, the whole-board one comparing the two messages rather than quoting either, and the identity-less case reached by making the other function answer nothing. Three existing assertions move to the new remedy, and one test's name and docstring are corrected because the remedy is no longer a save on that shape, with no assertion added or removed. The base run is 9 failed and 418 passed; the branch is 427 passed. Record docs/ops/s39-acceptance.md; open item #113 closed. Filed as open item #121: the panel says a note is free unless the picture has to change, while the api records every such note as a spend attempt.
S40 · One readiness rule for both frame gates, and a generate all that names every scene it drops · S · M · S2-W0 · fix/s/s40-one-readiness-rule (surfaced while carding open item #96 on 2026-09-19, pre-existing on main, open item #103; carded 2026-09-20)
Context. Measured at main 92a360f. The two frame gates share their wording and not their rule. _why_not_drawable (apps/api/app/backend/services/storyboard_image_runner.py:2618-2647) is the shared wording V46 introduced; it answers skipped itself (:2630-2636) and asks character_identity.blocking_reason for everything else (:2640, the rule at apps/api/app/pipeline/character_identity.py:817-849, the floor MIN_TRAITS = 4 at :250 and its message at :761-770). The per-scene gate asks it: _require_cast_ready calls it at :2726 and raises its sentence at :2728. The whole-board gate does not. preflight_all keeps a three-part test of its own (:3012-3013): no record, skipped, or an empty identity_lock. Only after that does it reach _unready_refusal (:2916-2963) for the words. So the gates disagree in both directions, and one direction costs the operator a board. A character whose stored lock is present but whose description is below the floor passes the preflight and is then refused scene by scene. Measured with the pipeline's own functions at this commit: character_identity.derive({'gender': 'man', 'hair': 'short black hair'}, require_name=False) returns trait_count 2 and identity_lock 'man, short black hair'; blocking_reason on the record that writes returns Only 2 identity traits — 4 are needed or the frames will not hold one face. Gender, age and build count as one. Still empty: Age, Build, Skin, Facial hair, Eyes, Brows, Makeup, Distinguishing, Condition, Signature accessories, Outfit.; and the preflight's own test on the same record answers True, so it passes. A writer leaves exactly that record. describe_supplied_character never raises on a thin read and says so in its docstring (apps/api/app/pipeline/storyboard_images.py:692-727), prints the floor's errors and nothing else (:738-741), and returns the identity beside the short lock derived from it (:746-747); _save_supplied stores both through write_character (storyboard_image_runner.py:663-666). That is an eighth state, and V46's seven-state table does not hold it (docs/ops/v46-acceptance.md:112-121): its "identity fields only" row is below the floor with no lock, which the preflight does refuse. What happens next, line by line. claim_all (:3027-3050) walks _scenes_needing_frames (:2902-2913) and calls claim_frames (:3042-3043); claim_frames calls _require_cast_ready at :2813, before _refuse_if_generating at :2814 and before the first create_version at :2822, so the refusal costs nothing and leaves no row; claim_all catches it, prints one server log line (:3044-3046) and continues (:3047). The route then answers in one of two ways and neither names the dropped scenes (apps/api/app/backend/routes/video_storyboard.py:2816-2858): with every scene dropped it raises a 400 saying Could not reserve any scene for generation. Something may already be running — reload and try again. (:2843-2846), which is the wrong cause; with some claimed it submits the batch and returns {"started": True, **plan, "scenes_claimed": len(claims)} (:2858), and the client's type for that call declares neither scenes_claimed nor anything about the drops (apps/web/src/lib/videoStoryboardApi.ts:1811-1814) while the screen's notice counts plan.scenes_to_generate (apps/web/src/pages/video-storyboard/VideoStoryboardScreen.tsx:766-770), so the dropped scenes are announced as generating. The batch summary written to the audit covers the claimed scenes only (generate_all_candidates, :3053-3105, the summary shape at :3081 and the write at :3100), and the screen reads that summary for failures and warnings alone (StoryboardBatchSummary at videoStoryboardApi.ts:361-368, the banner at VideoStoryboardScreen.tsx:853-865), so no record names them either. With no row they are still in the next run's plan, and each later press drops them the same way; only the per-scene button shows the reason, because its route calls claim_frames directly (routes/video_storyboard.py:2802-2803) and _image_guard turns the same exception into a 400 (:910-938, the image-error clause at :933-934). The card's rule, committed: _why_not_drawable becomes the whole readiness answer for a stored record, and it gains the empty-lock branch that is today the preflight's fallback text (:2958-2962), so the two gates refuse the same set by construction. That branch's own state, fields at or above the floor with an empty stored lock, is one no writer leaves: V46 measured every writer of a character record and each derives the lock from the fields it writes (docs/ops/v46-acceptance.md:153-170), and a single member's prompt re-derives the lock from the identity anyway rather than reading the stored string (character_cast.member_lock, apps/api/app/pipeline/character_cast.py:399-425). So moving it into the shared rule refuses nothing that occurs and removes the last place the two gates can disagree. One doc line is wrong while the function is open: claim_all's docstring says a skipped scene's claims are released (:3035-3037), and the code releases nothing, because _require_cast_ready runs before the first row exists.
Read first. apps/api/app/backend/services/storyboard_image_runner.py:2618-2647 (_why_not_drawable), :2650-2745 (_require_cast_ready, the no-record branch at :2698-2715, the shared ask at :2726-2728), :2787-2826 (claim_frames, the gate at :2813 and the rows at :2820-2826), :2902-2913 (_scenes_needing_frames), :2916-2963 (_unready_refusal, the head at :2935-2944, the per-character reasons at :2946-2962), :2966-3023 (preflight_all, its comment at :2992-3002 and its test at :3012-3013), :3027-3050 (claim_all) and :3053-3105 (generate_all_candidates), with :617-690 (_save_supplied). apps/api/app/pipeline/character_identity.py:250, :748-802 (_validate) and :817-849 (blocking_reason). apps/api/app/pipeline/character_cast.py:111-142 (display_name, part_name), :305-341 (resolve_scene_cast) and :399-425 (member_lock). apps/api/app/pipeline/storyboard_images.py:692-757 (describe_supplied_character, the thin-read rule at :704-723). apps/api/app/backend/routes/video_storyboard.py:910-938 (_image_guard), :2776-2813 (the per-scene route) and :2816-2858 (generate_all_stills). apps/web/src/lib/videoStoryboardApi.ts:361-368 and :1811-1814; apps/web/src/pages/video-storyboard/VideoStoryboardScreen.tsx:600-620, :762-772 and :848-868. docs/ops/v46-acceptance.md:66-105 (the reasons as they print), :108-152 (the seven states and where the two questions differ) and :153-170 (every writer and the state it leaves). Open items #103, #111 and #113, with S38 (open item #111) and S39 (open item #113), which land first.
Deliverables. (1) One readiness rule. _why_not_drawable(slug, record) answers every refused state of a stored record and is the only such answer in the module: the skipped branch as it stands, the blocking_reason branch as it stands, and a third branch for a record whose stored identity_lock is empty, carrying the sentence that is the preflight's fallback today, verbatim. Its docstring says that both gates ask it and that an absent record stays the caller's own question, as it does now. (2) The whole-board gate asks it and nothing else. preflight_all's own three-part test is gone; per resolved slug the gate reads the record once, as it does today, and refuses when the record is absent or when _why_not_drawable answers. _unready_refusal keeps the head, the per-character scene count and the all-absent sentence it has, and its per-character reason is _why_not_drawable's answer with no or fallback, because there is no longer a state it does not answer. The comment at :2954-2957 that names the one state the per-scene gate passes goes, because that state is now refused by both. (3) The per-scene gate is unchanged except that it now refuses the empty-lock state too, which it inherits from the shared rule. (4) Every scene claim_all still drops is named with its reason. claim_all returns the claims and the drops together, each drop carrying the scene id, its position and the refusal's own sentence, and it keeps the log line. Its docstring says what it does and what is left for it to do: after (2) no statically unready character reaches it at all, because the whole-board gate refuses that board synchronously before a claim is attempted, so what it still drops is a race, a scene that began generating between the preflight and the claim (_refuse_if_generating, :2814), a scene whose prompt was cleared in the same window, or a write that failed. Those are the drops the contract is for. The docstring also says that a dropped scene leaves no row, because the gate runs before the first one is written. (5) The route's answer names them. With nothing claimed, the 400 carries the drops' reasons rather than the reload sentence, which stays only for the case where there is no reason to give. With some claimed, the answer carries scenes_claimed and the dropped scenes with their positions and reasons beside the plan. The per-scene route is untouched. (6) The client declares what the route sends, and the screen says it. generateAllStills's return type gains scenes_claimed and the dropped list; the notice counts the scenes claimed, not the plan's scenes_to_generate, and when any scene was dropped it says which and why in the screen's own words around the server's sentences. (7) The batch record names them. generate_all_candidates takes the drops with the claims and writes them into last_batch, StoryboardBatchSummary declares the field, and the screen's existing batch banner reports a dropped scene the way it reports a failed one. (8) docs/ops/s40-acceptance.md: the eighth state and how it is reached, the refused sets of both gates before and after with the one state each gained, the printed answer of the route in both shapes, the bite proofs, and the measured counts.
Interfaces. Exposes _why_not_drawable as the single readiness answer both gates ask, which every later gate on a stored character record asks too. Exposes the dropped-scene shape on claim_all's return, on the route's answer and on last_batch, and scenes_claimed on the client type. Consumes character_identity.blocking_reason, character_cast.resolve_scene_cast and member_lock, svc.read_character and svc.record_audit, all unchanged. The per-scene route, claim_frames's signature and the claim's row shape do not move.
Acceptance. The api tier green in the api job with the quarantine list deselected as CI does it (.github/workflows/ci.yml:216-221), and the whole tier's pass count recorded beside the base's. Each of these fails on the base sources for its own reason, over the storyboard fixtures, and the two halves of the change are proven by two different fixtures, because after (2) an unready character never reaches claim_all. The new gate, proven on a board of two scenes, one cast with a character whose supplied read found two traits and one claimable beside it, either using no character or using a ready one. The second scene is not optional and the base expectation rests on it: with the unready scene alone, the base's claim_all returns no claims and the route answers its existing generic 400 (routes/video_storyboard.py:2842-2846), so the two sides would differ only in wording. With it, on the base the preflight returns a plan of two, claim_all drops the unready scene with a log line and the route starts a run answering scenes_claimed of 1 against scenes_to_generate of 2; on the branch the preflight refuses the whole board synchronously with the floor's sentence naming the empty fields, the route answers 400 with that sentence, nothing is spent, nothing is claimed for either scene, and claim_frames is not called at all, asserted by a counter on it, which is the pin that the refusal is the preflight's and not a drop. The drop contract, proven on a board that passes the preflight and then fails inside claim_all, with claim_frames made to refuse the second of two scenes with a sentence of its own: the route starts the batch and its answer carries scenes_claimed of 1 beside that scene's position and its reason, where the base answers scenes_claimed alone and names nothing; the notice counts one scene and names the other; and last_batch carries the same drop after the batch, where the base summary has no field for it. With the same fixture made to refuse both scenes, the 400 carries both reasons, where the base gives the reload sentence. On the branch alone: the preflight and the per-scene gate refuse the same record in every one of the eight states, asserted state by state against one table in the test, which is the pin that stops them drifting again. V46's existing gate tests still pass unchanged except the two that assert the preflight's fallback wording, which now come from the shared rule and assert the same sentence from the one place (apps/api/app/backend/tests/test_video_storyboard_e2e.py:2554-2582), and test_generate_all_passes_a_character_that_has_a_lock keeps both of its cases, because a legacy record is exempt from the floor (character_identity.py:840-849) and a fully ready one answers None. npm test in apps/web green with a new case on VideoStoryboardScreen.test.tsx that fails on the base: the notice after a generate all that dropped a scene names it and counts the scenes claimed. npm run lint, npx tsc -p tsconfig.app.json --noEmit and npm run build clean. No comment the card adds names a card, an item or a phase. Record docs/ops/s40-acceptance.md; open item #103 closed.
Out of scope. Letting a thin supplied read refuse at upload: describe_supplied_character states why it never raises there, and the refusal belongs where the money is (storyboard_images.py:704-723). Making the per-scene gate pass the empty-lock state on the grounds that member_lock re-derives, which would widen the drawable set rather than align the gates. Any change to what the preflight counts or to the shape of the plan it returns. The subject of the refusal sentence and the remedy it names, which are S38 (open item #111) and S39 (open item #113) and land first. The panel's read-only identity fields. Partial rows from a create_version that fails part way through claim_frames's comprehension (:2821-2826), which is a database failure and its own question.
Waits on / Unblocks. Waits on S38 (open item #111) and S39 (open item #113): all three edit _why_not_drawable's one sentence, #111 its subject and #113 its remedy, and this card's tests assert the finished wording, so it is cut from main after both merge. It also shares storyboard_image_runner.py with S41 (open item #114), so whichever of the two merges second re-runs its checks on the first one's merge commit / every later question about whether a character can be drawn, which now has one answer, and every later reader of a generate all's result, which now learns what was not started and why.
Built as (PR #124 → main 3b05a5d, 2026-09-20). The two frame gates shared their wording and not their rule, so each passed a state the other refused. One readiness expression now answers both, with three branches, and the whole-board preflight's own three-part test is gone. The refused sets are measured state by state on the same board, before and after, and exactly two rows move, one in each direction. The per-scene gate gains fields at the floor with no lock, which no writer leaves. The board gate gains the thin read, a supplied sheet that read below the floor and left the short lock derived from exactly those fields, which a writer does leave and which cost an operator a board: the preflight accepted it and the per-scene gate then refused every scene inside the batch. That eighth state is measured with the pipeline's own functions, two traits against a floor of four. Every scene the board run dropped used to be a server log line and nothing else, because the answer counted the scenes planned, so a dropped scene was announced as generating, stayed in the next press's plan and was dropped again the same way. The claim call now returns its claims and its drops, the route carries the claimed count and the drops, the client contract declares both, the notice counts what was claimed and names the rest with the server's own sentence for each, and the batch banner reports a dropped scene the way it reports a failed one. The reload sentence stays for the case it was right about, nothing claimed and nothing dropped. One wrong doc line went with it: the claim call said a skipped scene's claims are released, and nothing was released, because every gate runs before the first version is created. Six new api tests over thirteen ids and one new web test fail on the base; five existing tests move to the new return with no assertion changed, and the two gate tests the card expected to move read their sentence from the shared rule and pass untouched. The base run is 11 failed and 135 passed; the branch is 556 passed, and the web file is 13 passed against 1 failed on the base. Record docs/ops/s40-acceptance.md; open item #103 closed. Filed as open item #125: the batch note holds one batch, so a board pressed twice keeps the second press's drops and loses the first's.
S41 · Only the stage's own error is a cast that could not be read · S · S · S2-W0 · fix/s/s41-only-the-stage-error-degrades (surfaced while reviewing S37 on 2026-09-19, open item #114; carded 2026-09-20)
Context. Measured at main 92a360f. ensure_cast wraps the cast call in a try (apps/api/app/backend/services/storyboard_image_runner.py:176-178), lets the gateway's own refusals through by type (:179-182), and catches everything else (:183). That clause prints the cast could not be read (:187-188), records any paid attempts the error carries (:192-193) and saves an empty roster marked failed with the error's text (:194), after which the session behaves as a one-character session for the rest of its life. Its comment states the assumption it rests on: Every failure of the model call arrives as StoryboardLLMError. (:184-186). That was true before S37 and is not true now. generate_cast ends in run_with_fallback (apps/api/app/pipeline/storyboard_llm.py:1108), which loads the instruction first (:293-294), and load_instruction now reads through the shared loader and catches only the loader's own refusal, by type (:204-229, the catch at :227-228). Its docstring says why in the card's own words: a failure to read the row is not converted, because the operator's own instruction may exist and could not be fetched, and a fallback there would run a prompt somebody deliberately replaced. The read itself has no catch of its own: _read_instruction opens a cursor and selects the row (apps/api/app/pipeline/utils/file_io.py:77-82), and the loader's InstructionRefused is deliberately its own type because a read can raise a ValueError and the two mean opposite things (:13-25). So a database failure during the prompt read now travels from the cursor to :183 and the operator is told the cast could not be read when no model call was made. S37's record states this and files it (docs/ops/s37-acceptance.md:110-116 and :436-440). Two things the item assumed need correcting against the tree. First, nothing is billed. record_spend_attempts writes one row per attempt and a driver error carries none: getattr(exc, "attempts", None) is None on a psycopg2.OperationalError, measured, and the loop over None writes nothing (apps/api/app/backend/services/storyboard_service.py:4100-4120). The harm is not a charge, it is the silent failed roster. Second, the lines either side of the try already propagate: svc.get_cast at :161 and _launch_config at :165 are database reads outside it, so a blip one line earlier fails the call today and a blip one line later does not, which is the inconsistency the fix removes. What else can reach :183 that is not the stage's error: run_with_fallback converts a ValueError from parse into StoryboardLLMError with its attempts (storyboard_llm.py:299-351), and parse for this stage is character_cast.normalise_roster (:1097-1101), so an ordinary unusable answer is already the right type; a normaliser bug that raised something other than a ValueError would escape as itself, and under today's clause it is swallowed with no attempts recorded either, so narrowing costs the ledger nothing. Where a propagating error lands: the sheet-preparation caller has its own broad catch and logs (storyboard_image_runner.py:970-975), the shot writer's caller runs inside the prompt runner's own handling (apps/api/app/backend/services/storyboard_prompt_runner.py:127), and the build path (:1397, :1413) answers through _image_guard, which converts only the storyboard error types and lets anything else become a 500 (apps/api/app/backend/routes/video_storyboard.py:910-938). That is the right answer for a database failure and it is what S37 chose everywhere else. One existing test encodes the old assumption: test_a_failed_roster_call_degrades_to_one_character_rather_than_blocking raises a bare ValueError from the stub and asserts the failed roster (apps/api/app/backend/tests/test_video_storyboard_e2e.py:2243-2257), a type the boundary never produces.
Read first. apps/api/app/backend/services/storyboard_image_runner.py:143-238 (_save_empty_cast and the whole of ensure_cast: the docstring's never-raises stance at :152-157, the reads outside the try at :161-169, the try at :176-178, the gateway clause at :179-182 and the broad clause at :183-194), :960-976 and :1390-1420 (its callers), with apps/api/app/backend/services/storyboard_prompt_runner.py:118-140. apps/api/app/pipeline/storyboard_llm.py:143-190 (StoryboardLLMError, what each failure class did or did not buy, and that the gateway's refusals are never converted), :204-229 (load_instruction), :248-360 (run_with_fallback, the load at :293-294 and the three raises that carry attempts) and :1059-1108 (generate_cast). apps/api/app/pipeline/utils/file_io.py:13-25 (InstructionRefused and why it is its own type) and :64-93 (_read_instruction, the uncaught read at :77-82). apps/api/app/backend/services/model_gateway.py:260-265 (GATEWAY_ERRORS). apps/api/app/backend/services/storyboard_service.py:4100-4120 (record_spend_attempts). apps/api/app/backend/routes/video_storyboard.py:910-938 (_image_guard). apps/api/app/backend/tests/test_storyboard_image_runner_gateway_refusals.py:60-90 (the refusal and degradation pair) and apps/api/app/backend/tests/test_video_storyboard_e2e.py:2243-2257. docs/ops/s37-acceptance.md:95-116 and :430-440. Open item #114.
Deliverables. (1) The clause narrows to the stage's own error. except storyboard_llm.StoryboardLLMError as exc takes the degrade branch, keeping its sentence, its attempts record and its failed roster exactly as they are; the gateway clause above it is unchanged; every other exception propagates and nothing is written. (2) getattr stops standing in for a type the clause no longer catches: attempts are read off the error directly, because StoryboardLLMError always carries the list and its own docstring says so (storyboard_llm.py:183-189). (3) The docstring and the comment say what is now true. ensure_cast's never-raises paragraph becomes the rule it actually keeps: a roster the model could not produce degrades to the single primary, and a failure that is not the model's, a gateway refusal or anything else, is raised to the caller, because a session that silently becomes a one-character session on a database blip is a board built from a state nobody chose. The comment at :184-186 stops asserting that every failure arrives as one type and says which one takes this branch and why the rest do not, and it names no card, item or phase. (4) The test that encodes the old assumption is corrected rather than deleted: its stub raises the error the boundary produces, and it keeps asserting the empty failed roster and the error text, because that behaviour is the one this card preserves. (5) docs/ops/s41-acceptance.md: the failure classes that reach the cast call and which branch each takes, where a propagating error lands for each of the four callers, the measurement that a driver error carries no attempts and so was never billed, and a note that the same broad clause shape exists at the shot-variation call (:2769-2779) with the same getattr, left alone here and filed as its own open item.
Interfaces. Exposes nothing new. ensure_cast keeps its signature and its return, and its contract narrows: it still never raises for a model failure and now raises for everything that is not one. Consumes StoryboardLLMError and model_gateway.GATEWAY_ERRORS as they stand. No route, no client and no web change; the four callers are untouched, and the two that already wrap it in their own handling keep answering as they do.
Acceptance. The api tier green in the api job with the quarantine list deselected as CI does it (.github/workflows/ci.yml:216-221). Each of these fails on the base sources for its own reason, driving ensure_cast with the suite's existing session fixture: a stubbed generate_cast raising a psycopg2.OperationalError propagates that exact object, writes no cast and records no spend, where the base swallows it and saves a roster with source failed; the same failure raised from inside the real load_instruction path, with the cursor made to fail, propagates for the same reason, which is the path S37 opened; a stubbed generate_cast raising StoryboardLLMError still degrades to the empty failed roster with its text in error, unchanged from the base; the same error carrying two attempts records two spend rows under cast, unchanged from the base; and every member of model_gateway.GATEWAY_ERRORS still raises and writes nothing, which the existing parametrised test already covers (test_storyboard_image_runner_gateway_refusals.py:67-87) and which must stay green. On the branch alone, a TypeError from the cast call propagates and writes nothing, which is the pin that the clause is a type and not a shape. Through the build route, a database failure at the cast answers 500 rather than 200 with a board, measured once and recorded. test_a_failed_roster_call_degrades_to_one_character_rather_than_blocking passes with its stub corrected and every assertion kept. No comment the card adds names a card, an item or a phase. Record docs/ops/s41-acceptance.md; open item #114 closed.
Out of scope. Restoring the loader's old database-blip fallback, which S37 removed deliberately and recorded its reason for (docs/ops/s37-acceptance.md:95-108): running the shipped text over an edit that could not be read is the failure that change exists to prevent, and putting it back here would undo it for one stage. The shot-variation clause at :2769-2779, which has the same broad shape and the same getattr and reaches a different set of callers; it is measured in the record and filed as its own open item rather than changed under a card about the cast. _record_wardrobe_leak_scan and the sheet-read catch at :965-969, which the docstring names as the same stance and which this card does not touch. Any change to what a degraded roster means downstream, to _save_empty_cast, or to the sentence the operator reads when the model genuinely could not be used.
Waits on / Unblocks. Nothing: S37 is merged at main 56175db and the loader is on main at 92a360f in the shape this card reads. It shares storyboard_image_runner.py with S38, S39 and S40 (open items #111, #113 and #103), in a region none of them edits, so whichever merges last re-runs its checks on the previous merge commit / every later reading of a storyboard failure sentence, which from here names what actually failed.
Built as (PR #125 → main 752b8b6, 2026-09-20). The broad clause around the cast call is now a type. The storyboard error takes the degrade branch, with its sentence, its attempts record and its failed roster unchanged, and everything else propagates with nothing written. The clause rested on an assumption its own comment stated, that every failure of the model call arrives as that one type, and the shared loader ended it: a database failure during the prompt read now travels from the cursor into the clause, so the operator was told the cast could not be read when no model call was made, and the session kept an empty roster every later stage reads as nobody being in the ad. Every failure class that reaches the cast call is tabulated with what it does before and after, and the reads either side of the try already behaved this way, so a blip one line earlier failed the call and one line later did not. Measured on the build route with a driver error raised at the cast call: the base answers 200 with an empty failed roster carrying the driver's sentence and saves one roster, and the branch answers 500 and saves none. The item assumed a spend row and there was none: the driver's error carries no attempts, so narrowing the clause costs the ledger nothing, and the defensive read goes with the broad clause because the storyboard error always carries its own list. Three new tests fail on the base and a fourth is a pin that passes on both sides. One of the three drives the real stage by making the cursor in the loader fail rather than by raising from a stub, and one pins that the clause is a type and not a shape, with an error that carries an attempts list of its own. Two existing tests that raised a type the boundary never produces are corrected rather than deleted, every assertion kept. The base run is 3 failed and 13 passed; the branch runs 162, 349 and 134 passed over every targeted file that drives the cast or the routes above it. Record docs/ops/s41-acceptance.md; open item #114 closed. Filed as open item #127: two more copies of the same broad catch, at the shot variation and at the sheet match.
S42 · The delivery fixtures own every path they write · S · S · S2-W0 · test/s/s42-delivery-fixture-paths (surfaced while building S37 on 2026-09-19, open item #115; carded 2026-09-20)
Context. This card fixes a defect proved by reading the file, on its own evidence, and it says at the top what it cannot claim: the link to the failure open item #115 records is unproven and stays unproven. The defect is that apps/api/app/backend/tests/test_video_storyboard_delivery.py chooses temporary paths it does not own. It is the only user of tempfile.mktemp in the whole api, five sites, at :52-67, :75-88, :93-105, :110-121 and :144-160, and a grep over apps/api/app finds no sixth anywhere, inside the tests or outside them. mktemp picks a name in the shared temporary directory, checks that nothing holds it and hands it back reserving nothing, so from that moment any process on the box may take it; each of the five sites then writes to that unowned name and, in a finally, unlinks whatever is sitting at it by the time the call ends (:65, :86, :103, :119, :158). On this box the shared directory is the root ext4 filesystem, 145 G at 82 % used, shared with every other process, the dev stack and any other test run. So the file both trusts a path it does not own and deletes by name, and a collision damages the other writer as well as itself. The production helper two directories away does the opposite: storyboard_ffmpeg._tmp reserves its name with NamedTemporaryFile(delete=False) (:72-79) and _cleanup removes only what it made (:82-87). That asymmetry is the whole card. What open item #115 records is separate and is written down here so the next reader does not conflate the two. Two ffmpeg tests in this same file failed once in a busy subset run and passed every time after: ::test_a_short_line_does_not_truncate_the_picture (:209) and ::test_the_raw_variant_is_the_providers_bytes_untouched (:232). That subset cost 8 min 26 s; the same subset, run again with nothing changed, cost 2 min 02 s and was clean but for the one quarantined test, and both whole tier runs were clean at one-minute loads of 3.76 and 3.56 in 11 min 30 s and 11 min 35 s (docs/ops/s37-acceptance.md:301-327). The record that held the two failure messages was in the S37 worktree, which is gone, and the merged record does not carry them, so what those two tests actually reported is not known and no card can honestly say it fixes it. Two of the three candidate causes were eliminated at 92a360f anyway, and the figures belong in the record whatever happens next. The subprocess ceilings are not a candidate: the run stretched 4.1 times, and the ceilings in this path are 300 s for every call mix_narration and strip_audio make (_TIMEOUT_S at apps/api/app/pipeline/utils/storyboard_ffmpeg.py:47, through _run at :58-69 and media_ffmpeg.run_tool at :92-105), 120 s for each of the three fixture makers (:60, :81, :98), 120 s for the volume probe (:151) and 60 s for ffprobe (:115), against ffmpeg calls that cost well under a second on this box; the one tight ceiling, 30 s at :34, runs at collection and a timeout there would skip all fourteen ffmpeg tests rather than fail two. The duration assertion is not a candidate either, and this was measured rather than argued. The exact fixtures and the exact filtergraph mix_narration builds (storyboard_ffmpeg.py:285-303) were rebuilt at 92a360f with the box's own ffmpeg 6.1.1: a 2.000000 s clip with a 0.5 s line gives a container of 1.903991 s, a video stream of 1.875000 s at 45 frames where the source has 48, and an audio stream of 1.903991 s at 83 frames. Ten consecutive runs beside a six-process busy neighbour, at a one-minute load rising from 2.68 to 3.98, gave those figures bit for bit every time, so the number the assertion at :226-228 reads does not move with load. Its margin is thin and belongs in the record as its own fact: the tolerance is 0.15 s and -shortest already spends 0.096 s of it, leaving 0.054 s, which is about two AAC frames at 44,100 Hz and 1,024 samples per frame. None of that names a cause for the two failures, and this card does not invent one. The file is 1,719 lines with 77 tests, 14 of them marked @requires_ffmpeg (:39-41), so the change is small and bounded. There is no pytest.ini, no pyproject.toml, no setup.cfg and no tox.ini under apps/api: pytest runs on its defaults, and the deselect list is assembled by the CI step from apps/api/ci-known-failures.txt (.github/workflows/ci.yml:216-221), whose header says a quarantine is not a fix and whose three lines include one test in this same file. Nothing joins that list here.
Read first. apps/api/app/backend/tests/test_video_storyboard_delivery.py:1-16 (the module docstring, which states why these tests run real ffmpeg against fixtures generated at run time and why mocking it would prove nothing), :29-41 (the ffmpeg probe and the skip marker), :44-122 (the three fixture makers and _probe, four of the five unowned-name sites with their finally unlinks), :124-160 (_streams, _duration and _mean_volume_db, the fifth site and the comment explaining why the ducking is measured in the tail), :163-207 (the three ffmpeg tests that did not fail) and :209-250 (the two tests open item #115 names, with their docstrings). apps/api/app/pipeline/utils/storyboard_ffmpeg.py:41-49 (the two loudness constants and the two timeouts), :58-87 (_run, _tmp and _cleanup, the reserved-name pattern this card copies), :90-102 (strip_audio), :104-169 (probe_clip and has_audio_stream) and :250-307 (mix_narration, its docstring on apad with -shortest, and the filtergraph both named tests exercise). apps/api/app/pipeline/utils/media_ffmpeg.py:74-75 and :92-105 (the shared ceiling and thread pin, and the pin=False the storyboard passes). apps/api/app/backend/tests/conftest.py (whole: the suite-wide guards, none of which touches temporary files or ffmpeg). apps/api/ci-known-failures.txt and .github/workflows/ci.yml:194-221 (the api job, ffmpeg installed at :209-210, the tier step at :216-221). docs/ops/s37-acceptance.md:301-327 (the four runs, their loads and their walls). docs/ops/v37-acceptance.md:15-48 (the neighbour and the ladder this card's load run copies) and :80-97 (how a negative result is recorded and why it is recorded as one). Python's tempfile documentation on mktemp, which states the race in its own words and names NamedTemporaryFile as the replacement. AGENTS.md:32 onward for the api command. Open item #115.
Deliverables. (1) The five unowned names go. Each of :52-67, :75-88, :93-105, :110-121 and :144-160 takes a name the operating system reserved for it, in the pattern storyboard_ffmpeg._tmp already uses at :72-79, so a path this file writes to is a path nothing else can be handed. After the card, a grep for tempfile.mktemp over apps/api returns nothing. (2) Every temporary file this module makes lands in a directory pytest created for that one test and owns. One autouse fixture in the file points tempfile at pytest's tmp_path for the duration of each test by setting tempfile.tempdir, with a comment saying why it is the attribute and not the environment variable, which is that gettempdir caches its answer the first time it is asked and an exported variable set later would not move a call that has already run. Because both the file's helpers and storyboard_ffmpeg._tmp go through gettempdir, that one fixture also keeps the production helper's files for these tests out of the shared directory, and no helper signature and no call site changes. (3) The five os.unlink calls in finally blocks go. Nothing deletes by name any more; the per-test directory is pytest's to clean and it keeps the last three runs, so a failed run leaves its own inputs and outputs on disk to be read. The comment that replaces them says that, and says it is what was missing when the two failures open item #115 records could not be diagnosed. (4) A test that two concurrent callers cannot collide: two threads, or two processes, each driving the file's own fixture makers and probes at the same time many times over, asserting that no two callers were ever handed the same path, that every caller read back exactly the bytes it wrote, and that no caller's file was removed by another. On the base sources the same test is written against mktemp's contract and shows the hole directly, by handing a name out and then taking it before the writer uses it, which is the race in one assertion rather than a probability. (5) The duration test states what it measures, in numbers rather than in a tolerance nobody can size. Its comment carries the measurement: for a 2.000000 s clip with a 0.5 s line the mix already comes back at 1.903991 s with 45 video frames where the source has 48, so -shortest spends 0.096 s of the 0.15 s allowance and about 0.054 s is left, and the tolerance covers that trim and nothing else. Its assertion message prints both container durations and both frame counts, so a future failure names the number that moved. No tolerance changes. (6) One run under a deliberate load, for evidence and not for a fix, in V37's method: a neighbour started from a script that records its pids, the contention before it starts, samples during the run and the wall time, climbing its ladder until the subset's wall reaches the 4.1 times the failing run showed, with the ladder recorded rung by rung, over the subset the failure was seen in, on the base and on the branch. If nothing reproduces, that negative result is recorded as one, the way V37 recorded its case 1, and the card stands on its own evidence unchanged. If a failure does reproduce, the builder captures the message verbatim into the record and files a new open item for it, and changes nothing else in this card: a cause discovered mid-build is the next card's work, not this one's. (7) The record docs/ops/s42-acceptance.md: the unsafe-path defect and its five sites, the collision test on the base and on the branch, the two ruled-out candidates with the figures above, the ten-run bit-identical result under the six-process neighbour, the 0.054 s margin, the ladder and every load run with its samples, and one plain paragraph saying that the cause of the two failures open item #115 records is still unknown. It files one new open item with its measurement, that -shortest with -c:v copy drops three frames and 0.096 s from every voiced clip of this shape, which is a delivered product shorter than its picture and is not a test problem. (8) Open item #115 is not closed by this card. Its row is updated to carry the two eliminations with their figures, to say the observed failure's cause is unknown and that the record it was written from no longer exists, and to point at this card for the unsafe-path defect that was found while reading it. The only way it closes here is if deliverable (6) reproduces the failure and the captured message proves the unsafe path was the cause, and the record then says so with the message quoted.
Interfaces. Exposes nothing outside this test module: five reserved names, one autouse fixture, five removed unlinks, one collision test, one comment and one assertion message. Consumes storyboard_ffmpeg.mix_narration, strip_audio, has_audio_stream and _tmp exactly as they are, media_ffmpeg.run_tool's ceilings exactly as they are, and pytest's tmp_path, which needs no plugin. No production file changes, no ffmpeg argument changes, no ceiling and no tolerance moves, and nothing joins or leaves apps/api/ci-known-failures.txt.
Acceptance. The collision test is the card's own proof and it fails on the base sources for the reason the card names, with mktemp's handed-out name taken before the writer uses it, and passes on the branch, where no name can be taken. A grep for tempfile.mktemp over apps/api returns nothing. The file's own run green from apps/api with the venv's interpreter, all 77 tests, on a quiet box and again at the rung the load run settled on. The whole api tier green with the CI deselect list, 3 deselected as today, and its wall recorded against S37's 11 min 35 s, because this card changes a test module's temporary-file handling and a tier run is what shows it cost nothing. The api job green on the pull request, whose step ran in 228 s to 229 s on recent runs. Three recorded proofs that the fixture is real and not cosmetic: a print of tempfile.gettempdir() inside a test in this file names that test's own directory, names a different one for the next test, and names the shared one on a scratch copy with the fixture removed; a deliberately failed run leaves its fixtures on disk under the test's directory, where before the change it left nothing. The two tests open item #115 names still fail for their own reasons on mutated sources, which proves the card did not soften them: switching the mix to duration=shortest and removing apad together fails the duration test, as its own docstring at :218-222 states is the only mutation that does; making the raw variant go through ffmpeg fails the other. The load run of deliverable (6) recorded with its ladder and its samples, and its result stated either way. python -m py_compile clean on the changed file. No comment the card adds names a card, an item or a phase. Record docs/ops/s42-acceptance.md; open item #115 stays open with its row updated, unless the load run proves the link and the record quotes the message.
Out of scope. Claiming this card fixes the two failures open item #115 records, which the evidence does not support and which the card states plainly in its context, its record and that item's row. Fixing any other cause the load run turns up: the message is captured and a new open item is filed, and nothing else in the tree is touched, because a cause discovered mid-build is the next card's work. Raising the 0.15 s tolerance or any of the six subprocess ceilings, both ruled out by the measurements in the context, and a rerun or a retry, which rule 5 forbids. Adding anything to apps/api/ci-known-failures.txt, whose own header says a quarantine is not a fix. The three-frame trim -shortest makes on every voiced clip, which is a product defect rather than a test defect, and which the record files as its own open item with the frame counts. The temporary-file handling of any other test module, because a grep over apps/api finds this file is the only one using an unowned name. The api tier's whole-run load sensitivity, which is the same class of question as open item #112 in the web tier. The production helpers in storyboard_ffmpeg.py, which already reserve their names and clean up only what they made.
Waits on / Unblocks. Nothing: the file, storyboard_ffmpeg.py and media_ffmpeg.py are unchanged on main at 92a360f, S37 is merged, and no open card edits any of them. The one card that would meet it is A35 (#99), which sweeps comments repository-wide and will meet the three comments this card adds, so whichever merges second re-runs its checks on the first one's merge under rule 1's shared-surface test. Unblocks every later api record that has to read a tier run taken on this shared box, because a failing ffmpeg test in this file will from now on leave its own fixtures on disk with the durations and frame counts printed beside the assertion, which is what would have let open item #115 be answered instead of carded.
Built as (PR #132 → main a2b0ff8, 2026-09-20). One test file changes. Five temporary paths the file did not own are gone: the name picker it used reserves nothing, so any process on the box could take the name between the call and the write, and each site then deleted by name whatever sat there, which damaged the other writer as well as itself. The five now call the production helper two directories away, which reserves its name and removes only what it made, consumed exactly as it is, with no production file, helper signature or call site changed. One autouse fixture points the temporary directory at pytest's own for each test, through the attribute rather than the environment variable, because the default is worked out once per process and a variable exported after that first call would move nothing; because the file's helpers and the production helper both reach it the same way, that one fixture also keeps the production helper's files out of the shared directory. Nothing deletes by name any more, so a failed test now leaves its own inputs and outputs on disk. A collision test runs two callers held together at a barrier, four rounds each, driving all five sites, twenty paths per caller, and asserts that every name was reserved before it was handed out, that the bytes at each path are its own caller's, that no two callers were handed one path, and that every path is inside the directory the test owns. The hole is made certain rather than probable by replacing the unreserving picker with a stand-in that keeps its contract. The test fails on the base, passes on the branch, and fails again on a copy with one site alone put back. Three proofs show the fixture is real, two mutations of the production sources show the duration test and the raw-variant test still fail for their own reasons, and the timing allowance is sized from measured stream durations rather than guessed at. A deliberate load ladder was run to 4.39 times the subset's own wall, past the 4.1 the original failing run showed. Record docs/ops/s42-acceptance.md. Open item #115 stays open and its cause is still unknown: the two tests it names did not fail at any rung, on the base or on the branch, and the link between them and the unowned paths is unproven and stays unproven. Two findings filed: open item #132, a product defect where the voiced clip carries less picture than the raw clip it was cut from, and open item #133, a delivery test that failed once at the eight-process rung with a message that cannot say why, captured because this card's own change left its fixtures on disk.
A4 · Intelligence data-path proof + pressure test → ADR-020 · A · L · S2-W1 · spike/a/a4-intelligence-data-path
Context. Decided: real port to direct Postgres, no PostgREST. A4 proves it before A9 commits — schema, RPC semantics, load on the shared box. Measured while planning (2026-09-14): the unified mirror at c0ac54a is the standalone repository at d842453 (24 .rpc() sites / 22 functions; the S2 facts paragraph above describes the older 62268df: 18 / 17) and it is refreshed by A4a first; 357 data-access sites; the admin server writes through the anon key at 86 sites; 48 selects rely on PostgREST's 1000-row maximum; ad_rank_snapshots carries RLS with auth.role() policies; the 38 migration files are unnumbered and replay only in a derived order; ADR-003 is the lineage contract, so the decision record is ADR-020; the X3b sampler is pgbench select 1 against the ecomprofits pooler; 8 cores / 24 GB for engine + transcriber on top of the S1 stack is 20 CPU / 50 GB, past architecture §9's 12 CPU / 32 GB tenant budget. S2's entry gate (then the S1 exit gate true in production) was not met; the owner opened A4 for its local legs on 2026-09-14, and ADR-020 recorded that deviation, which the owner's decision of 2026-09-19 ends (see the amendment below).
Read first. A4a's acceptance record (the refreshed SHA); the mirror's migrations/*.sql, server.js, lib/**, admin/**; infra/smoke/pressure.sh; infra/scripts/compose-limits.py; architecture §9; docs/ops/RESOURCE_LIMITS.md; docs/decisions/.
Deliverables — gated, in order; every artefact keys to A4a's refreshed SHA; nothing under services/ is edited. (L-1) spikes/intelligence-data-path/: a committed migration-order.json (each file's SHA-256, the source SHA, order, execution mode, filtering; the harness fails on a mismatch) and a local postgres:17 pair — a Supabase-shaped reference (the migrations with the Supabase roles and RLS) and the stock candidate with one app role; the catalog diff names every filtered difference. (L-2) the inventory generator at the pinned SHA → the docs/spikes/a4-parity.csv skeleton, one row per call site with a chain-shape hash; rows = the source-search count. (L-3) lib/db.cjs, the direct-pg adapter, with contract tests in CI: a catalog allowlist for RPC and table names, bound values, typed arguments and results, SQLSTATE preserved in {data, error}, transactions, a guard against UPDATE/DELETE without WHERE, the table-helper shapes the inventory needs. (L-4) the synthetic parity harness: every RPC including the stateful ones (reset per case, side-effect assertions, concurrency cases), the feature classes (embedded resources, .or grammar, JSON-path filters, exact counts, upsert conflict targets, ranges, the 1000-row reliance), and three ported paths (browse, enqueue teardown, research ranking) equal on JSON. (L-5) the pressure script extended with its mocked test, the load drivers, and the owner-run read-only capture harness. (L-6) ADR-020 and the migration runbook as drafts, the record template, a draft PR. (O-1, owner, only when A4 reaches this gate) one pg_dump -Fc -n meta_ads without the rows of admin_users, settings and proxies (their structure kept), plus a short fidelity manifest: the project's Postgres version; the PostgREST version with its db-schemas, db-max-rows and anonymous-role/JWT settings; the extension list with versions; any role beyond the standard Supabase roles. No schema-only dump, no size query, no live capture — the schema comes from the code (L-1) and the dump, the sizes from the restore. (D-1) a local Supabase-faithful reference — the dump restored into Supabase's own Postgres image with the Supabase roles and served by Supabase's PostgREST with the manifest's settings — proven by a reference manifest BEFORE parity (image digests; extensions, schemas, policies, grants, RLS state and auth.role() behaviour; PostgREST exposure, db-max-rows, anonymous role and error shapes; the 1,000-row truncation exercised by a fixture case), against the direct-pg candidate on a stock postgres:17 restore of the same dump with its intentionally different roles and grants; one committed deterministic fixture overlay for the excluded tables (synthetic users with placeholder hashes, only the settings the tested paths need, no proxy credentials), its version and checksum in the CSV and the record, every path that depends on it marked fixture-backed and never live-data parity; the dump's schema diffed against the L-1 replay (drift); the CSV finished with 0 FAIL and every difference explained; the runbook's restore steps proven. A live read-only capture is optional escalation evidence only, owner-run, when the local reference cannot explain a difference. (Budget, owner) an allowed engine/transcriber allocation, §9 updated and the compose-limits.py change merged — P-1 cannot run before it. (O-2, owner) the production window: operator, workload and images, isolation with no double write, neighbour sampling targets, spend, cleanup boundary. (P-1) a 600 s baseline and at least 1,200 s under the approved enforced caps, samples every 30 s, the sampler named exactly; pass = pooler p95 ≤ baseline × 1.10 with no OOM, restart or cap breach. (F-1) ADR-020 with the measured limits and the explained-differences table; the runbook, the CSV, the pressure report and docs/ops/a4-acceptance.md; secret scan clean.
Acceptance. Gate G1, the owner's decision on ADR-020's evidence: accept (ADR accepted, PR merged, A9 may start) or reject along ADR-020's direct-pg fail path — a row that the adapter or a case can fix is fixed inside A4; a structural FAIL stops: A9 does not start, the scraper stays on its Supabase project, and a re-plan card carries the fix. Amended 2026-09-19: G1 stays decided on D-1 (owner, 2026-09-17). A4 merges after its stacked fixes A31 and A32 (open items #98 and #101), a final review and CI, with F-1 written before the merge. Production-host load is not A4's: it is X7's P-1, and a late pressure result follows ADR-020's fail path for it.
Out of scope. The cutover (A9); the mirror refresh (A4a); the cards found while planning — admin access control between cutover and native pages (open item #74), llm_reporting on the unified cluster (#75), cross-schema migration order (#76), the meta_ads function-body lint (#77).
Amended 2026-09-15 (measured at the refreshed mirror; plan round 2, agreed). The Context numbers describe the old mirror at d842453 and are superseded at e818b711, measured there: 30 .rpc() sites over 28 functions; 407 de-duplicated data-access sites (461 raw including test/, 423 excluding it); 48 migration files, 47 of them .sql, exactly one numbered; 62 selects exposed to the 1000-row maximum, 5 of which name it; 85 server-key sites plus 4 browser-client sites; RLS on one table; the only unqualified DELETEs sit in superseded refresh_brand_metrics bodies, the authoritative version carries WHERE true after the 2026-08-20 incident, and the L-3 guard allow-lists exactly that; refresh_brand_metrics takes pg_try_advisory_xact_lock; no pg client exists in services/scraper; neither spikes/ nor docs/spikes/ exists, so A4 defines the convention, with tests and a CI job. Build sequence: S0 is a scaffold — the spike package with pg pinned and node --test, the README, spike.json carrying the provenance source_sha e818b711, bin/pair-up.sh and bin/pair-down.sh whose contract is proven by an ownership test against docker (pair-down removes only what its own pair-up recorded, never a prune), the CI job spike-a4 filtered on spikes/intelligence-data-path/** and infra/smoke/** and listed in both aggregator lists, and the docs/CI.md row. Then L-1, L-2, L-5 and L-6 are built in parallel in one worktree with disjoint file ownership: L-1 owns migration-order.json, the two compose files, bin/, sql/, lib/order.cjs, lib/catalog.cjs and its tests; L-2 owns tools/, docs/spikes/a4-parity.csv and its tests; L-5 owns infra/smoke/{pressure.sh,test-pressure.sh,pressure_report.py}, pressure/ and capture/; L-6 owns docs/decisions/ADR-020-intelligence-data-path.md, docs/runbooks/INTELLIGENCE_DATA_MIGRATION.md, docs/ops/a4-acceptance.md and spikes/intelligence-data-path/docs/RECORD_TEMPLATE.md. Then L-3 (lib/db.cjs, allowlist.cjs, guards.cjs, shape.cjs, pool.cjs and their contract tests) on the L-1 pair, then L-4 (test/parity/**). Each leg gets one review round over its whole diff, one fix pass, one re-check, then its commit; leg commits run their targeted tier, the full tier runs once before the final commit; one DRAFT PR opens at the end and never merges before G1. The eight contradictions are settled. (C1) L-1 runs no size query and no live capture; the pair is synthetic, tiny and torn down after every run; D-1 preflights available disk and memory, attempts restores only inside that envelope, and is blocked for an owner-directed alternative environment if either restore cannot complete safely. (C2) The inventory holds 407 de-duplicated source-access rows at e818b711; its header records the reconciliation 461 → 423 → 387 → 386 → +21 → 407; "rows equal the source-search count" means this reconciled total, not a raw hit count; the 21 non-legacy-client rows stay as EXPLAINED rows. (C3) The llm_reporting view count — 22 CREATE statements against 25 named — is settled by pg_views in L-1's catalog manifest, and both SQL counts are recorded. (C4) L-4 opens and classifies the two alleged JSON-path sites before it writes cases; an empty class is recorded with its paths, lines and basis. (C5) The parity harness may apply a 1,000-row comparison limit to isolate legacy response-shape effects, and it must also run the uncapped direct candidate; for all 62 exposed sites the CSV records the observed capped/uncapped difference and whether the current path pages, bounds or consumes the full set; the adapter policy is a single A9 decision, not 62 silent per-site policies; D-1's reference manifest establishes the real reference limit before any final parity conclusion. (C6) L-4 adds the two-session advisory-lock case for refresh_brand_metrics: one caller rebuilds, the concurrent caller returns the documented no-op result, and side effects are asserted on both databases. (C7) Open item #74's count is corrected to 85 server-key sites plus 4 browser-client sites, with the counting convention stated. (C8) The adapter prototype is spikes/intelligence-data-path/lib/db.cjs; A9 moves it to services/scraper/lib/db.cjs, and ADR-020 states that move. L-1 detail: the reference runs the local Supabase Postgres image (public.ecr.aws/supabase/postgres:17.6.1.080, already on the box) from L-1 on and the candidate runs stock postgres:17, both from compose files under the spike; the candidate has one application role skynet_intel, created by a superuser bootstrap script with its grants; the reference's response service is configured and manifest-recorded (row limit, exposed schema); every filter file sql/filters/<file>.filter.json names the source statement span, the reason, the effect on reference and candidate, and the manifest allowlist entry; add_indexes_concurrently.sql runs in autocommit mode; the llm_reader bootstrap is reference-only pending the owner's decision on open item #75, while the candidate's llm_reporting views still exist; the candidate states its RLS disposition explicitly — for this synthetic one-role proof RLS on ad_rank_snapshots is disabled after the unavailable auth.role() policies are omitted, recorded as an intentional candidate difference and not an A9 security decision; the catalog comparison uses actual catalog objects (distinct views from pg_views, sequences and their current values, grants, policies, index validity, routine body hashes, triggers, constraints, extensions, roles); both builds use the same function-body validation setting and session time zone, or the manifest marks the difference. L-3 detail: a UTC session, a statement timeout, application_name, catalog-derived typed RPC signatures, SQLSTATE preserved in {data, error}, transaction rollback proven, scalar / set / void JSON behaviour, partial-update protection for first_seen_at, and the WHERE-less UPDATE/DELETE guard with WHERE true allow-listed. L-4 detail: all 28 called RPCs plus the seven defined-but-uncalled functions and the trigger paths, all 17 writers' side-effect diffs, sequence-state assertions, the seven skip-locked cases, masks for volatile values, the 18 conflict targets, embedded-resource and disjunction grammar cases, exact-count and range cases, and the three ordered ported paths (browse, teardown enqueue, research ranking); the reference client version is pinned from the refreshed mirror's lockfile; no stateful operation ever runs against live data; storage, signed URLs, browser auth, realtime, secret/settings fallback and the external reporting role get explicit EXPLAINED rows, each naming its A9 follow-up action. L-5: the sampler is named exactly (pgbench select 1 against the ecomprofits pooler), read-only capture is enforced, no production run happens before O-2 and the Budget gate, and cleanup is proven. L-6: the local-only statement (G1 has not occurred; A9 is not authorised), the S1-gate deviation, the direct-database structural-fail path, the role and access decision, the realtime replacement decision, and the runtime dependency and image consequence for A9. Builders: tests first, every number measured, nothing under services/ edited, no secrets in transcripts, raw transcripts kept outside the repository and summarised redacted in the record, and provenance fields such as source_sha required.
Built so far (draft PR #83, seven commits rebased onto 0c31f5a, head 753ea67; 16 CI checks were green at cf8617f, the run at 753ea67 is in progress at the time of writing). The six local legs (S0, L-1/L-2/L-5/L-6, the CI fix, L-3 the adapter with 85 contract tests, L-4 the parity harness, the concurrency addendum) and now D-1 on the owner's real dump (2026-09-17): both sides restored from the archive inside their containers (477/477 and 473/477 entries, 2,415,157 rows over 44 tables identical; three passes with a committed fixture overlay for the three excluded tables, because seven foreign keys point into admin_users); two defects of the restore path found and fixed (--no-acl dropped the archive's REVOKE … FROM PUBLIC, 14 routines callable by every role until re-run; the readiness gate accepted a PostgREST that did not yet serve the schema); fidelity compared both ways (tables 166/166, routines 78/78, policies 5/5 exact by identity; schema privileges exact in shape, three grantees unresolved by identity, owner-pending); the reference manifest committed (anon reads 31 of 52 relations, calls 20 of 34 routines; the 1,000-row cap silent at 1,000 of 26,964); schema drift 33 in meta_ads, 528 with privileges, each read (mi_export_count in the migrations and the engine's code but not in the project; RLS on six more tables live, five without a policy; three admin_users policies USING (true); PUBLIC EXECUTE on refresh_brand_metrics live; 472 grants in no migration); parity on the real pair with the candidate as skynet_intel: 374 cases 214 PASS / 160 EXPLAINED / 0 FAIL, the CSV 411 rows 173 / 238 / 0 labelled real 137 / synthetic 212 / overlay-backed 15 / unmeasured 47; one finding for the port, mi_claim_retag hands the app role no work. Review 3 rounds → APPROVE. What is NOT done, all the owner's: four pending manifest lines (the PostgREST version, db-schemas, db-max-rows, the identity of three schema grantees), the engine and transcriber budget, O-2, P-1, F-1 (which needs P-1), and the decision G1, now on real evidence. The PR is a draft and must not merge before them.
Decided 2026-09-17 (owner). G1 ACCEPTED on D-1's evidence. The BUDGET: scraper engine 2 CPU / 4 GB, transcriber 2 CPU / 6 GB as hard caps in the production compose (the box has 20 cores / 62 GB; the existing caps sum to about 14 cores / 28 GB and ecomprofits' own containers share it); §9 and compose-limits.py carry them before A4 merges. The WINDOW: a weekday evening 20:00–21:00 CEST (after the buyers' day, before the 01:00 backup and the 02:30 sync), operator the owner or Sead with the orchestrator preparing every command, spend ceiling 20 USD, writes only to the run's own scratch schema; the date is set in the session that runs P-1. The one pending manifest line (the PostgREST version, Settings → Infrastructure in the Supabase dashboard) is a fidelity note, not a gate.
Amended 2026-09-19 (the owner's decision: production at the terminal go-live). The Skynet production stack is deployed only at the terminal go-live, after S1, S2 and S3. P-1 needs that stack, so P-1 and its window O-2 leave A4's gate and become an X7 T-1 gate on the release candidate. G1 stays decided on D-1 (owner, 2026-09-17). A4 proves the data-path semantics and the mechanically enforced allocation. Production-host load is deferred to X7's P-1. A4 merges after its stacked fixes A31 and A32, a final review and CI. F-1, before the merge, is the record and ADR-020 stating what is proven and what is deferred, in this sentence: "The caps are approved and mechanically reserved; production-host performance remains unmeasured until the go-live pressure gate." The caps are never described as measured capacity, so the "measured limits" in the deliverables' F-1 are superseded. The P-1 result fields move to the X7 go-live record. ADR-020 gains a section with the fail path for a late P-1, quoted in X7's card. Only a newly found data-path defect (semantics, restore or security) affects A9's structural fail path. Where the records state the evidence's limits, they use this text: "The direct-Postgres port is proven against the captured meta_ads snapshot, including real-data cases, but not against the final production delta. The final freeze, delta migration, checksum and row-count verification, sequence state, and source-to-target authority switch remain unproven. Forty-seven inventory rows remain unmeasured and must remain visible in A9 and go-live acceptance." A4 is no longer a deviation from S2's entry gate: that gate is now "The S1 card set is merged, deployed and green on staging.", and it is met.
Waits on / Unblocks. A4a merged; the owner gated O-1, the budget and G1; A4 merges after its stacked fixes A31 and A32, a final review and CI; O-2 and P-1 moved to X7's T-1 / A9, A2d, A13a, V22, A14.
Built as (PR #83 → main 8d529d2, 2026-09-20). The legs were reviewed in their own rounds, then one final whole-pull-request review with four record corrections → APPROVE TO MERGE. What shipped: the spike package spikes/intelligence-data-path/ with its own CI job; the parity result, measured with the candidate's calls made as the application role skynet_intel, 374 cases at 214 PASS / 160 EXPLAINED / 0 FAIL and the committed results file at 411 rows, 173 PASS / 238 EXPLAINED / 0 FAIL, sha256 6a4600fb…, every row carrying one fidelity word (138 real, 211 synthetic, 15 overlay-backed, 47 unmeasured); the direct-Postgres adapter prototype lib/db.cjs with its contract tests, its catalog allowlist, bound values and typed results, SQLSTATE preserved at the caller, proven transaction rollback, partial-update protection, the caller's own row bound, and the guard that refuses an UPDATE or DELETE with no predicate, allow-listing whereTrue() alone; the approved caps of 2026-09-17, the scraper engine 2 CPU / 4 GB and the transcriber 2 CPU / 6 GB, reserved in the compose files and enforced by infra/scripts/compose-limits.py, whose check CI runs; the pressure rig, its three load drivers, the named sampler and the mocked suite CI runs; and ADR-020, accepted at G1 on 2026-09-17. It carries A28, A29, A30, A31 and A32, which merged into its branch and reach main with it. The production pressure run is not here: by the owner's decision of 2026-09-19, P-1 is a T-1 gate of card X7 on the release candidate, and the P-1 result fields move to the X7 go-live record. F-1 states it in one sentence: "The caps are approved and mechanically reserved; production-host performance remains unmeasured until the go-live pressure gate." The merge commit's CI run and its staging deploy both completed successfully on main at 8d529d2, each watched to completion. The pull request's last pre-merge run was green at cec2115 in 11 minutes; an earlier run was cancelled by hand after 9 minutes while both long jobs were still progressing, on a misread of the run's timestamps, which cost one cycle and nothing else. Record docs/ops/a4-acceptance.md and docs/decisions/ADR-020-intelligence-data-path.md; open items #84, #85, #86, #98 and #101 closed.
A28 · The database driver replays a full lap before it stops · A · S · S2-W1 · fix/a/a28-db-driver-replay-floor (surfaced while fixing A4's engine pressure driver on 2026-09-15, open item #85; carded 2026-09-18)
Context. The database pressure driver can replay nothing and call it a failure. spikes/intelligence-data-path/pressure/drivers/db-driver.cjs:154-155 fixes startedAt and deadline = startedAt + durationMs before the scratch-schema setup (:227-241) and before each worker opens its own connection (:198), and the worker's first act is the deadline check at :207. A box slow enough to spend the window connecting and creating the schema therefore leaves every worker already past the deadline at its first look, the run replays nothing, and ok: units > 0 && refusals === 0 at :259 records that as a failure of the load rather than of the box; infra/smoke/pressure_report.py:362-364 reads the same file as INVALID. Measured on the spike branch with a stub client factory that takes 300 ms to hand back a connection over a 250 ms window at concurrency 2: units 0, ok false, achieved 0 against a target of 80, connections_peak null, and setup_statements_run 4, so the setup finished, the window was gone, and nothing was replayed. max_connections still reads 80, because the probe reads it at :175 before its own while at :180, so connections_peak is the only field the slow box empties. A 700 ms factory over a 600 ms window at concurrency 1 gives the same. This is not hypothetical CI risk and it is not one case: pressure/test/db-driver.test.cjs is in test:ci (spikes/intelligence-data-path/package.json:12), which the spike-a4 job runs on every push (.github/workflows/ci.yml:895-911, filtered at :159-162), and four of its cases go red on a slow box. The engine leg already has its answer, pressure/drivers/engine-driver.cjs:145-157 is a do … while (Date.now() < deadline) with the reason at :141-144 and a case that proves it at pressure/test/engine-driver.test.cjs:92-108, but copying a bare do…while here buys one statement per worker off the head of a weight-expanded order, and cursor is shared by every worker (:208-209), so concurrency 2 reaches position 2 of the lap and concurrency 4 reaches position 4. Either way the run holds one upsert and no advisory probe. That satisfies units > 0 while replaying nothing like the mix the report claims. What this driver is missing is not a loop shape, it is a stated floor: what a run guarantees when the window is shorter than its own setup.
| Statement | Weight, and times in one lap | First position in the lap |
|---|---|---|
| select_by_advertiser | 30 | 1 |
| upsert_ad | 25 | 2 (second at 6) |
| join_snapshots | 10 | 3 |
| insert_snapshot | 10 | 4 |
| select_page_1000 | 8 | 7 |
| count_exact | 6 | 8 |
| json_path_filter | 6 | 10 |
| advisory_lock_probe | 5 | 13 |
| One lap | 100 draws | 1 to 100 |
No window in the file completes a lap today, so every case here becomes a floored run under the contract and the file has no control case until one is added. Measured on an idle box against the ordinary stub, target rate 80 a second:
| Window, ms | Concurrency | Statements | Laps | Case, and how it breaks on a slow box |
|---|---|---|---|---|
| 60 | 1 | 3 | 0.03 | :180-197, asserts duration_s < 0.5; at risk from the floor, not from the slow box |
| 120 | 2 | 10 | 0.10 | :90-99, lastSetup < firstReplay goes 3 < -1 and blames the setup |
| 200 | 2 | 16 | 0.16 | :126-131, connections_peak null against 61 |
| 250 | 2 | 20 | 0.20 | :80-88, units > 0 |
| 600 | 1 | 48 | 0.48 | :101-108 needs the advisory probe, :110-117 needs two upserts |
| 1400 | 1 | 111 | 1.11 | none today; the window a control case needs |
Read first. spikes/intelligence-data-path/pressure/drivers/db-driver.cjs:1-28 (the file's own statement of its two decisions, including why the replay order is deterministic), :75-96 (expand, the smooth weighted round robin and the comment on what makes a short window representative), :105-125 (the run's options and the client factory), :127-158 (the gate, the reservation check, and the counters, startedAt and deadline), :161-193 (record and the probe, whose max_connections read at :175 is already outside the loop, whose while at :180 carries the same shape as the worker's, and whose sleep clamp at :191 must survive the change), :195-225 (the worker, the connect catch at :200-203, the loop at :207, the shared cursor at :208-209, the shared sequence at :210 and the placeholder at :211-213, the pacing at :220-223), :227-247 (the setup and the Promise.all that also holds the probe), :249-276 (the result and the ok rule); pressure/drivers/engine-driver.cjs:114-160 (the fixed counterpart and the comment that states why a deadline says how long a worker keeps going and not whether it starts); pressure/test/engine-driver.test.cjs:92-108 (the shape the new cases copy); pressure/test/db-driver.test.cjs:28-54 (the stub client), :80-88, :90-99, :101-108, :110-117, :126-131 (the five cases this card moves onto the contract), :119-124 (the refusal, which the floor must not rescue), :180-197 (duration_s < 0.5 on a 60 ms window, the bar the unpaced floor has to stay under); pressure/lib/result.cjs:1-48 (the shape every driver fills, the ok default, and achieved_rate_per_s at :41) and :141-202 (the reservation, which the change must not disturb); pressure/lib/gate.cjs:82-106 (what a driver leaves behind when it did no work, and why it needs no rule of its own in the analyser); infra/smoke/pressure_report.py:355-378 (the INVALID rules a result has to keep satisfying, including the target-against-achieved rule at :366-371); pressure/corpus/queries/local-mix.json (the weights, the target rate and the four setup statements); pressure/README.md:145-225 (the start-gate section, where the contract is written down) and :313-323 (what the tests are and are not); spikes/intelligence-data-path/package.json:11-15 and .github/workflows/ci.yml:157-162 and :869-911 (the CI half, its path filter and the job that runs it); open item #85.
Deliverables. (1) The driver states a minimum-work contract and keeps it: a run replays at least one full lap of the expanded weighted order, expand(mix.statements).length, which is 100 draws for the shipped mix, and at least one statement for every worker that opened a connection, whichever of the two is larger; the deadline still ends a run that has met the floor and no longer ends one that has not. A lap and not a statement, because a statement satisfies units > 0 while leaving the run unrepresentative of the mix it claims to have replayed; a floor and not a separate verdict, because a verdict would need a new rule in the analyser and would still leave the shortest runs measuring the box instead of the load. (2) The floor binds only the workers that opened a connection. A run in which no worker connected replays nothing, and that is correct: its refusals are what the result says, not a floor it could never have met. (3) The floor is unpaced: past the deadline the worker drops the inter-statement wait at :220-223, so finishing the lap cannot stretch a short run into a long one, and the pace is kept exactly as it is for every run still inside its window. (4) The probe takes one sample before it consults the clock, so a floored run records connections_peak rather than null; max_connections already survives a floored run and keeps reading as it does; the sleep clamp and the comment at :188-191 stay as they are, and the clamp still holds the probe at a zero wait once the deadline has passed. (5) A floored run says so in its own result: the lap length, the number of laps the run completed, and a field naming the case in a sentence a reader of the report understands without this card. That field is also what tells a reader the rate is not the load's rate, because a floored run finishes its lap unpaced and its achieved_rate_per_s therefore rises above its target, which no rule in the analyser flags. ok keeps its meaning: a floored run that replayed its lap is ok, and a floored run whose statements failed is not. (6) Nothing turns a failure green. A worker that could not connect still returns at :200-203 and its refusal still makes the leg not ok; a setup that failed still leaves every statement failing, units at 0 and the leg not ok; and the floor terminates in both cases, because cursor advances at :209 before the query and a worker that returned early runs no loop. (7) Five cases stop measuring the clock. test/db-driver.test.cjs:80-88, :90-99, :101-108, :110-117 and :126-131 assert against the contract instead of against what a 60 ms to 600 ms window happened to buy: the lap's exact composition, the setup ahead of a replay that is now guaranteed to exist, the advisory probe present, distinct ids for every upsert of the lap, and the connection peak. (8) Three new cases, each failing on the base sources with its own sentence: a client factory that hands back its connection only after the window has closed still buys a full lap and reports ok true, in the shape of test/engine-driver.test.cjs:92-108; that same run records a connection peak rather than null; and a control run whose window is long enough to complete a lap inside it reports the floored field false and holds its achieved rate against its target. (9) pressure/README.md states the contract in its start-gate section: what a run guarantees when the window is shorter than its own setup, why the guarantee is a lap, and what a floored run's achieved rate is not. (10) docs/ops/a28-acceptance.md with the reproduction's numbers before and after, and the CI half's wall time before and after.
Interfaces. Exposes the driver's minimum-work contract and the result fields that say a run was floored, which infra/smoke/pressure_report.py and A4's F-1 record read. Consumes expand (already exported at db-driver.cjs:310), the mix's weights and target rate, and the gate's window as the run's script sets it. The result schema number in pressure/lib/result.cjs:21 does not change: the new fields are added under extra, every existing key keeps its meaning, no reader requires the new ones, and the engine and transcriber results are untouched.
Acceptance. cd spikes/intelligence-data-path && npm run test:pressure green with the new cases included, and each new case failing on the base sources with its own sentence. The reproduction re-run on the branch: a client factory that takes 300 ms against a 250 ms window at concurrency 2 reports 100 statements, ok true, the advisory probe 5 times and 25 upserts with 25 distinct ids, where the base reports units 0, ok false and connections_peak null. The control run: a 1,400 ms window at concurrency 1 against the ordinary stub, which the base replays 111 statements in 1.403 s at an achieved 79.116 against a target of 80, reports the floored field false and the same paced rate. The refusal case is unchanged: a stub that refuses after the first connection, at concurrency 3, still records refusals, still reports units 0 and still reports ok false. The gate case is unchanged and still reports duration_s under 0.5 on its 60 ms window. npm run test:ci green, with its wall time recorded before and after the change, so the added control case and the floored runs are shown against the whole of the CI half. The spike-a4 job green on the pull request. gitleaks detect --no-git --source . --config infra/secret-scan/gitleaks.toml --redact clean. Record docs/ops/a28-acceptance.md; open item #85 closed.
Out of scope. The engine driver, which already has its floor. The transcriber driver's loop at drivers/transcriber-driver.sh:139, whose deadline at :133 is set with no setup inside it. Moving the deadline, and changing what duration_s and achieved_rate_per_s divide by, which is one decision across all three legs and the analyser and which engine-driver.cjs:118-120 shares. The analyser's rules in infra/smoke/pressure_report.py, including its target-against-achieved rule. The query mix, its weights and its target rate. The result reservation and gate protocols. Anything under services/.
Waits on / Unblocks. A4: the driver and its tests exist only on A4's draft pull request #83 (spike/a/a4-intelligence-data-path), because spikes/ does not exist on main, so this card is a stacked pull request on that branch after the branch is rebased onto main. It merges into that branch before A4's final review, so the later P-1 at X7's T-1 carries the fields the X7 go-live record quotes. The floor never fires on P-1: that window is 1,200 s or more, and no box spends that on a connect and four setup statements / the CI half, which stops going red because the runner was slow, and the X7 go-live record, which quotes the P-1 pressure report and can now say what a run replayed rather than only how long it ran.
Built as (PR #102 → A4's branch spike/a/a4-intelligence-data-path at 21a88fd, 2026-09-18; it reaches main with A4's pull request #83). Code 1 review round → one must-fix (a failed setup could still report ok, because the mix's advisory-lock statement touches no table, so on a real Postgres it succeeds five times a lap and the floor now guarantees the lap) and one should (the README stated an expectation as a guarantee); re-check → one more must-fix (ok ignored errors that were not refused connections); second re-check → APPROVE. The floor: a run replays at least the larger of one lap and one statement per worker that connected, counted across all workers, and the deadline decision and the shared cursor claim happen in one synchronous step, so concurrent workers stop exactly at the floor (100 at concurrency 8, 150 at concurrency 150, 100 on an uneven connect). Past the deadline a worker does not wait between statements; inside the window the pace is unchanged. The probe samples once before it reads the clock. New fields under extra: lap_statements, laps_completed, floored, floor_note, setup_failed and setup_note; the schema stays 1. Two premises of this card were wrong, and the build corrects them. A floored run's rate is not always above its target (63.573 against 80 on a 700 ms window), so the note and the README say it can be above or below. A failed setup does not leave every statement failing, because the advisory probe needs no schema; so a failed setup, a statement or its connection, now ends the run before the probe and the workers, and ok is !setupFailed && units > 0 && refusals === 0 && errors.length === 0, the last term matching the engine driver and the shared result default. The base driver reported ok: true with a failed setup and with statement errors. The reproduction: a 300 ms factory against a 250 ms window at concurrency 2 went from 0 units, ok false and no connection peak to 100 statements, ok true, the advisory probe 5 times and 25 upserts with 25 distinct ids; the 700 ms against 600 ms variant went from 0 to 100; the 1,400 ms control is not floored and keeps its paced rate (78.515 against 80). Five existing cases assert the contract instead of the clock, seven new cases each fail on the base for their own sentence, and the test's control case uses a 2,000 ms window for margin on slow runners. test:pressure 98/98, test:ci 347/347 (340 on the base), infra/smoke/test-pressure.sh 98 ok. Record docs/ops/a28-acceptance.md; open item #85 closed. Found: the report's rate rule marks a healthy paced replay INVALID (open item #97, card A30, which lands before P-1), and the probe calls a denied max_connections read a connect failure (open item #98). The line above that says no box spends 1,200 s on a connect and four setup statements states an expectation: if P-1's floor ever fires, its result says so in floored.
Amended 2026-09-19 (the owner's decision: production at the terminal go-live). P-1 left A4's gate. It is now an X7 T-1 gate on the release candidate at the terminal go-live. This card is stacked on A4 and landed before A4's final review. Its result fields reach the X7 go-live record, not A4's F-1 record.
A29 · Every parity case names the rows its failure has to reach · A · S · S2-W1 · fix/a/a29-case-inventory-targets (surfaced while building A4's parity harness on 2026-09-15, open item #86; carded 2026-09-18)
Context. A row of the results file takes its verdict from the cases that name its inventory target: spikes/intelligence-data-path/test/parity/verdicts.cjs:163-170 groups every result by the targets it declares, :262-263 looks the row's own target up, and :275-289 writes FAIL onto every row a failed case named. Forty-three of the 374 cases the restored run built name nothing. The four concurrency cases: test/parity/cases/concurrency.cjs:112-188 builds concurrency:skip-locked:<routine> over mi_claim_work, mi_claim_retag and gold_next_item with its id at :114, :247-300 builds concurrency:advisory-lock:refresh_brand_metrics with its id at :248, and the word targets appears nowhere in that file. The twenty-four row-bound cases: test/parity/cases/rowbound.cjs:72-125 builds one case for each of 23 exposed relations plus row-bound:truncation-is-silent at :98, none of them declares a target, and the 62 implicit-1000 rows they are about reduce to 53 of the 291 targets. Eleven feature cases: test/parity/cases/features.cjs:170-350 pushes feature:int8-as-string, feature:partial-update:first-seen-at, feature:jsonb-string-payload, feature:count-exact, feature:count-head, feature:range, feature:on-conflict:brands, feature:ignore-duplicates, feature:trigger:set-updated-at, feature:trigger:preserve-ad-owner-brand and feature:transaction with no targets key at all, while only the eight REFUSED_SITES cases above them get one at :158. And the four uncalled routine definitions at test/parity/cases/rpcs.cjs:257-267, whose lookup at :306 returns an empty list because the inventory records no row calling them. The rows to carry the concurrency verdicts already exist and are already answered by something else: mi_claim_work at docs/spikes/a4-parity.csv:243 (target case:79f823aceace@mi_claim_work, EXPLAINED), mi_claim_retag at :264 (case:d0b2009faf50@mi_claim_retag, EXPLAINED), gold_next_item at :48 (case:e8e40d0a0b59@gold_next_item, EXPLAINED) and refresh_brand_metrics at :402 (case:5a5b10feb7dd@refresh_brand_metrics, PASS), each measured today by its rpc:<routine> case alone and each the only row of its target. So a concurrency, row-bound or feature FAIL fails the parity suite and is written into the run record while the results file still reads 411 rows, 173 PASS, 238 EXPLAINED and 0 FAIL, and a reader who takes that file as the run's verdict reads a green count over a red run. The same hole swallows a case that throws: test/parity/run.cjs:254-270 records it as verdict: 'FAIL' carrying targets: testCase.targets || [] at :261. Both records state the asymmetry where they present the totals, docs/ops/a4-acceptance.md:426-433 and docs/decisions/ADR-020-intelligence-data-path.md:287-289, which is the honest first move and not a fix, and both understate it by naming only the concurrency cases. The reason the harness gives itself, that these cases are about what two sessions do to each other rather than about a call site, does not survive measurement: each of the four is about one named site (ingest-worker.cjs:786, keyword-retagger.cjs:239, admin/app/api/gold/route.ts:30, server.js:1485) and each of those has exactly one row. The machinery is already written twice over: rpcs.cjs:272-279 builds a routine-name to inventory-target map and hands it to each case at :306 and to each skip at :292, features.cjs:139-142 picks targets by relation and feature, and test/parity/cases/index.cjs:36 already passes targets into features.build while :38 and :39 do not pass it into concurrency.build or rowbound.build. None of this is on main: the spike lives on spike/a/a4-intelligence-data-path at dc0f7bc in open pull request #83, and main at ba66058 has no top-level spikes/ tree, no results file and no ADR-020.
Read first. spikes/intelligence-data-path/test/parity/verdicts.cjs:1-40 (the writing rule and the two columns this module owns), :153-181 (the target grouping and the skip grouping), :186-192 (the row-bound measurement rule, and why a row takes the number from the case about its own relation), :224-347 (decide, and the verdict precedence a second answering case joins, EXPLAINED above PASS at :315-326), :233-247 (the one explained-json-payload row, decided before any target is looked up), :378-400 (totals and labels); test/parity/cases/index.cjs:26-78 (the manifest build, the six groups, the per-case bound at :63 and the answered set at :65); test/parity/run.cjs:190-210 (where facts and plan come from, which is why the manifest is not built in the CI half) and :241-271 (the run loop and the FAIL a throw is recorded as); test/parity/cases/concurrency.cjs:1-66 (what the four cases are for and what they deliberately do not claim), :112-189 (claimCase), :191-302 (build, and the gold_next_item case at :220-245 which is built only when the fixture seeded a batch and a labeller); test/parity/cases/rowbound.cjs:1-36 (the 62 exposed selects and whose decision they are), :37-70 (exposed, which already holds the rows behind each relation), :72-125; test/parity/cases/features.cjs:131-165 (targetsFor and the eight cases that use it) and :170-350 (the eleven that declare nothing); test/parity/cases/rpcs.cjs:272-310 (the byName map, the skip that names the targets it leaves unanswered, and the case shape to copy) and :257-267 (the uncalled definitions, one of the two real reasons to name no target); test/parity/cases/shapes.cjs:387-400 and :420-430 (one target per case, and a skip that names its target); test/parity/masks.cjs with test/parity-manifest.test.cjs:124-129 (refused rather than applied, the fail-closed shape this card copies); test/parity/inventory.cjs:12-21 (what a target is and why it is not case_ref), :70-104 (portRows, targetOf, targets), :158-165 (rowBoundDisposition, rowBoundRows); tools/inventory.cjs:84-89 (generatorDigest), :145-218 (the header the generator writes) and :271-285 (the header comparison that fails a file whose header the generator does not state); test/inventory-roundtrip.test.cjs:1-60; test/parity-manifest.test.cjs:742-748 (the pinned 291 targets, 29 rpc targets, 28 routines and 62 row-bound rows), :936-947 (portRow), :949-992 (the FAIL a row carries and the totals that count it), :1093-1119 (a row-bound measurement decided from a synthetic record); test/parity.test.cjs:239-252 (the tally), :616-635 (the CSV write and the FAIL report); docs/spikes/a4-parity.csv:1-28 (the header block and the column row) and rows :48, :243, :264, :402; docs/ops/a4-acceptance.md:371 and :374-396 (the totals table and the file's sha256 8ef648e7…), :398-420 (the four fidelity-word counts) and :426-433; docs/decisions/ADR-020-intelligence-data-path.md:279-289; spikes/intelligence-data-path/package.json (test:ci and test:local, which decides where each new test runs); open item #86.
Deliverables. (1) test/parity/cases/index.cjs passes the inventory targets into concurrency.build and rowbound.build the way :36 already passes them into features.build, so no builder has to re-read the inventory to know what it answers. (2) Each of the four concurrency cases declares the inventory targets of the routine it contends over, taken from the same routine-name to target grouping rpcs.cjs:272-279 builds, so concurrency:skip-locked:mi_claim_work names case:79f823aceace@mi_claim_work and the other three name theirs. (3) Each row-bound case declares the targets of the relation it measures, derived inside exposed from the rows it already holds, row-bound:truncation-is-silent included, since ads is one of the 23 exposed relations and holds 6 of the 62 rows; the measurement rule at verdicts.cjs:186-192 is untouched, so the number a row carries still comes only from the case about that row's own relation. (4) Each of the eleven feature cases at features.cjs:170-350 either names the targets of the relation and feature it is about, through targetsFor or the same grouping, or declares its reason; feature:jsonb-string-payload is the case whose reason is that the generator settled its site as explained-json-payload, so the inventory holds no ported row for it and verdicts.cjs:233-247 decides that row before any target is read. (5) A case that names no target is refused by id when the manifest is built, unless it declares in one field why no row can carry it. The two reasons that exist today are a routine the schema defines and no site in the mirror calls, so the inventory holds no row for it (rpcs.cjs:257-267), and a site the generator settled outside the comparison, so the inventory holds no ported row for it. The refusal lives in index.cjs so it fires on the live run as well as in the test, and it is a hard error and never a warning, in the same fail-closed shape as the unnamed mask test/parity/masks.cjs refuses rather than applies, so the next case cannot be added target-less. (6) The results file's header gains one # key stating what its counts cover: that every case the suite runs names at least one inventory target or declares why it names none, so a FAIL in any case reaches the rows of that target and the counts below are the whole run. The key is added to the generator's header array at tools/inventory.cjs:145-218 and the file regenerated with npm run inventory, which carries the recorded verdicts forward and rewrites # generator_digest; a key written into the file by hand fails the header comparison at :275-276. (7) A run on the restored pair rewrites the verdict cells, and docs/ops/a4-acceptance.md is re-measured from the new file: the result totals and the sha256 at :374-396, and the four fidelity-word counts at :371 and :410-417, which move with the rows that gain an answering case. (8) The two coverage paragraphs are replaced by what is true after the change, and they name every group rather than only the concurrency cases: docs/ops/a4-acceptance.md:426-433 and docs/decisions/ADR-020-intelligence-data-path.md:287-289. (9) No record of its own: the change lands inside pull request #83 and docs/ops/a4-acceptance.md is the record that presents these totals, so it carries this in the sections it already owns and open item #86 is closed there.
Interfaces. Exposes one rule for every parity case written from here on, that a case names the inventory targets its verdict must reach or declares why it names none, and the refusal that enforces it. Consumes the target vocabulary already defined at inventory.cjs:74 and :80-104, and the generator's header contract at tools/inventory.cjs:145-218. Nothing about how a verdict is decided or written changes: the precedence at verdicts.cjs:275-335, the two written columns at :40, the row key, the fidelity words and the row-bound measurement rule are all as they were.
Acceptance. npm run test:ci in spikes/intelligence-data-path, with new cases in test/parity-manifest.test.cjs that all fail on the base sources. Nothing in that half builds the manifest today, so the test builds it: cases/index.build over the committed inventory, a synthetic fixture plan and an empty facts map, under which every sweep case becomes a declared skip that already names its targets, and the five other groups build in full. Over that manifest: every case names at least one inventory target or declares its reason; the four concurrency ids name case:79f823aceace@mi_claim_work, case:d0b2009faf50@mi_claim_retag, case:e8e40d0a0b59@gold_next_item and case:5a5b10feb7dd@refresh_brand_metrics; the row-bound cases together name the 53 targets the 62 implicit-1000 rows reduce to; a case built with no target and no declared reason is refused by id, and the message names the case and the remedy; and a synthetic record in which only concurrency:skip-locked:mi_claim_work fails puts FAIL on that routine's row and makes verdicts.totals count one, where the same record on the base sources leaves the count at zero, the same proof again for row-bound:ads. npm run inventory:check and test/inventory-roundtrip.test.cjs green with the new header key, which proves the generator states it. Then npm run restore and npm run test:local on the pair with the owner's dump restored, the archive read where the owner put it per docs/ops/a4-acceptance.md:546-563: 374 cases and the tally the record states, and the results file written from that run. The diff of docs/spikes/a4-parity.csv then holds the new header key, the four routine rows each naming its concurrency case in follow_up beside its rpc: case, and the 62 row-bound rows each naming the case about their own relation. No row moves to FAIL, the 411 rows still add up to the four verdicts, and the pinned inventory assertions at test/parity-manifest.test.cjs:742-748 still hold. A row that moves between PASS and EXPLAINED is not a failure of this card and is not asserted away: verdicts.cjs:315-326 ranks EXPLAINED above PASS, so docs/spikes/a4-parity.csv:402 becomes EXPLAINED if the advisory-lock case was EXPLAINED rather than PASS, and a row-bound case that names a difference from verdicts.cjs:96-103 moves its rows' word from synthetic to real. Every such move is listed in the record with the case that caused it and why it is the honest reading, and the result totals, the four label counts and the sha256 in docs/ops/a4-acceptance.md are re-measured from the committed file rather than transcribed. Open item #86 closed.
Out of scope. Giving the results file a second section with a count of its own, which the rows make unnecessary. Any change to what a concurrency, row-bound or feature case measures, arranges or asserts. The measurement rule at verdicts.cjs:186-192, the verdict precedence at :275-335 and the rule that EXPLAINED is final, which is why verdicts.cjs:233-247 keeps the generator's verdict on the explained-json-payload row and why this card declares that case's reason rather than rewriting the branch. The row-limit policy for the 62 exposed selects, which is one decision taken at the cutover. The adapter, the fixture plan, the reference manifest and the pressure leg. Every column of the results file but result and follow_up, and every header key but the one this card adds.
Waits on / Unblocks. A4: the parity harness this card edits exists only on A4's draft pull request #83 (spike/a/a4-intelligence-data-path), so this card is a stacked pull request on that branch after the branch is rebased onto main. It merges into that branch before #83 merges, because it rewrites the verdict cells and the totals that #83's record presents, and the results file and both records are that pull request's own artefacts and would otherwise be written twice / a results file whose failure count covers every case the parity suite runs, which is the file A9 reads at the cutover.
Amended 2026-09-18 (at review, agreed). Two changes join this card. (1) A third declared reason: a case that states a capability no call site uses names no target, because no inventory row can carry it. It is accepted only for a case in the feature group that runs and observes nothing (the harness answers it EXPLAINED without executing it) and that is listed by id in one reviewed constant in test/parity/cases/index.cjs with its reason; today that is feature:transaction alone, whose rows would otherwise turn from PASS to EXPLAINED for a difference none of them makes. An explain-only case outside the list, or in another group, is refused. (2) The results file's write is idempotent: test/parity/verdicts.cjs's settled-row branch built the new follow_up from the already-written one and appended the fidelity sentence again, so every write after the first doubled it on the settled rows, and A9 re-runs this writer on this file. A settled row's follow_up is now the generator's text plus the sentence once, whatever the file held; no verdict, precedence, column or fidelity word changes, and writing a results set twice equals writing it once. The review also made the refusal check that every named target exists in the inventory.
Built as (PR #106 → A4's branch spike/a/a4-intelligence-data-path at 8ac6089, 2026-09-18; it reaches main with A4's pull request #83). Code 1 review round → four must-fixes (a case could name a target the inventory does not hold; the builder's third reason accepted any explain-only case; the record kept the earlier leg's restore timings; the writer doubled a sentence on a second write, folded into this card) and one should (the README's counts); re-check → APPROVE. The card was amended at review for the third reason and the idempotent write. test/parity/cases/index.cjs refuses, by id and at manifest build, a case that names no target unless it declares untargeted with one of routine-uncalled, site-settled or capability-no-site-uses, each proven true of the case; the last is accepted only for a feature case that runs nothing and is listed in CAPABILITIES_NO_SITE_USES (today only feature:transaction). Every named target must exist in the inventory. An unknown or free-text reason, a reason beside targets and a false reason are refused. 368 cases name targets and 6 declare a reason: the 4 uncalled routines, feature:jsonb-string-payload (site-settled) and feature:transaction. The four concurrency cases name exactly their routines' targets, and the 24 row-bound cases the 53 targets their 62 rows reduce to. verdicts.cjs's write is idempotent: the old writer turned the committed file into a different one with the fidelity sentence twice on 46 settled rows, and the new one leaves it byte-identical. The generator writes a # coverage header key. 13 new tests, each failing on the base (43 cases named nothing there; a failing mi_claim_work or row-bound:ads now reaches its rows and the totals). test:ci 360/360 (347 on the base). The owner's dump restored into the pair (477/477 and 473/477 entries, 184.2 s and 184.6 s) and test:local 116/116: 374 cases, 214 PASS / 160 EXPLAINED / 0 FAIL as before; the results file 411 rows, 173 / 238 / 0, labels 137/212/15/47 → 138/211/15/47 (rank-tracker.js:78 moved from synthetic to real through its row-bound case); 91 follow_up cells gained their answering cases; sha256 6a4600fb…. The record and ADR-020 were re-measured from the committed file and both coverage paragraphs name every group. Open item #86 closed in docs/ops/a4-acceptance.md.
A30 · The pressure report judges a paced replay by the pace it kept · A · S · S2-W1 · fix/a/a30-paced-rate-tolerance (surfaced while building A28 on 2026-09-18, open item #97; carded 2026-09-18)
Context. The pressure run's analyser calls a run INVALID when a load driver's achieved rate is below its target: infra/smoke/pressure_report.py:366-371 compares achieved_rate_per_s with target_rate_per_s with a strict <. The database replay cannot pass that rule. It paces each worker at exactly its share of the target (spikes/intelligence-data-path/pressure/drivers/db-driver.cjs:205, intervalMs = (1000 / targetRatePerS) * concurrency, on an absolute schedule at :220-223), so it never runs ahead of its target, while pressure/lib/result.cjs:26 and :41 divide its units by the whole run, which starts before the connect and the scratch-schema setup. So its achieved rate sits just under its target on every healthy run: A28's builder measured 79.832 and 79.26 statements a second against 80 on 10 s windows, and 78.459 against 80 on a 1,400 ms control. The real run sets the database leg to the mix's 40 a second times PRESSURE_DB_RATE_MULTIPLIER 2, which is 80, at concurrency 4 over 1,200 s (pressure/compose.intel.yml:183-185, corpus/queries/local-mix.json:4), so P-1 would very likely end PRESSURE TEST INVALID on this rule alone, whatever the box does; only an unusually favourable schedule could round up to 80. The mocked suite could not see it, because its healthy fixture writes the database leg at 81 against 80 (infra/smoke/test-pressure.sh:414), a rate the paced driver cannot produce, and the P-1 command sheet was checked against that suite. The engine can trip the same rule only when PRESSURE_ENGINE_TARGET_RATE is set (pressure/drivers/engine-driver.cjs:241, null otherwise); the transcriber writes no target. A28 adds a second result the rule reads wrongly: a floored run finishes its lap unpaced after the deadline, so its whole-run rate says nothing about the load, and it lands above or below the target (63.573 against 80 on a 700 ms window). The rule exists only on A4's branch; main's analyser has no driver section.
Read first. infra/smoke/pressure_report.py:177-196 (load_drivers: a result file is read whole, and a driver's extra fields are top-level keys because result.cjs:46 spreads them), :351-381 (the driver rules: no work, the rate rule at :366-371, refusals, errors, the connection peak) and :515-518 (the drivers table in the report); infra/smoke/pressure.sh:17 and :36-47 (what INVALID means, and why a replay under its target is one); infra/smoke/test-pressure.sh:402-446 (write_driver, healthy_drivers, the fixture at :414 and the driver cases after it); spikes/intelligence-data-path/pressure/lib/result.cjs:23-47; pressure/drivers/db-driver.cjs:110, :120 and :195-225 (the multiplier, the target and the paced worker) with A28's result fields (lap_statements, laps_completed, floored, floor_note, setup_failed, setup_note) and its README section on the floor; pressure/drivers/engine-driver.cjs:241; pressure/compose.intel.yml:175-190; corpus/queries/local-mix.json:1-10; open item #97.
Deliverables. (1) One named relative tolerance in pressure_report.py, RATE_SHORTFALL_TOLERANCE = 0.02, with its reason beside it: a paced driver cannot run ahead of its target, and its connect, its setup and its timer granularity cost it a share of the rate that shrinks as the window grows. A leg is under-rate only when achieved < target * (1 - RATE_SHORTFALL_TOLERANCE). The tolerance is relative and not a number of seconds, because a fixed allowance grows lax at low rates and strict at high ones, and it is stated as "up to 2%", never as a promise about the box. (2) The rule applies to every driver whose target is a positive number, the engine included when its target is set; a driver with no target is not judged on rate. (3) A result whose floored is true is not judged on rate, and the report says so in its drivers section in one visible sentence, for example "rate comparison skipped: the database replay finished its minimum-work floor after the load window". The skip reads only A28's documented field and never infers a floor from the numbers. (4) The INVALID sentence states the achieved rate, the target and the allowed shortfall. (5) Every other driver rule is unchanged: zero units, a reserved-only or unreadable result, connection refusals, error_count and the connection-peak check still make a run INVALID exactly as they do today, so the tolerance never makes a run that errored or was refused valid. (6) infra/smoke/test-pressure.sh: the healthy fixture's database leg moves from 81 to a rate a paced driver produces (79.9 against 80) and the report check that reads it follows; new cases: a rate exactly at target * 0.98 is valid, the next representable value below it is INVALID with the sentence naming the 2%, a floored database result below the tolerance gives no rate finding and the skip sentence is in the report, a floored result with no target is harmless, a driver with no target is not judged on rate, and a zero-work result with a setup_note carries the note in its INVALID sentence while one without it keeps today's wording. (7) A real proof, run once and recorded, never a per-push test: the database driver from this branch run for 60 s at its default target against a throwaway local postgres:17 container that is removed afterwards, and its result judged through the same mocked run test-pressure.sh drives (PRESSURE_DRIVER_RESULTS) by the base analyser (INVALID on the rate rule) and by the branch's (valid); then a copy of that result with its target set safely above the rate the healthy run measured, INVALID on both, so the proof never rests on a guess about the box's ceiling. (8) A failed setup is reported with its reason: when the zero-work rule at pressure_report.py:362-365 meets a result that carries a non-empty setup_note (A28's field for a setup that failed and stopped the replay), the INVALID sentence keeps "did no work" as the classification and appends the note as its cause; a result without the note keeps today's sentence word for word. (9) The record docs/ops/a30-acceptance.md, and one line in the P-1 command sheet, outside the repository, that the run waits on this card.
Interfaces. Exposes RATE_SHORTFALL_TOLERANCE and the rule that a floored result is exempt from the rate comparison. Consumes A28's floored and setup_note fields and nothing else new. The result schema, every driver file, the mix and the multiplier are unchanged.
Acceptance. bash infra/smoke/test-pressure.sh green with the new cases, and each of the healthy 79.9 fixture, the boundary case, the floored case and the setup-note case failing on the base sources for its own reason. The real proof in the record as deliverable (7) states it, with the achieved rate, the target and every verdict. python3 -m py_compile infra/smoke/pressure_report.py. The infra and spike-a4 jobs green on the pull request. gitleaks detect --no-git --source . --config infra/secret-scan/gitleaks.toml --redact clean. Record docs/ops/a30-acceptance.md; open item #97 closed.
Out of scope. Every driver file, and with them the pacing, the whole-run duration and what achieved_rate_per_s divides by, which stay one contract across the three drivers: a rate over the paced window alone would hide the startup cost the run exists to measure. A28's own contract (ok, the failed-setup rule, the probe order). The pooler p95 pass rule, the baseline and the sampler. The mix, its weights, its target and the multiplier. The transcriber, which states no target.
Waits on / Unblocks. A28: the skip reads A28's floored field, so this card is cut from A4's branch after A28 merges into it, as a stacked pull request on A4's draft pull request #83 / P-1 at X7's T-1, which would otherwise very likely end INVALID on a healthy box, and the X7 go-live record, which quotes the report.
Built as (PR #105 → A4's branch spike/a/a4-intelligence-data-path at eecd24b, 2026-09-18; it reaches main with A4's pull request #83). Co-plan 1 round → agreed with corrections (a relative tolerance and not seconds, the floored exemption read from A28's documented field, and the default database target is 80, the mix's 40 times the multiplier 2, not 160); A28's review then added deliverable (8), the setup note. Code 1 review round → one must-fix (the operator-facing comment in infra/smoke/pressure.sh still stated the old rule), then a re-check → APPROVE. RATE_SHORTFALL_TOLERANCE = 0.02 in pressure_report.py with its reason; a leg is short only when achieved < target * (1 - RATE_SHORTFALL_TOLERANCE), judged only for a positive numeric target and a present achieved rate, and exactly 0.98 of target is valid. A result whose floored is true is not judged on rate, and a sentence directly under the drivers table says so and why. A zero-work result carrying a non-empty setup_note keeps its "did no work" sentence and appends the driver's reason; without a note the sentence is byte for byte the same. Every other driver rule is unchanged in order and wording. pressure.sh's comment on what makes a run INVALID now states the rule and names the constant, so the number lives in one place. test-pressure.sh: the healthy fixture's database leg moved from 81, a rate the paced driver cannot produce, to 79.9 against 80, and the two fixtures built from it moved with it so each case still changes one thing; nine new cases (the boundary computed as the analyser computes it, the next double below it through math.nextafter, a floored result, a floored result with no target, no target, an engine with a target, an error within tolerance, the setup note with and without). 98 ok on the base, 107 ok here; the base analyser fails 10 of the new file's cases, the four the card names each for its own reason. The real proof: the database driver ran 60 s at its default target against a throwaway postgres:17 container, 4,766 statements, achieved 79.332 against 80 (0.835% short), not floored, 0 errors, at load average 1.78 to 1.51; the base analyser called it INVALID (exit 2) and the branch PASS (exit 0), and a copy with its target raised to 100 is INVALID on both. The container and its anonymous volume were removed by exact name. Record docs/ops/a30-acceptance.md; open item #97 closed. The P-1 command sheet was then checked against eecd24b in three rounds (the head and ancestry check, the 78.4 bound, the floored skip sentence, the load-time INVALID causes, and what a floored leg means) and is ready. Found: the report trusts the shape of a driver result, so a malformed one crashes it and reads as FAILED instead of INVALID (open item #101).
Amended 2026-09-19 (the owner's decision: production at the terminal go-live). P-1 left A4's gate. It is now an X7 T-1 gate on the release candidate at the terminal go-live. This card is stacked on A4 and landed before A4's final review. The P-1 command sheet it was checked against is now X7 T-1's runbook, the Intelligence activation pressure run, and is re-checked against the release candidate before use.
A31 · The database probe names the step that failed and keeps measuring · A · S · S2-W1 · fix/a/a31-probe-names-its-step (surfaced while fixing A28 on 2026-09-18, pre-existing on A4's branch, open item #98; carded 2026-09-19)
Context. The database pressure driver's probe records two different failures under one name and then stops measuring. In spikes/intelligence-data-path/pressure/drivers/db-driver.cjs at 8ac6089, unchanged since A28's 21a88fd, the probe (:223-251) opens its own connection at :226 and reads show max_connections at :228-230 inside one try, and its one catch at :231-234 records any failure as probe connect and returns before the sampling loop at :238-250. So a probe that connected and then failed its read is reported as a failed connect, takes no sample, and leaves connections_peak and max_connections null (:183-184, written at :368-369). A failed sample already has its own label, probe at :244, and the loop goes on sampling after it, so what is conflated today is the connect and the read, not three steps as the open item says (it cites :225-234; the probe now opens at :223). Measured at 8ac6089 under Node 24 with a stub client, a 1,500 ms window and concurrency 2: a read refused with SQLSTATE 42501 gives ok false, 120 statements, connections_peak null, max_connections null, 0 refusals and the one error probe connect: permission denied to examine "max_connections"; a sample that fails every time gives two errors labelled probe: beside max_connections 80 and a null peak; the healthy stub gives ok true, peak 12 and max 80. The verdict is already safe. Since A28 any recorded error makes the leg not ok (:349) and the analyser reads any error as INVALID (infra/smoke/pressure_report.py:427-428), so no healthy measurement is hidden. What is lost is the name of the step and the connection peak, and the report's connection line (pressure_report.py:576-577) is then left out, not printed empty. The 42501 case exists only in the test stub (pressure/test/db-driver.test.cjs:231-255). On stock PostgreSQL max_connections is not a superuser-only setting, so every role may read it, and pg_stat_activity lists every backend's row to every role, so the sample's count works for any role too. On a real server the read fails only when the probe's backend is lost between its connect and its read, for example a terminated backend or a dropped network. P-1 cannot meet the defect. The setup, the probe and every worker connect through one factory with one DSN (db-driver.cjs:156, :226, :256, :305). The db-driver service passes PRESSURE_REPLAY_DSN through unchanged (pressure/compose.intel.yml:182), and the P-1 command sheet sets it to pressure_p1, a plain login role that owns only its scratch database (development-processes/skynet/a4-p1-command-sheet.md:101-103, :107, :143). The server is the stack's pgvector/pgvector:pg17 Postgres with max_connections=80 (infra/compose/compose.prod.yml:91, :103), stock PostgreSQL 17 with one extension, where that role reads the setting.
Read first. spikes/intelligence-data-path/pressure/drivers/db-driver.cjs:105-108 (isRefusal), :136-156 (the options and the one client factory every connection goes through), :180-192 (the counters), :216-219 (record, its label and its 2,000-entry cap), :221-251 (the probe: its connect, its read, the one catch, the sampling loop, the sample's label at :244 and the sleep clamp at :246-249), :253-262 (a worker's connect catch, the label shape to copy), :297-330 (the setup connect first, then the Promise.all that starts the probe before the workers), :332-375 (the result, ok at :349 and the fields under extra); pressure/lib/result.cjs:21-47 (the schema number, the one result shape, errors cut to ten at :44, and the extras spread after the base keys at :46, so an extra with a base key's name overwrites it); pressure/test/db-driver.test.cjs:40-81 (late and the stub client), :166-173, :192-197 and :231-255 (the peak, the late probe and the refused read, the cases this card extends); pressure/README.md:224-272 (the floor section, where the probe's contract is written); infra/smoke/pressure_report.py:424-431, :447-448 and :576-577 (what the analyser reads from this result: refusals, errors, the peak, the ceiling and the connection line); pressure/compose.intel.yml:171-204 (the db-driver service); spikes/intelligence-data-path/package.json:12 and .github/workflows/ci.yml:159-162 and :895-910 (the spike-a4 job runs npm run test:ci, which includes pressure/test/**); the P-1 command sheet development-processes/skynet/a4-p1-command-sheet.md:90-107 and :143 (the role P-1 connects as); open item #98.
Deliverables. (1) The probe records each of its three steps under a label of its own: the connect, the max_connections read and a sample, each label naming its step in words a reader of the result understands without the code. A connect failure keeps today's behaviour: it is recorded once, counted as a refusal when isRefusal says so, and ends the probe, because there is no connection to sample on. (2) A failed read does not end the probe. It is recorded once under the read's label, max_connections stays null, and the probe goes on to its sampling loop exactly as it does after a read that succeeded, so connections_peak is measured whatever happened to the read. When the read failed because the probe's backend was lost, each later sample then fails and is recorded under the sample's label, as a lost backend is recorded today after a good read; that is the intended outcome. (3) The result states each step's outcome in one field under extra: whether the probe connected, whether it read max_connections, how many samples it took and how many of them failed. A reader of the result, and A4's F-1 record, then learn which step failed without parsing error text, and the fact survives the ten-entry cut on errors at result.cjs:44, which a run with many statement errors can fill before the probe's entry. The field is probe_steps, a probe record with four stable subfields that the README documents: whether the probe connected, whether it read max_connections, the samples it attempted and the samples that failed; its name is none of the keys buildResult writes and none the analyser reads, because the extras spread over the base keys at result.cjs:46; the schema number at result.cjs:21 stays 1, every existing field keeps its meaning, and no reader requires the new one. (4) Nothing turns a failure green and nothing else moves: ok at :349 is unchanged, so a failed read or a failed sample still makes the leg not ok; the sample query, the one sample before the clock that A28 added, the sleep clamp, the workers, the floor, the setup rule and the order in which the setup, the probe and the workers connect are unchanged. (5) A sample counts client connections only. Today it counts count(*) over pg_stat_activity, which on PostgreSQL 17 also counts the server's own background processes (the checkpointer, the background writer, the WAL writer, the autovacuum launcher and the logical replication launcher), so connections_peak sits about five above the client connections and the ceiling check at infra/smoke/pressure_report.py:447-448 fires about five connections early: a bias toward FAIL, never toward PASS, and one P-1 would carry. The sample counts only the rows whose backend_type is client backend, so the peak and max_connections compare like with like; the analyser is unchanged. (6) pressure/README.md states the probe's three steps, what each failure leaves in the result, the new field, and that the peak counts client connections only. (7) The record docs/ops/a31-acceptance.md, with one real proof run once and never a per-push test: the driver from this branch run for a short window against a throwaway local postgres:17 container, which the proof removes afterwards with its role and database by exact name and never by a broad cleanup command, as a plain login role that owns only a pressure_ database, the shape the P-1 sheet creates; its result shows the probe connected, max_connections read, samples taken with none failed, both connections_peak and max_connections set, and the peak equal to the client connections the run opened (the probe's own, the setup's and the workers') while count(*) over pg_stat_activity at the same moment is higher by the server's background processes; and python3 infra/smoke/pressure_report.py run on a work directory whose drivers/ holds that one result shows the database row in the drivers table, the line Peak connections during the replay: <peak> of <max>, and no sentence that names the database driver other than the rate rule's, which is open item #106's until it lands.
Interfaces. Exposes one new field under extra in the database driver's result that states each probe step's outcome, and three step labels in its errors. Consumes nothing new. Unchanged: the result schema number and every existing field, ok, the analyser infra/smoke/pressure_report.py, the engine and transcriber drivers, compose.intel.yml and the P-1 command sheet.
Acceptance. cd spikes/intelligence-data-path && npm run test:pressure green under Node 24, with new or extended cases in pressure/test/db-driver.test.cjs that each fail on the base sources for their own reason. A read refused with 42501: exactly one probe error, labelled as the read and not as the connect; connections_peak equal to the stub's count; max_connections null; the step field saying connected, not read and at least one sample; ok false, 0 refusals, and the lap still replayed (on the base the label is the connect's and the peak is null). A probe whose own connect is refused while the setup and the workers connect (the setup connects first and the probe second, :305 and :324): one error labelled as the connect, one refusal counted, and the step field saying not connected, not read and no sample (on the base the field is absent). A sample that fails once and then succeeds: one error labelled as a sample, a peak taken from the samples that succeeded, and the step field counting one failed sample (on the base the label is probe and the field is absent). A healthy run: the step field says connected, read, at least one sample and none failed, and ok is true (on the base the field is absent). A test builds a normal result with no extras and asserts that probe_steps is absent from its keys, which proves the field cannot overwrite a base key. The existing peak, late-probe and refused-read cases at :166-173, :192-197 and :231-255 pass, with the refused-read case's assertions extended as above and none weakened. npm run test:ci green, which is what the spike-a4 job runs. A case in which the stub answers the sample's query only when it filters on backend_type, or a check of the query text, proves the sample counts client connections only (on the base the query has no filter). The real proof in the record as deliverable (7) states it, with the result's probe fields and the analyser's drivers section. The spike-a4 job green on the stacked pull request. gitleaks detect --no-git --source . --config infra/secret-scan/gitleaks.toml --redact clean. Record docs/ops/a31-acceptance.md; open item #98 closed.
Out of scope. The analyser: pressure_report.py keeps reading errors, connection_refusals, connections_peak and max_connections as it does, the new field is for the result's reader and the F-1 record, and a result the analyser cannot read is A32's card. The ceiling rule itself, including that the server reserves superuser_reserved_connections (3 by default) and refuses a plain role a few connections below max_connections: it errs toward FAIL and stays as it is. Trying the read again, and opening a new probe connection after the probe's backend is lost. The engine and transcriber drivers, the workers, the floor, the setup rule, the mix, the result writer, compose.intel.yml and the P-1 command sheet.
Waits on / Unblocks. A4: the driver exists only on A4's draft pull request #83 (spike/a/a4-intelligence-data-path), so this card is a stacked pull request on that branch, cut from the rebased head 81ad786, as A28, A29 and A30 were, and it merges into the branch before #83's final review. P-1 runs at the terminal go-live on the release candidate (the owner's decision of 2026-09-19) and consumes this merged code; the run cannot meet the defect, so nothing about P-1 waits on this card. It shares no file with A32; whichever of the two merges second is rebased and re-tested on the first. / A4's F-1 record, and every later run of the replay, whose result then names the probe step that failed and keeps its connection peak when the read fails.
Amended 2026-09-19 (code review, the design as built). The probe connects through its own PRESSURE_PROBE_DSN, as a role that holds only pg_read_all_stats and names the replay's host, port and database; the replay role stays as restricted as before. The dedicated role is required because on PostgreSQL 17 a plain role sees backend_type only for its own sessions, so the literal backend_type = 'client backend' sample needs that visibility, and a fallback on a null type would count hidden autovacuum and parallel workers. probe_steps carries five fields: whether the probe connected, whether it read max_connections, whether its role sees every backend (sees_all_backends), the samples it attempted and the samples that failed. The probe fails closed, with the peak null and the leg not ok, when there is no probe DSN, when the DSN is not a PostgreSQL URL or names another host, port or database, when its role lacks the grant, and when the check errors. A backend lost after the connect may fail both the read and the privilege check, and the probe then ends before it samples; this replaces deliverable (2)'s expectation that each later sample fails. Deliverable (4)'s "the sample query is unchanged" is superseded by deliverable (5). compose.intel.yml passes PRESSURE_PROBE_DSN to the driver and its test proves the wiring. P-1's runbook gains the probe role and its DSN. The real proof's report may name the database driver only on the rate rule, which is open item #106 (the replay's pacing), until #106 lands; no other database-driver finding is allowed.
A32 · The pressure report calls a result it cannot read INVALID, never FAILED · A · S · S2-W1 · fix/a/a32-report-checks-result-shape (surfaced while building A30 on 2026-09-18, pre-existing on A4's branch, open item #101; carded 2026-09-19)
Context. The pressure analyser judges each driver's result without checking its shape. infra/smoke/pressure_report.py:191-211 (load_drivers) turns a file that does not parse into an unreadable entry and a reservation into a reserved_only entry, and passes anything else that parses through as it is; the driver rules at :383-431 and the leg count at :436-446 then call .get on it and compare its fields as numbers. Measured at 8ac6089 by running the analyser on a work directory that holds one result: a file holding [] or null raises AttributeError at :384; an achieved_rate_per_s of "79.9" against a target of 80 raises TypeError at :419; the same string on a floored result raises ValueError in rate_text at :415; errors of null beside an error_count raises at :428; a connections_peak of "12" raises at :430; a driver of ["database"] raises at :439. The same holds outside the drivers: a meta.json holding [] raises AttributeError at :240. Each ends in exit 1 before main writes the report (:617-622), and infra/smoke/pressure.sh:911-920 prints exit 1 as PRESSURE TEST FAILED with the advice to lower a cap, naming a report that does not exist. That is the verdict that says the box measured badly, given for a result nobody could read. Three smaller gaps: a positive target beside a null achieved rate is not judged at all (:406 requires achieved is not None; measured, no rate finding); an achieved_rate_per_s of NaN against a target of 80, which Python's json reads although no JavaScript writer emits it, compares false at :419 and gives no finding for that driver (measured); and the drivers table at :567-570 prints None for any field present as null (measured: | engine | pages | 5 | 1.2 | None | 0 |). None of these comes from the writers P-1 runs. The three drivers write through one writer, spikes/intelligence-data-path/pressure/lib/result.cjs:23-47, the transcriber through pressure/lib/write-driver-result.cjs:53-65, and it always writes an object with a numeric achieved_rate_per_s (:41), numeric units and error_count, a list of errors and a target that is a number or null (a NaN serialises as null). The drivers' extra fields (drivers/db-driver.cjs:350-373, drivers/engine-driver.cjs:180-193, drivers/transcriber-driver.sh:158-162) override none of those keys. A result is written beside its placeholder and renamed over it (result.cjs:174-196), so a driver that dies leaves its placeholder, which is already read as reserved-only. On P-1 the results directory is $WORK/drivers (pressure.sh:135-137; the sheet sets no PRESSURE_DRIVER_RESULTS), inside the kept work directory the sheet names at a4-p1-command-sheet.md:147, which pressure.sh:132 sets to mode 700. The None cell does appear on P-1. The engine's target is null unless PRESSURE_ENGINE_TARGET_RATE is set (engine-driver.cjs:241), which pressure/compose.intel.yml:50-71 does not set, and the transcriber states no target, so both rows read None in the target column; no verdict changes. The mocked suite never showed it, because its healthy fixture gives the engine a target of 3 and the transcriber 0.5 (infra/smoke/test-pressure.sh:417-418), targets the real rig does not state. The open item's line numbers still hold at 8ac6089: A29 changed no file under infra/.
Read first. infra/smoke/pressure_report.py:1-39 (the module's contract and its exit codes), :191-217 (load_drivers, is_positive_number), :220-226 (rate_text), :233-245 (where meta.json is read and first used), :380-448 (the driver rules, the leg count and the connection ceiling), :504-509 (gb and pct, the dash the other tables already use), :566-582 (the drivers section of the report), :613-631 (main: the analysis, the overwrite refusal, the write, the exit code); infra/smoke/pressure.sh:130-137 (the work and results directories), :835-838 (the results copied beside the samples) and :911-920 (the analyser call and the three verdict lines); infra/smoke/test-pressure.sh:400-420 (write_driver, healthy_drivers) and the driver cases after it to :509, among them :469 and :472, the engine cases with a target and with none; spikes/intelligence-data-path/pressure/lib/result.cjs:21-47 and :174-202 (the one shape and the atomic write), pressure/lib/write-driver-result.cjs:30-67, pressure/lib/gate.cjs:85-99 (the gate-timeout result), pressure/drivers/db-driver.cjs:332-375, pressure/drivers/engine-driver.cjs:169-194 and :241, pressure/drivers/transcriber-driver.sh:111-122 and :154-162; pressure/compose.intel.yml:50-204; .github/workflows/ci.yml:159-162 (the spike-a4 filter, which includes infra/smoke/**), :507 (the infra job runs test-pressure.sh) and :895-910 (the spike-a4 job runs npm run test:ci only); A30's card and its record docs/ops/a30-acceptance.md (the rules this card leaves as they are); the P-1 command sheet, steps 3 and 5 (the verdict lines and exit codes an operator reads); open item #101.
Deliverables. (1) One shape check runs on every driver result before any rule reads it, and holds each field the analyser reads to the type lib/result.cjs writes: the result is a JSON object; driver is a non-empty string; units and error_count are finite numbers that are not negative; achieved_rate_per_s is a finite number that is not negative; target_rate_per_s is null or a finite number that is not negative; errors is a list of strings; and, where present, unit_name is a string, floored a boolean, setup_note a string or null, connection_refusals, connections_peak and max_connections null or finite numbers that are not negative, and client an object. One predicate defines a finite number for every numeric field named here: an int or a float, never a bool, and finite under math.isfinite, so no boolean passes as a number and NaN and infinity, which Python's json reads, are refused. A field the analyser does not read is not checked, so a driver may add fields under extra (A31 adds one) without a change here. (2) A result that fails the check is INVALID with one sentence that names the driver (the file's name when the result carries no usable driver, as the unreadable case does at :202), the field, a short form of what it held and the type the writer writes. No other driver rule reads that result, so one broken result gives one finding, and it still counts as its leg's result for the one-result-per-leg rule at :436-446, so the run does not also say that leg left no result. (3) A result that states a positive target and has no achieved rate is INVALID, and its sentence names the target and says that a stated target with no achieved rate cannot be judged; this is the shape rule on achieved_rate_per_s, stated here because :406 skips that case today. (4) The drivers table renders a missing or null value as a dash, as the containers table already does through pct and gb at :504-509; a present value renders as today, so the healthy report's row | database | u | 96000 | 79.9 | 80 | 0 | is unchanged. (5) No analyser fault reads as FAILED. An exception anywhere in the analysis, the rendering or the write ends the analyser with exit 2 and an INVALID line that names the exception and where it was raised. It writes a minimal INVALID report when the output path is writable and absent; otherwise it leaves the existing evidence untouched and the caller reports INVALID without claiming a report was written. The analyser catches Exception, never BaseException, so an interrupt or a termination is never turned into a completed INVALID result, and a FAIL stays exit 1 only when the analysis and the report write complete normally. And at the verdict step (pressure.sh:911-920), pressure.sh prints PRESSURE TEST FAILED only when the analyser exited 1 and the report it names exists; any other exit 1 there is printed as INVALID with a sentence that the analyser stopped before it judged the run, and the script exits 2. The script's earlier FAILED lines, such as an image that cannot be pulled, are unchanged. The three exit codes and the three verdict words are unchanged. (6) Every rule the analyser has today keeps its order, its wording and its verdict for a result that passes the check: zero units with and without setup_note, reserved-only, unreadable, the rate rule and its 2% allowance, the floored skip, refusals, errors, the connection ceiling and the leg count. (7) infra/smoke/test-pressure.sh: the healthy fixture states no target for the engine and the transcriber, as the real drivers do on P-1, and the cases that give the engine a target, such as :469, keep one. (8) The record docs/ops/a32-acceptance.md.
Interfaces. Exposes the shape a driver result must have to be judged, written once beside the rules that read it, and the rule that an analyser fault is INVALID. Consumes the result shape of pressure/lib/result.cjs and nothing new. Unchanged: every driver, lib/result.cjs, the result schema number, the verdict words, the exit codes 0, 1 and 2, the P-1 bar and every rule for a well-formed result.
Acceptance. bash infra/smoke/test-pressure.sh green with new cases that each fail on the base sources for their own reason. A result file holding [], one holding null, a string achieved_rate_per_s against a target, the same string on a floored result, errors of null, a string connections_peak and a list for driver: each ends the run PRESSURE TEST INVALID with exit 2, a report written and a sentence naming the field, and never PRESSURE TEST FAILED (on the base: exit 1, PRESSURE TEST FAILED, no report). A malformed result for a configured leg adds no sentence that the leg left no result. A positive target with a null achieved rate on an otherwise healthy run is INVALID naming the target (on the base: no finding and exit 0). An achieved rate written as the bare token NaN against a target of 80 on an otherwise healthy run is INVALID naming the field (on the base: no finding for that driver and exit 0). A units of true is INVALID naming the field (on the base: judged as a count). The healthy report's drivers table shows a dash, not None, in the engine and transcriber target cells (on the base: None). The analyser run directly on a work directory whose meta.json holds [] exits 2 with an INVALID line naming the exception and leaves a report (on the base: exit 1, a traceback from :240 and no report). pressure.sh run with an analyser that exits 1 and writes no report prints INVALID and exits 2 (on the base: PRESSURE TEST FAILED). An analyser fault with an output path that already exists leaves that file unchanged and exits 2 INVALID, never overwriting it and never FAILED. A negative target_rate_per_s is INVALID naming the field (on the base: the rate rule is evaded). Every existing case passes with its wording unchanged, and the record gives the case count on the base and on the branch. One guard, stated as a guard because it fails on the base only in that the check does not exist there: results written by the real writers, pressure/lib/result.cjs for the database and engine drivers and pressure/lib/write-driver-result.cjs for the transcriber, with the settings compose.intel.yml gives P-1 (the engine and the transcriber with no target, the database at 80), pass the check with no finding, and so do a gate-timeout result (pressure/lib/gate.cjs:85-99), a failed-setup result and a floored result; it lives in test-pressure.sh, which the infra job runs at ci.yml:507, or in pressure/test/, which the spike-a4 job runs through npm run test:ci, and the record names which. python3 -m py_compile infra/smoke/pressure_report.py and bash -n infra/smoke/pressure.sh. The infra and spike-a4 jobs green on the stacked pull request. gitleaks detect --no-git --source . --config infra/secret-scan/gitleaks.toml --redact clean. Record docs/ops/a32-acceptance.md; open item #101 closed.
Out of scope. Every driver file and lib/result.cjs: the writers already write the shape this card checks, and no result changes. meta.json, legs.json and the samples, which pressure.sh writes itself and parses as today; they get no shape check of their own and are covered only by deliverable (5). The rules for a well-formed result, the P-1 bar, the rate allowance, the caps, the pooler and the api legs. The probe step field that A31 adds, which this card neither reads nor rejects. The connection line's ? for a missing max_connections at :577. The P-1 command sheet: its three verdict lines and three exit codes stay what they are.
Waits on / Unblocks. A4: the analyser's driver section exists only on A4's draft pull request #83 (spike/a/a4-intelligence-data-path), and main's analyser has none, so this card is a stacked pull request on that branch, cut from the rebased head 81ad786, as A28, A29 and A30 were, and it merges into the branch before #83's final review. P-1 runs at the terminal go-live on the release candidate (the owner's decision of 2026-09-19) and consumes this merged code; no P-1 driver can produce a malformed result, so nothing about P-1 waits on this card. It shares no file with A31; whichever of the two merges second is rebased and re-tested on the first. / A4's F-1 record, which quotes the report; A2d's pressure sample; and every later pressure run on main once #83 merges, whose analyser then reports a result it cannot read as INVALID with its reason and never as a cap to lower.
S34 · The migration runner replays schemas in a declared order · S · S · S2-W1 · fix/s/s34-migration-schema-order (discovered by A4 planning on 2026-09-14, open item #76)
Context. apps/api/migrate.py:68-91 builds the apply plan from sorted(p for p in migrations_dir.iterdir() if p.is_dir()) (:76), then the sorted NNN_*.sql files inside each directory, so the order across schemas is alphabetical on the directory name. Today that is core then production and it happens to be right; the moment S2 adds meta_ads it becomes core < meta_ads < production, and A13a's meta_ads/002_competitors.sql links product_competitors.product_id to production.products. Every fresh replay would then reach meta_ads/002 before production/001_baseline had created the table and fail: the test container's own run (app/backend/tests/core/conftest.py:55-58 calls migrate.main against a scratch database, and every core test depends on it), a dev stack's compose migrate service, S12's migration dry run and X5's restore drill. Alphabetical is not a decision anybody took — docs/MIGRATIONS.md:45-51 writes it down as "directories sorted, then files sorted within each directory" — and it is cheap to replace because the LEDGER does not depend on it: the version key is <schema-dir>/<file-stem> (migrate.py:6-8), so re-ordering directories renames nothing and invalidates no row. Two readers do depend on plan ORDER and must keep agreeing after the change: compute_head (:104-110), which GET /api/version serves (app/backend/main.py:368-389), which the deploy smoke gate compares and which X5's rollback reads to decide whether a pre-migrate dump must be restored.
Read first. apps/api/migrate.py:1-21 (the module contract: plan order, the version key, the rules it enforces), :52-62 (default_migrations_dir, the image and checkout layouts), :65-91 (_FILE_PATTERN, discover_plan, and the stray-file refusal at :78-82 — the shape a directory refusal copies), :104-110 (compute_head), :113-127 (classify, and the unknown-ledger-row rule), :145-213 (main); apps/api/app/backend/main.py:368-389 (_migration_plan, _migration_head) and the /api/healthz + /api/version handlers below them; apps/api/app/backend/tests/core/test_migrate.py:41-80 (the pinned 21-entry plan order and head), :152-175 (the two pure tests — the shape the new order tests take), :180-201; apps/api/app/backend/tests/core/conftest.py:40-61; docs/MIGRATIONS.md (the whole of "Ledger and version keys", and the numbered steps for adding a migration); db/migrations/unified/ as it stands (core/001–core/013, production/001–production/008); A13a's deliverables (the meta_ads/002_competitors.sql shape) and A9's (meta_ads/001_baseline.sql); open item #76.
Deliverables. (1) The directory order is DECLARED and not inferred: one ordered list in migrate.py — core, production, meta_ads, and studio where S3 adds it — that discover_plan walks instead of sorted(...), with a comment stating the rule (a schema that references another schema's tables applies after it) rather than the current alphabetical accident. (2) A directory present in the tree that the declaration does not name is a hard error naming the directory and the remedy, in the same fail-closed shape as the stray-file refusal at :78-82 — so the next schema cannot be added without a position, and A9 declares meta_ads in the same PR that creates the directory. (3) A declared directory that does not exist yet is skipped without complaint: the tree is built slice by slice. (4) File order inside a directory is unchanged, the version key is unchanged, and compute_head, classify and the ledger rules are untouched. (5) docs/MIGRATIONS.md states the declared order, why it is not alphabetical and where the declaration lives; the "adding a migration" steps gain the declaration for a NEW schema directory. (6) docs/ops/s34-acceptance.md with the measured before/after plan order for the real tree.
Interfaces. Exposes the declared directory order every future schema directory must join, and a refusal for one that has not. Consumes nothing new; the ledger's version keys and compute_head's contract are unchanged.
Acceptance. pytest: over a tmp_path tree holding core, meta_ads and production, discover_plan returns them in declared order and NOT alphabetical order; an undeclared directory raises SystemExit naming it; a declared directory that is absent is skipped; over a scratch database, a fixture tree whose meta_ads file references a table the production file creates applies clean end to end and the same tree ordered alphabetically fails — so the suite proves the ordering rather than asserting a list; the existing pinned assertions in tests/core/test_migrate.py:41-80 still hold (the 21 entries in core/… then production/… order, every checksum matching, head production/008_render_provider_to_binding); GET /api/version reports the same head as before the change; the whole api tier green, since every core test runs the runner at fixture time. Record docs/ops/s34-acceptance.md; open item #76 closed.
Out of scope. Per-FILE cross-schema dependencies — a declaration per directory is what this tree needs. A13a's own migration and A9's meta_ads/001_baseline.sql. Moving the link to a production-side migration, which this card makes unnecessary. The llm_reporting schema and its cluster-global role (open item #75), the meta_ads function-body lint (#77), and the admin access boundary (#74).
Waits on / Unblocks. Nothing — the runner exists today and this needs no meta_ads directory to prove / A9 (which declares meta_ads when it creates the directory), A13a.
Built as (PR #93 → main 7937a03, 2026-09-17). Co-plan 1 round → agreed with one correction (the alphabetical negative proof runs on a second fresh scratch database, because the ledger of a successful run would report everything applied and prove nothing). Code 1 review round → APPROVE with no must-fix. apps/api/migrate.py declares SCHEMA_ORDER = ("core", "production", "meta_ads") with the rule in its comment (a schema that references another schema's tables applies after it); studio is not declared and S3 adds it when its references are known. discover_plan walks the declaration, skips a declared directory the tree does not hold yet, keeps the per-directory file walk and the stray-file refusal as they were, and after the walk refuses every directory with no declared position in one sentence that names each path and the remedy (declare it in SCHEMA_ORDER where its references need it, or move it out of the tree); it is an error and never a warning, because a scratch directory under the migration root cannot be told from an undeclared schema. File order, the version key, compute_head, classify and the ledger rules are untouched. Five tests in tests/core/test_migrate_schema_order.py, all failing on the base sources: the declared order over a temporary tree, the refusal, the skip, a fixture tree whose meta_ads file holds a foreign key to a table its production file creates applying clean on a fresh scratch database, and the same tree failing on a second fresh database once the order is patched to the alphabet (schema "production" does not exist, only the earlier file recorded). The rollback test moved from aaa/bbb to declared directory names (it pins rollback, not names) and scratch_db moved to the core conftest. The real tree's plan is identical before and after (21 entries, the same checksums, head production/008_render_provider_to_binding); with a meta_ads directory added to a copy of the tree the base put it at positions 14 to 15 and the branch at 22 to 23. Whole api tier 4322 passed / 11 skipped / 3 deselected, +5; the PR's api job was the tier on the actual base. docs/MIGRATIONS.md states the declared order, why it is not alphabetical, and the step for a new schema directory. Record docs/ops/s34-acceptance.md; open item #76 closed. Discovered, recorded: infra/migrate/production/restore.sh builds its own directory list but compares it as a set, so it does not read plan order.
S35 · A creative code belongs to its product · S · M · S2-W1 · fix/s/s35-code-identity-per-product (open item #61, decided by the owner 2026-09-17)
Context. A creative code such as TMi1-A is the short identity every ad name carries so performance traces back to the exact creative. workos numbers codes per product, starting every product at 1 (nextSequence(productNumber, kind); POST /naming/sequence on the bridge). Skynet's register was built as if a code string were unique across the workspace: core.creative_codes and core.launch_packages are UNIQUE (workspace_id, code), the lineage node is creative_code:<code>, GET /api/creative-codes/{code} and the creative page /creatives/:code address by string, and the readback folds view rows by canonical code across products. Measured on the dev stack on 2026-09-10: the test product 999001's draws TMi1-A, TMi2-A, TMi3-A were each refused 409 because product 441 holds them. Right with one exporting product; wrong the day a second product exports its first team-made image (a 409 on export), and silently wrong in the readback where two products' ads share a code. The owner decided on 2026-09-17: the identity of a creative is (workspace, product, code); the code grammar, which is workos's and ecomprofits' contract, does not change; nothing changes for a person using Skynet.
Read first. Open item #61; apps/api/app/core/creative_codes/ (the register, the sequence through the bridge, the generator); db/migrations/unified/core/ (the two unique constraints); apps/api/app/core/lineage/ (the node id); apps/api/app/core/readback/ (the fold by canonical code; the ad name's product slot); apps/api/app/core/launch_packages/; apps/web/src/shell/pages/creatives/ and components/creative-code/ (the address and the chip); V7b's record (product_number already travels on every codes[] entry); sequence-via-bridge-plan.md (workos resolves product THEN code in findCreativeByCode).
Deliverables. (1) A forward-only migration: core.creative_codes and core.launch_packages unique on (workspace_id, product_number, code), the product carried on every row, the existing rows backfilled from the product they were issued for. (2) Every consumer carries the product: the read route (/api/creative-codes/{product}/{code}, the bare-code route kept and answering 300-style ambiguity as a 409 naming the products when more than one holds the code), the creative page's address (/creatives/:product/:code, the bare address redirecting when unique), the lineage node (creative_code:<product>:<code>, old nodes migrated), the launch package row, and the readback's match by the ad name's product slot. (3) The register refuses a draw whose product is unknown and never a draw whose code exists under another product. (4) Tests: two products each holding TMi1-A, both exporting, both resolving by address, the readback attributing each product's rows to its own creative; the bare-code paths on a unique and on an ambiguous code. (5) docs/ops/s35-acceptance.md; ADR-021 stating the identity.
Acceptance. The dev-stack loop with two products: the second product's first export succeeds; GET /creatives/<p>/<code> for both; the readback shows each product's rows under its own creative; the whole api and web suites green; migration replay from scratch green (S34's ordered runner). Open item #61 closed.
Out of scope. Changing the code grammar or workos's counter; the ClickUp Launch card; the pilot product's existing codes beyond the backfill.
Waits on / Unblocks. S34 (the ordered migration runner) / the second exporting product; S10's loop on more than one product.
Built as (PR #97 → main 78165f0, 2026-09-18). Co-plan 2 rounds → agreed with corrections (a dependency-safe migration order; the product-existence check inside the service before the bridge draw; the snapshot's primary key carries the product; the backfill proven from a copied tree; one builder). Code 1 review round → 3 must-fixes and 2 notes, then 3 re-checks on one shared grammar → APPROVE. core/014_creative_code_identity.sql, one transaction: the package rows, the snapshot rows and both lineage ref families are backfilled first, while (workspace_id, code) still answers one row, then the old package key and foreign key go, the register's unique becomes (workspace_id, product_number, code) with a plain index on (workspace_id, code) for the bare lookups, the package unique and its three-column foreign key follow, the snapshot key becomes (workspace_id, product_number, creative_code, ad_id, date), and creative_code refs read <product>:<code>, performance refs <product>:<code>:<date>. The register refuses a draw for a product the workspace does not know inside issue_code, on Production's own connection and before next_seq, so the in-process export stamp cannot burn a counter number either; its 409 names the product. GET /api/creative-codes/{product}/{code}, iterate, GET /api/performance?product=&code= and the three package routes carry the product; every bare form stays and answers one holder, a 409 whose detail names the code and the products, or 404, resolved through the register and never through the rows that happen to exist (must-fix 1: the bare package routes had decided ambiguity from the package rows, so a second product that had not reached reconciliation was served the first's package). The readback keys its register and its fold by (product, code) with the product from the view row's own column and writes the product on every snapshot. The smoke readback code accepts <product>:<code> under one grammar shared word for word by the synthetic check and infra/smoke/run.sh through infra/smoke/readback-code.sh (must-fix 2 and the three re-checks: the check had aggregated snapshots by bare code; then the two split rules diverged on whitespace, on an unvalidated code part and on embedded newlines, until both sides refused the same shapes and a parity test drives the real shell helper from Python over one case table). The bridge payload to workos is unchanged. The web: /creatives/:product/:code, the bare address resolves and replaces itself or shows one link per product; creativeRef.ts stays the one address module; chips, the lineage sheet and the snapshot footer carry the product; a creative_code node's label still shows the code. Contracts: the two ref conventions in the lineage README, schema and fixture chain. e2e: the creative and winners specs read the seeded product number off the api and assert the product address (must-fix 3: the winners spec still expected bare links). seed_dev fills a missing product number on an existing fixture product (found: it set one only when it created the product). Tests: two products each holding TMi1-A through the real register and the bridge fake, both addresses, the bare 409, the unknown product refused before any bridge call, the readback attributing each product's rows, two package rows for one code, and the migration proof over the real tree through 013 plus old-shaped rows and then 014, asserting every backfill and rewritten ref, that the new snapshot key permits one ad and day under two products and refuses a duplicate under one, and that the package foreign key cascades over its own product only. Whole api tier 4331 passed / 11 skipped / 3 deselected, +14; web 118 files / 1023 tests, lint, build and eslint src clean; the whole e2e chromium project against the dev stack 114 passed; on the dev stack two seeded products drew the same code, 201 each, the unknown product 404, both product addresses 200, the bare code 409 naming both. ADR-021 states the identity, ADR-002 §7 and ADR-005 §5 carry supersession notes; docs/SECURITY.md, apps/api/CLAUDE.md and the smoke env examples updated. Record docs/ops/s35-acceptance.md; open item #61 closed. Housekeeping the owner may want: the dev stack's pre-existing volume predated its generated env, so the builder aligned the postgres role password and three fixture users' password hashes on the dev stack only; a make dev-clean restores a reproducible dev database.
A9 · meta_ads migration + scraper direct-pg cutover · A · L · S2-W2
Read first. A4 artefacts; services/scraper/lib/db.cjs; every call site from the A4 inventory; admin/lib/supabase*.ts; admin/lib/auth.ts (admin_users, HS256 cookie) — replaced by Skynet auth for the native pages.
Deliverables. Migration db/migrations/unified/meta_ads/001_baseline.sql (schema + all functions, workspace_id added to every table with the default workspace, composite uniqueness updated); data migration runbook + script (infra/migrate/meta_ads/{dump,restore,verify}.sh, checksums per table); server.js, all lib/*.cjs workers and the remaining admin API routes rewired to lib/db.cjs (no supabase-js, no GoTrue, no Storage client — R2 only, no Realtime); env: DATABASE_URL (Skynet Postgres), R2_*; the scraper's settings table stays, read through the same client; node --test test/ extended with a parity suite (old vs new answers for ads_browse, intel_*, research_*, teardown enqueue on the same fixture). The step that drops add_market_intel.sql's mi_* REVOKEs and GRANTs runs only after A9a is merged, because those grants are the admin's access boundary until the app layer holds it.
Acceptance. Parity suite green; verify script: row counts + checksums equal after a full migration on staging; rg "supabase" services/scraper --glob '!node_modules' --glob '!*.md' = 0 runtime hits.
Out of scope. Native UI (V22/V15); retiring the old stack (A19).
Decided 2026-09-17 (owner, on D-1's findings). G1 is ACCEPTED: ADR-020's direct-Postgres path is the way in, the API-layer option is dead. The three differences D-1 found between the live project and its migrations are MISTAKES to fix in this card, never copied: (1) mi_export_count is called by the engine's code and absent live; the unified schema carries it from the migration and the port proves the call path; (2) PUBLIC EXECUTE on refresh_brand_metrics is revoked as the migrations say; (3) the admin_users policies that admit every role are replaced by the app-layer rules A9a builds. One more port finding to honour: mi_claim_retag hands the app role no claimable work under the project's own rules, so the retag queue's grants are part of this card. The exact shape of each fix is settled in this card's co-plan.
Agreed shape (co-plan 2026-09-17, 2 rounds, before the build; A9 starts only after A4's pull request merges). One builder implements ordered leg commits on one draft pull request, with a review after each leg and a whole-pull-request review before the merge; if a builder's context is exhausted a successor continues the same branch from a leg boundary. L-A, the baseline. db/migrations/unified/meta_ads/001_baseline.sql is derived from the owner's dump (the final state of 44 tables and 34 routines), never from a concatenated replay of the 45 historical files, which suits neither the one-transaction-per-file runner nor the former concurrent indexes; the file is reviewed and deterministic, and a machine-readable drift report names every intentional difference from migration-order.json's replay. The fixes live in the file: mi_export_count present; EXECUTE on refresh_brand_metrics revoked from PUBLIC; no admin_users policies; row-level security DISABLED on all seven tables that carry it live and their auth.role() policies dropped, because one application role cannot meaningfully satisfy them and the app layer is the boundary (ADR-022); the mi_* REVOKE and GRANT pairs carried as they are (their removal is A9a's); mi_claim_retag reachable by the application role; llm_reporting not carried. Every table gains workspace_id on production/005's pattern (nullable add, a slug-joined backfill to the rescale workspace, an index per table, no cross-schema foreign key) and every business-key UNIQUE gains workspace_id in front; that is the whole tenant change of this card. Before S5 every row carries the same workspace, so no cross-workspace link can occur; ADR-022 carries the inventory of every routine and join that still reads the whole schema, with its exact count, as S5's multi-tenant work. meta_ads is already declared in the runner's schema order (S34). L-B, the adapter's home. lib/db.cjs with its allowlist, guards, shape and pool modules and their contract tests moves into ONE local shared package inside services/scraper, copied into the admin build stage and present in the admin's traced standalone output; pg becomes a production dependency wherever the built engine or admin requires it, and the image is rebuilt (this is A9's, the mirror's merge slice). The row cap is ONE rule: the hidden 1,000-row maximum is dropped and every one of the 59 set-consuming selects states a reviewed explicit bound; the 3 paging sites keep their page limits. L-C1 rewires server.js and the lib/** workers; L-C2 rewires the admin routes and admin/lib (the server client and the service client become the one adapter), storage goes to R2 only through the existing provider switch, the browser realtime hook becomes polling of the job row through an admin route, and the settings table is read through the adapter; every ported site whose {data, error} was ignored gains a throw path, and the two silent failures ADR-020 names are fixed. L-D, parity. A4's harness cases ported into services/scraper/test (old client against the reference, the adapter against the candidate, the same fixture; browse, intel, research, teardown enqueue, the 28 called routines), 0 FAIL in the scraper job. The acceptance's literal search for supabase is replaced by a shipped-runtime import gate over server.js, lib/** and the admin's production route and lib modules (no supabase-js, GoTrue, Storage or Realtime import); the parity fixtures keep the old client on purpose. L-E, the data migration. infra/migrate/meta_ads/{dump,restore,verify}.sh: freeze the source, dump, filter the table of contents, restore the legacy archive into an ISOLATED legacy schema, then explicit column-list copies into the final meta_ads tables with the default workspace joined from core.workspaces; every foreign key and constraint validated after the load (a copy under session_replication_role = replica alone can leave bad references that counts and checksums do not see); concurrent indexes built outside the runner; per-table checksums; a drill in miniature in CI like infra/migrate/production/test.sh; the runbook's draft sentences replaced by the drill's proof. L-F, the staging proof, DATA half only. The scripts run against staging's unified Postgres, verify.sh equal, the parity suite green against the local pair; then A9 merges. The unified ENGINE on staging under its caps is A2d's acceptance, and the old stack stays scheduled until that proof passes: cutover day is A2d's, not A9's. Order in the wave. A9 L-A → A9b developed as a stacked change on it → L-B → L-C1 → L-C2 → A9a developed as a stacked pull request on that commit → L-D → L-E → L-F → merge A9 → merge A9a (which owns meta_ads/002, the grant removal) and A9b → A2d's unified-runtime staging proof. Owner decisions carried as recommendations at this gate: the runtime dependency move and the image rebuild sit in A9; the legacy admin keeps its own login (admin_users and the HS256 cookie, hardened by A9a) through the cutover window until A19 retires it; the proxy password and the model-router key are rotated through the deploy and vault mechanisms before the staging cutover, and neither value appears in a migration artefact.
Amended 2026-09-19 (the owner's decision: production at the terminal go-live). A9 is the staging port, the migration automation and the direct-pg proof. Its acceptance requires a reproducible staging rehearsal from a named source snapshot, an explicit watermark and freshness boundary, and a verification of the final-delta procedure, not only "the same answers". The 47 unmeasured inventory rows stay visible in A9's acceptance and in the go-live acceptance. A9 performs no live freeze, final delta, scheduler switch, redirect or archive; X7 executes those at the go-live, from the artefacts A19 prepares. The "cutover day" the agreed shape gives to A2d is now A2d's staging proof, and the live scheduler switch is X7's. Until the final switch at the go-live, the old scraper's data is authoritative. Staging output is never a production source and no production-facing workflow consumes it. No dual writes. The evidence's limits are stated in this text: "The direct-Postgres port is proven against the captured meta_ads snapshot, including real-data cases, but not against the final production delta. The final freeze, delta migration, checksum and row-count verification, sequence state, and source-to-target authority switch remain unproven. Forty-seven inventory rows remain unmeasured and must remain visible in A9 and go-live acceptance."
Amended 2026-09-19 (co-plan: the staging rehearsal and the terminal replacement). A9 builds the staging port, the migration automation and the direct-Postgres proof. Its named rehearsal source is the owner-provided custom-format meta_ads archive, identified in every command and record by its full SHA-256; an archive path is an input location only, and a digest mismatch refuses the run. The rehearsal record carries a source-scope manifest: the included tables; the excluded rows of admin_users, settings and proxies; and the target treatment of each exclusion. It records the archive creation time, per-table row counts and each applicable table timestamp maximum as observations only, with none where a table has no suitable column. Staging evidence says "as of" that archive boundary and never claims freshness beyond it. L-E implements a re-runnable full replacement of that declared scope: a restore into an isolated legacy schema; explicit column-list copies; validation of every constraint and foreign key; a reset and a verification of sequence state; row-count and checksum verification; and one stated abort or restore point before any destructive target work. The target is write-fenced during the replacement. The rehearsal runs twice from the named archive, proving a clean replacement over rows a previous run migrated, then from a derived later archive with added, changed and deleted rows, proving that the replacement propagates deletions. A migration run requires a recorded freeze witness and refuses a missing, malformed or mismatched one. The witness records the freeze procedure's scheduler stop, drained in-flight work, denied manual triggers and fenced old writer. An archive is immutable input, not proof that a live source was frozen correctly; L-E builds and rehearses the freeze only through declared archive inputs, and "freeze the source, dump" in the agreed shape above is X7's live procedure. L-F runs this rehearsal on staging's unified Postgres only after the owner authorises the transfer of the identified archive to staging. Before that approval, the local build and the archive-based local proof proceed, but no archive data is copied to staging and no staging live acceptance is claimed. The 47 unmeasured inventory rows remain visible in A9's and the go-live acceptance. A9 performs no live freeze, final source capture, scheduler switch, redirect, archive or production activation. X7 performs the final replacement from a newly captured frozen source under the same declared scope, then verifies counts, checksums, constraints, sequences and the source-to-target authority switch. Until that switch the old scraper remains authoritative; there are no dual writes. The third owner decision carried as a recommendation now reads: the proxy password and the model-router key are rotated before A2d's staging proof enables the unified engine to use them.
Amended 2026-09-21 (co-plan, three rounds: L-D, parity). Four measurements at 955d7b0 re-plan the leg. The harness A4 built cannot measure the ported tree: both sides of its pair are replayed from the 45 old migrations, and the ported code names a workspace on every write that those tables do not hold. Its carry rule is keyed on a line and is all or nothing, so one moved call site in any later card would wipe every verdict and ask for a pair that CI never runs. Its shape sweep lets an operator it does not know fall out of a chain without a word, so a read is compared weaker than it is written. And the acceptance's search for a word proves nothing about what the image resolves. So: (1) The harness stays in the spike, where its pair, its replay, its catalog tools and its CI and local halves live, and where the old client stays out of both scraper manifests; its candidate is already the shipped adapter package. What enters services/scraper is the import gate. The pair does not run in CI, which is A4's standing decision; the scraper job's "0 FAIL" becomes a committed result that CI refuses when it is stale: the spike_a4 filter key gains services/scraper/** and db/migrations/unified/**, and the spike's CI half refuses a TBD or a FAIL on any port-* row, a stale run digest, and evidence that does not recompute. (2) The candidate is what ships. The candidate side is built from db/migrations/unified in the runner's own order with the rescale workspace seeded and the application role and grants machine-checked; the reference stays the replay, or the owner's archive in the dump run. The fixture is a deterministic projection: one plan, a candidate-only enrichment that supplies the workspace, and a named response mask. The port's workspace rule is one schema-aware, named transform over payloads, filters, conflict targets, routine arguments, nested payloads and result columns; every removal is recorded and an occurrence the transform does not expect is refused. An unknown method fails the manifest build. (3) Named differences follow the baseline. rls-unreachable-policy retires; rls-denies-the-anonymous-role cites the declared baseline policy; mi_export_count gets a named reference-absence and candidate-presence difference that is EXPLAINED and never PASS; live-grant-denied and the PUBLIC EXECUTE revoke are re-measured through a role-by-routine grant matrix. A port-* row ends PASS or EXPLAINED under a named difference; nothing else closes it. (4) A verdict is carried on evidence, never on a line. row_evidence hashes the row's generated columns except its site and line, plus a normalised fingerprint of the call expression's own source text; case_evidence hashes a serialised case definition with both sides' chains after the transform, the synthesised columns, predicates, values and writes, the masks, the bound, the seed digest and the catalog facts the generator read. The run commits a canonical catalog artefact beside the CSV so CI recomputes both with no pair, and a run digest in the CSV header binds the verdict set to the shipped adapter files, the unified core and meta_ads migrations, the harness, the fixture plan, the replay order, the candidate bootstrap and that artefact. A verdict is kept only while both hashes recompute and the run digest is current. A path case hashes the ordered evidence of its steps; the concurrency cases gate the run and carry to no row. Sweep verdicts are written as shape parity and path verdicts as path parity; nothing is called call-site parity unless a case replays the call site's own arguments. (5) The three operators get cases that branch on the side they build for: the old grammar each site had before the port, frozen as versioned case data with its source commit and file digest, against the adapter method; the inventory's chain vocabulary learns ilikeAny and jsonTextEq. (6) The import gate has three named parts. The lock closure rule is the dependency-resolution proof: neither scraper lockfile holds the old client or its auth, storage or realtime packages anywhere in its closure, development dependencies included, and every install line of the Dockerfile is npm ci or an exact-version install (the process manager's global install is pinned in this leg). The source scan is the pre-build diagnostic, over a file set derived from the Dockerfile's own copy lines and the admin's sources. The built-image scan, over the image and the admin's standalone output, is the runtime evidence, run in the leg's one local image build and recorded; no CI job builds this image, which is an open item that closes before A2d's staging acceptance. (7) Two local runs fill the record: the replayed reference, and the dump run from the owner's archive by its full digest, "as of" that archive; if the archive is absent the record says so and claims nothing in its place. The 47 unmeasured inventory rows stay visible. (8) The boundary with L-E. L-D owns the catalog comparison between the reference and the candidate that is now the baseline, with its allowlist rebuilt from L-A's drift report and the workspace shape. Parity proves behaviour against a projected fixture; it is not proof that a copy list preserves archive values, and the source-to-target column mapping, casts, defaults, sequences and restore validation are L-E's own first step.
Built so far (draft pull request #118; the mirror slice merged separately as PR #129 → main fbb92cd, a merge commit). A9 has not merged. Two legs are committed on the draft. L-A carries the baseline generated from the owner's archive, its declared drift, ADR-022 and the tenant shape: workspace_id NOT NULL on all 44 tables with no default, 23 business keys and five primary keys leading with it, and nine composite references. L-B carries the adapter's own package inside the scraper, the database client as a production dependency of all three manifests, the image rebuild, the 59 reviewed row bounds at their call sites, the ten routines that now name the workspace they write for, and the spike reading the scraper tree from the boundary its inventory was measured at. The mirror graduation was split out of L-B and merged on its own, because a mirror import lands as a merge commit and this card's pull request squashes. It retires the two-root S2 lock, gives the transcriber a lock, a transform and an entry point of its own, adds the one-time bootstrap, and adds a scope check that refuses services/scraper in any lock, mapping, overlay, script or rule file and refuses a read-only exclusion that still covers a graduated root. The two roots could not simply be separated: a rewritten commit's id depends on the whole tree the transform produces, so removing a mapping is a new import and not an edit to a lock. A mapping whose target is null is now read as a declaration that the path is deliberately not imported, so a mirror of one directory of a repository no longer stops on the first path it does not name. The rewrite was run against the real upstream and reproduces the id the new lock records, and the whole procedure was run end to end against a throwaway copy of the repository, where the merge and three refusals were exercised. A latent bug was found by the new refresh-after-a-bootstrap case: a lock with no overlays left the merge-message loop at a failing status and ended the run under errexit with no sentence anywhere, and that line is now a refusal rather than a test. The behavioural test is 122 results from 112, on a fixture it builds itself with no network. The merge could not take the ordinary path, because the web credential may not write a workflow file and this change carries the mirror job; it was made in a clone with every workflow and mirror check run on the merged tree first, and giving that credential the scope is an owner item. Record docs/ops/a9-mirror-graduation-acceptance.md, with ADR-020 amended and the mirror runbook, AGENTS.md and docs/CI.md following. A9's remaining legs are L-C1, L-C2, A9a, A9b, L-D, L-E and L-F, and L-F is the owner's gate.
A9a · The admin's access rules move into the app layer · A · M · S2-W2 · feat/a/a9a-admin-access-rules (surfaced by A4 planning on 2026-09-14, open item #74)
Context. Two database roles carry part of the legacy Intelligence admin's access control today, and the port removes both. Measured at 32cfffc: admin/lib/supabase.ts:7-15 builds the server client with SUPABASE_ANON_KEY; that client is built at 43 call sites in 20 files, which hold 88 .from( / .rpc( sites (9 of them .rpc() and 21 writes (insert, update, upsert, delete), the admin_users login among them (admin/lib/auth.ts:74,87,127,149). The market-intel corpus is out of that role's reach only because migrations/add_market_intel.sql:310-324 REVOKEs the five mi_* tables from anon, authenticated and mi_claim_work from PUBLIC as well, and GRANTs them to service_role; those routes therefore build the service client (admin/lib/supabase-service.ts:20) behind requireAdmin() (:52). A9 replaces both keys with one application role, so the database stops telling admin from non-admin and the app layer becomes the only boundary. What the app layer holds today: admin/middleware.ts verifies the HS256 admin_session cookie, REDIRECTS (302) instead of answering 401 or 403, skips any path that contains a dot (:48), names six admin-only prefixes (:31-34) and exempts /api/integration/v1/jobs(/<id>)? (:25); under admin/app/api there are 51 route files (the S2 facts paragraph's "40 admin API routes" predates the A4a refresh), 25 of which call requireAdmin and 11 of which call checkOrigin; 10 make no identity check of their own and 9 of those export a write method. The Next admin stays until V22 and V15 replace its pages and A19 retires it, so the gap is the whole of S2.
Read first. A4's inventory and ADR-020 (the one app role); admin/middleware.ts; admin/lib/auth.ts (admin_session, HS256 at :43, roles admin | user, bcrypt, and JWT_SECRET falling back to a literal default at :6-7); admin/lib/supabase.ts; admin/lib/supabase-service.ts (createServiceClient :20, requireAdmin :52, checkOrigin :68, clampPaging, escapeLike, and the comment that states why the mi_* rules were database rules); services/scraper/migrations/add_market_intel.sql:296-324; services/scraper/test/origin-check.test.mjs — the shape to avoid: its one case against the real module is skipped when the import fails and the other six run against a copy of the logic kept in the test file (:34-46); A9's rewire list; the permission catalogue (S1a) for the vocabulary the native pages will use.
Deliverables. (1) One access table in code: every route path under admin/app/api and every page path, with the identity it needs (public, signed in, admin), whether it writes, and the database rule it replaces where one existed. The table is the single source and the test reads it, so a route that is not listed fails the run. (2) Every route enforces its own row inside the handler and never by middleware alone: an API path answers 401 or 403 as JSON, a page redirects; a dot in the path changes nothing; the two integration endpoints keep their key check and stay outside the session rules. (3) Every write route checks the origin, not only the 11 that do today. (4) The mi_* routes keep exactly what the REVOKEs enforced — admin only, for read and for write — once the grants no longer separate them. (5) The login path and admin_users behave the same on one role, and a missing or empty JWT_SECRET refuses to start instead of signing with the literal default. (6) A route-by-route authorization test that imports the real handlers, never a copy: for every row, an anonymous caller, a signed-in non-admin and an admin against each exported method, asserting status and that no row was written on a refusal. It runs inside npm run test:unit, so the existing scraper CI job (.github/workflows/ci.yml:288-304) runs it, and it skips no case — a case that cannot import its handler is a failure. (7) docs/ops/a9a-acceptance.md: the table, the counts above measured again after the change, and the rows the database used to enforce.
Interfaces. Exposes the admin API's refusal shape — 401 and 403 with a JSON body where a 302 to /login stood. Consumes A9's lib/db.cjs and the single application role.
Steps. The access table → route-level enforcement → the test that reads the table → the origin rule on every write → the record.
Acceptance. The authorization test green in CI with 0 skipped cases and every route file represented; a path that contains a dot is refused exactly like the same path without one; an anonymous API call answers 401 JSON and a signed-in non-admin mi_* call answers 403 JSON; no route reaches the database before its check; a start with no JWT_SECRET refuses with a sentence; the record.
Out of scope. The native Intelligence pages (V22, V15); retiring the admin (A19); replacing admin_users with Skynet auth; the write guard (A9b); anything in server.js's own API-key surface.
Waits on / Unblocks. A4's gate G1 / A9's grant-removal step — the cutover drops the mi_* REVOKEs and GRANTs only after this card is merged, because the rules then live in the app layer; the card runs beside A9 in the same wave and holds back no other part of the cutover.
Agreed shape (co-plan 2026-09-17). A stacked pull request on A9's adapter-and-admin commit (after L-C2), so the real handlers exist on the adapter when its authorisation suite imports them. A9a OWNS db/migrations/unified/meta_ads/002_*.sql, the step that removes add_market_intel.sql's mi_* REVOKEs and GRANTs: it is never committed on A9's plan, where the runner would apply it before the app layer holds the rule, and A9a's acceptance proves the application role holds every mi_* grant it needs before the old grant model goes. Merge order: A9, then A9a immediately before the cutover.
Amended 2026-09-19 (co-plan). "Immediately before the cutover" means after A9 merges and before A2d's staging proof, never before X7's go-live: the cutover the phrase named is now A2d's staging proof, and the live switch is X7's.
A9b · No unqualified UPDATE or DELETE survives the port · A · S · S2-W2 · test/a/a9b-write-guard (surfaced by A4 planning on 2026-09-14, open item #77)
Context. The scraper's SQL was written against a database that refused an unqualified write. migrations/fix_refresh_brand_metrics_safeupdate.sql:1-6 records the incident of 2026-08-20: every cron refresh of the brands rollup died on "DELETE requires a WHERE clause", the homepage froze at 20 of 22 brands, and manual runs from the SQL editor kept working because they were not API connections. The fix added WHERE true to the two DELETEs (:22, :28). Stock Postgres has no such guard, so after A9 nothing rejects the next one. Measured at 32cfffc: 47 migration files hold 43 CREATE [OR REPLACE] FUNCTION statements over 36 distinct function names, and the two superseded bodies of refresh_brand_metrics still carry the unqualified DELETEs (add_brand_metrics.sql:261,264; add_product_research.sql:522,528), so the replay order decides which body wins. A text search is not the check: the same search over the migrations returns 40 candidates of which 4 are real, the other 36 being BEFORE UPDATE ON trigger definitions, ON CONFLICT DO UPDATE SET, FOR UPDATE and privilege lists; over the scraper's Node code it returns 100 .update( / .delete( sites, most of them digest, Map, Set and URLSearchParams calls. A4's L-3 gives the adapter a run-time guard; this card is the check that runs before anything is deployed.
Read first. migrations/fix_refresh_brand_metrics_safeupdate.sql; migrations/add_brand_metrics.sql:255-270; migrations/add_product_research.sql:515-535; A4's migration-order.json (the replay order and each file's digest) and the L-3 adapter guard; services/scraper/lib/db.cjs as A4 leaves it; services/scraper/package.json (test:unit = node --test 'test/**/*.test.{js,mjs}', 20 test files today) and .github/workflows/ci.yml:288-304.
Deliverables. (1) A check that parses rather than greps: over the meta_ads function bodies in A4's replayed order, it rejects any UPDATE or DELETE statement with no WHERE clause, and it does not report trigger definitions, ON CONFLICT DO UPDATE, FOR UPDATE or privilege lists. WHERE true passes — it is the declared intent, and the sentence says so. (2) The same check over the callers: a chain that starts at the database client and reaches .delete( or .update( with no filter is a finding; a digest, Map, Set or URL call is not a database write and is never reported. (3) The 2026-08-20 body is the regression case, committed as a fixture: the pre-fix refresh_brand_metrics fails the check with its sentence, and the shipped body passes. (4) One sentence per finding, naming the file, the line and the statement, with no value from the statement quoted. (5) The check runs inside npm run test:unit, so the existing scraper job runs it and no new CI job is added. (6) It reads the baseline A9 writes (db/migrations/unified/meta_ads/001_baseline.sql) as well as the mirror's own migration files. (7) docs/ops/a9b-acceptance.md: the functions and call sites scanned, the four findings in the superseded bodies and how the replay order settles them, and the shapes the check must not report.
Acceptance. npm run test:unit green with the check in it; the pre-fix body fails and the shipped body passes; the four unqualified DELETEs in the superseded files are either reported or excluded as superseded with the reason in the record; none of the 36 measured false shapes is reported; the check covers A9's baseline file; the record.
Out of scope. The adapter's run-time guard (A4 L-3); any behaviour change to a function; the admin's access rules (A9a); write guards outside meta_ads.
Waits on / Unblocks. A4's migration-order.json and the lib/db.cjs adapter / A9 — the port cannot quietly lose the guard the old project gave it.
Agreed shape (co-plan 2026-09-17). A stacked change once A9's baseline file exists (after L-A). The check uses a real PostgreSQL parser over the function bodies in replay order and over the baseline, and walks the callers' chains from the database client; WHERE true passes as declared intent; the 2026-08-20 pre-fix body is the committed regression fixture. The catalog it reads distinguishes explicitly global objects from tenant tables, and whereTrue() never authorises a write over a tenant-owned table.
A35 · Comments say why, and the build refuses a card reference · A · L · S2-W2 · chore/a/a35-comments-say-why (surfaced while reviewing V41 on 2026-09-18, pre-existing on main, open item #99; carded 2026-09-19)
Context. The house rule is that a comment explains why, in plain words, and carries no card key, open-item number, phase, slice or wave reference; the acceptance record under docs/ops/ is the one place a key belongs. The repository does not state it. AGENTS.md and the four CLAUDE.md files (CLAUDE.md, apps/api/CLAUDE.md, services/agent-runtime/CLAUDE.md, services/workos/CLAUDE.md) hold only its neighbours: discovered work becomes a card and never a silent TODO (AGENTS.md:39, :51), and acceptance outputs go to docs/ops/<card>-acceptance.md (:41). The rule itself is written only in the builder briefs outside the repository, for example development-processes/skynet/briefs-2026-09-16/v35-brief.md:5: "No card keys, open-item numbers, phase or ticket references in code comments or test names. Comments explain WHY in plain words. The acceptance record under docs/ops/ is the one place card keys belong." (the same line is in the v36, v37, s31, s32 and s33 briefs). Nothing checks it, so review catches it by hand: V34 and V41 each spent a must-fix on a card key in a comment and V42 one on an item number in a comment (their Built-as notes, skynet-task-specs.md:426, :911, :921). Re-measured at main 16dd547; the item measured d1fd3c9, and V41 and V39 have merged since. The item's own shape over its three trees finds 26 files, not 24: its list, plus apps/web/src/README.md (Markdown) and a comment at infra/compose/compose.prod.yml:90 that the list missed. (card V33's is inside that count, not outside it (apps/web/src/lib/useVisibleInterval.ts:3), and two of the files carry the key in output, not in a comment: the report line infra/smoke/pressure_report.py:315 and the echo infra/smoke/proxy-io.sh:430. The wider measure reads comments and docstrings per language in every tracked file outside docs/, Markdown and the read-only mirrors under services/, with the card set's key grammar (a lane letter A, M, S, V or X, one or two digits, an optional letter, with or without 's, never inside a word, a path or an identifier) beside the explicit shapes. It finds 742 comment and docstring lines in 293 files. 76 of them, in 20 files, sit in applied migrations under db/migrations/unified/, whose bytes are pinned: apps/api/migrate.py:14-15 refuses a changed checksum of an applied file (:108, :204-208), so an edited comment there fails the next deploy's migrate step. The other 666 lines in 273 files are what the rule covers when it is read that widely; this card's first pass takes those in the written shapes of a reference (Deliverables), and the record counts both. By shape: a bare key on 578 lines in 232 files (by letter S 206, M 197, V 138, X 52 and A 34 occurrences), card <key> on 46 lines in 46 files, an item number on 20 lines in 16 files, Phase <n> on 26 lines in 17 files, and one wave (W3 at apps/api/app/core/scripts/readlayer_parity.py:1). By directory: apps/api/app 441 lines in 186 files, infra 105 in 34, apps/web/src 61 in 28, .github/workflows 20 in 2 (among them ci.yml:68, :115, :499 and :707), e2e 17 in 7, apps/api/tools 10 in 6, packages/contracts 4 in 4, apps/api/.env.example, apps/api/docker-compose.yml and apps/web/vitest.config.ts 4 in 3, apps/api/scripts 2 in 1, CODEOWNERS 1 and Makefile 1. By origin (git blame --first-parent), 422 lines in 189 files were written here since the import and 244 lines in 98 files came in with it: the imported plan's milestones and steps (M2 S7, M4 S3b, Phase 1 (plan)) and a scenario list X1 to X7 that the repository does not hold. The M and X letters matter: M1 to M6 and X1 to X7 are also cards of this set (M1 the settings move, M3 the mailer, X1 the session policy, X7 the production stack), and the item's pattern cannot see M. Beyond comments: two comment lines in list files (apps/api/ci-known-failures.txt:6, apps/api/requirements.txt:92); eleven Python test names that carry X1 to X7, all imported in 7d053ea (apps/api/app/backend/tests/test_video_storyboard_delivery.py:365,384,403,1052,1073,1087 and test_video_storyboard_queue.py:1606,1807,1827,1904,1942), none of them named in apps/api/ci-known-failures.txt or in ci.yml; explicit shapes in five strings in four files (.github/workflows/ci.yml:165 S1-W2, e2e/production-states.spec.ts:113 open item #64 · card V34, and the two output lines above); and prose strings that name a bare key, which no check can tell from a value: operator messages at apps/api/app/backend/services/model_gateway.py:286, infra/backup/verify.sh:87,109,140,148,154, infra/deploy/preflight.sh:32, infra/migrate/production/dump.sh:41, infra/migrate/production/restore.sh:311 (an SQL comment inside a heredoc), infra/migrate/production/test.sh:229-230, infra/scripts/env.sh:125, infra/import/mirrors/refresh-mirror.sh:102 (asserted by infra/import/mirrors/test-refresh-s2.sh:1117, with the case label at :1169), e2e/production-states.spec.ts:128, the pair infra/secret-scan/extract_fingerprints.py:119,153 and infra/secret-scan/gitleaks.toml:2,62 (the script wrote the file, and it reads credentials from outside the repository), and eleven contract descriptions (packages/contracts/bindings/binding.schema.json:87,156,187, bindings/usage-event.schema.json:124, launch/ad-name.schema.json:5, launch/launch-package.schema.json:5, lineage/schema.json:10,16,120, lineage/fixture-chain.json:2, and e2e/package.json:4). Some docstrings are output, not only comments: 24 scripts pass their module docstring, whole or its first paragraph or line, to argparse as the --help description (git grep -n "description=__doc__": 20 under apps/api, among them apps/api/migrate.py:174, apps/api/app/core/scripts/import_users.py:254, r2_repoint.py:565, readlayer_parity.py:406 and seed_core.py:234, and 4 under infra), infra/smoke/pressure_report.py:373 prints its own on a usage error, one test asserts on a docstring (apps/api/app/backend/tests/contracts/test_launch_package.py:289, "UTC" in run_once.__doc__), and the api tests read source text through inspect.getsource or read_text in 64 places. The same letters are not a task reference on 31 comment lines (paths under apps/api/app/ unless named): object storage S3 on 15 (backend/services/analysis_media.py:8, backend/services/r2_service.py:1, backend/services/storyboard_image_delivery.py:21, pipeline/utils/chatcut_client.py:32,77,138,483,486,519, backend/tests/test_chatcut_client.py:15,17,400,435,449, apps/api/requirements.txt:84), angle labels A1 and A2 on 3 (backend/tests/test_winner_anchors_curated.py:459,460,474), a second voice clone V2 on 2 (backend/tests/test_video_storyboard_voice.py:226,228), the M-Series reference ads M1 to M7 on 2 (pipeline/longform_m_series.py:2,135), and an algorithm's passes Phase 1 and Phase 2 on 9 (backend/services/embedding_service.py:348,367, pipeline/utils/rag_v2.py:401,427, apps/api/scripts/storyboard-db-updates.sql:90,91,102,108,114). A key is data in these places: the records under docs/ (984 occurrences in docs/ops alone); the applied migrations; the mirror overlays, whose bytes the lock pins by sha256 (infra/import/mirrors/s2-mirrors.json, overlays); string values that use the letters as values ("ad_archive_id": "V1" at backend/tests/test_ad_extractor_graphql.py:50, the video track "V1" at backend/tests/test_chatcut_runner.py:443, angle="A2" at backend/tests/test_winner_anchors_curated.py:413, [V4+ Styles] at pipeline/localize_video.py:1621, S1.png at infra/migrate/production/test.sh:88, the rule labels BEROAS V1 at packages/contracts/creative-code/ecomprofits-meta-rules.json:168-193); lowercase names that other files point at (docs/ops/<key>-acceptance.md, infra/import/mirrors/s2-mirrors.json, refresh-s2.sh); and the TechDesk import JSON, which lives outside the repository (development-processes/skynet/final/skynet-techdesk/appendix-b-import.json) where no repository check sees it. "The S2 mirrors" is also the working name of the scraper and transcriber mirrors, whose lock and entry point keep s2 in their file names; where the sweep rewrites such a comment, it names the set by its members. The tracked files outside services/ and docs/ are not only code: lockfiles (pnpm-lock.yaml, package-lock.json), the fingerprint list infra/secret-scan/patterns.txt, the JWT and PEM samples under packages/contracts/auth/samples/, SVG assets, .patch overlays, supervisord.conf files and ignore files, so a checker that silently skips a kind it cannot read would let a new language escape. No test on main asserts on a card key; one asserts on a sentence that names a slice (test-refresh-s2.sh:1117). CI is one workflow of path-filtered jobs, secrets runs always, and the ci aggregate needs every job (.github/workflows/ci.yml:1-6, :857-897); a change to ci.yml runs every job (docs/CI.md:24). A4's branch (8ac6089, over 0a69129), measured with the letters A, S, V and X, adds 63 lines with a reference in a comment, a string or a test title, in 32 files: comments in spikes/intelligence-data-path/** and infra/smoke/{pressure.sh,pressure_report.py,test-pressure.sh} (A4, A9, S1, S2, and open item #75 at spikes/intelligence-data-path/lib/order.cjs:251 and sql/bootstrap/llm_reader.sql:10), explicit references in strings and JSON (open item #74 or #75 in sql/catalog-allowlist.json, sql/filters/add_llm_reporting_access.sql.filter.json, bin/build-migration-order.cjs, tools/classify.cjs and test/parity/differences.cjs; card X3b and card A4 at infra/smoke/pressure_report.py:515,529), and three test titles (test/inventory.test.cjs:332, test/parity-manifest.test.cjs:143, pressure/test/compose-intel.test.cjs:47). It also holds 47 bare keys in strings in 17 files, most of them the parity harness's follow-up sentences that name A9, which it writes into the record docs/spikes/a4-parity.csv and which test/parity-manifest.test.cjs:875 asserts on, and it edits the ci aggregate (ci.yml:939, :946 on that branch).
Read first. AGENTS.md:20-43 (the mirrors rule at :22 and the rules that apply now) and :45-55; CLAUDE.md:1-5; development-processes/skynet/briefs-2026-09-16/v35-brief.md:5 (the rule as the briefs state it); .github/workflows/ci.yml:1-6, :28-152 (the filter keys), :442-445 and :857-897 (secrets and the ci aggregate); docs/CI.md:3-24 (the job table); infra/scripts/image-frontend-gate.py with infra/scripts/test-image-frontend-gate.py (the house shape for a standard-library checker whose own behavioural test runs in CI); apps/api/migrate.py:1-24, :95-112 and :198-210 (the ledger that pins applied migrations); infra/import/mirrors/s2-mirrors.json (the overlays pinned by sha256); infra/secret-scan/extract_fingerprints.py:1-12 and :114-160 with infra/secret-scan/gitleaks.toml:1-3 and :60-63 (the generated comment this card leaves); infra/import/mirrors/refresh-mirror.sh:98-104 with infra/import/mirrors/test-refresh-s2.sh:1110-1120 and :1169, and e2e/production-states.spec.ts:90-135 with e2e/README.md:119 (output this card leaves, and why); apps/api/app/backend/tests/contracts/test_launch_package.py:285-290, the scripts that pass __doc__ to argparse (git grep -n "description=__doc__") and infra/smoke/pressure_report.py:370-374 (the docstrings that are output); apps/api/app/backend/tests/_test_helpers.py:78-95 (what the tests read from app.openapi()); apps/api/app/backend/tests/test_explore_instructions_shipped.py:41-49 and test_intelligence_instructions_shipped.py:52-60 (the prompt files pinned by sha256, all Markdown); apps/api/app/pipeline/expand.py:1-10 with apps/web/src/components/ExpandAuditPanel.tsx:240-270 and :525-570 (page copy this card leaves alone); the Built-as notes of V34, V41 and V42; open item #99. At the cut, on main after A4 merges (line numbers as on A4's branch at 8ac6089, read with git show 8ac6089:<path>): spikes/intelligence-data-path/lib/order.cjs:251 and sql/bootstrap/llm_reader.sql:10 (comments the sweep takes), and test/parity/differences.cjs, tools/classify.cjs, test/parity-manifest.test.cjs:143 and :875, test/inventory.test.cjs:332, sql/catalog-allowlist.json, sql/filters/add_llm_reporting_access.sql.filter.json and infra/smoke/pressure_report.py:505-535 (strings and titles it leaves).
Deliverables. (1) The rule is in the repository. AGENTS.md "Rules that apply now" gains a rule: a comment, and a docstring that serves as one, explains why in plain words and carries no card key, open-item number, phase, slice or wave reference; a comment that needs a record names the record's path under docs/ops/; infra/scripts/task-refs.py refuses those references in their written shapes, with a reviewed list for the places a match is not a reference or is one this first pass leaves. "How to run checks" (AGENTS.md:24-31) names the command. (2) infra/scripts/task-refs.py, standard library only, run as python3 infra/scripts/task-refs.py check .. It reads every tracked file (git ls-files) except what its reviewed list excludes, each exclusion with its reason: docs/**, Markdown anywhere, the four read-only mirrors under services/ (AGENTS.md:22), the mirror overlays the locks pin, and each migration file under db/migrations/unified/ present at the cut, which is applied and pinned by the ledger; a migration added later is read like any other file. It reads comment text only, and only where the text is plainly a comment: it parses no language and never decides what a string, a template literal, a regex or JSX text means. In Python it reads each comment token of the standard tokenize module and each module, class and function docstring that the standard ast module finds. In every other kind it reads a line whose first non-blank text opens a comment (# in shell, YAML, TOML, Dockerfiles, the Makefile, the Caddyfile, CODEOWNERS, ignore files, env examples and list files; ; and # in the supervisord.conf files; // and /* in TypeScript, JavaScript and the tsconfig files, and {/* in TSX and JSX; -- and /* in SQL; /* in CSS; <!-- in HTML), the rest of a block comment opened that way up to its close, and a comment after code on the same line where the marker follows a space and no quote, backtick or slash comes before it. JSON, which has no comments, is read for nothing. In that text it refuses a task reference in its written shapes, a key being a lane letter A, M, S, V or X, one or two digits and an optional letter, never inside a word, a path or an identifier: card <key> or cards <key>; a key that opens a parenthesis ((V27), (M3 S2)); <key>'s; a key that opens a comment as its label, after any rule characters and before a colon, a middle dot or a spaced dash (# M2:, // X1 ·); an item number (item #<n>, with or without open) or a pointer to open-items; Phase <n>; and a wave W<n>, alone or joined to a slice (S1-W2, S1 W1). A path, a lowercase file or job name and an identifier never match. A tracked file of a kind the checker has no reader for fails the check naming the file, unless the list excludes it with a reason (the lockfiles, patterns.txt, the JWT and PEM samples, the SVG and PNG assets and the .patch overlays are such entries), so a new kind of file is a decision and never a silent skip. The checker's own files pass the check without excluding themselves: neither carries a shape in a comment or a docstring, and the test builds every fixture that carries one at run time in a temporary directory, so no fixture is a tracked file. Each finding prints the path and line, the shape, the text and the rule in one sentence, and the run exits 1. (3) The reviewed list infra/scripts/task-refs-allow.json: the exclusions above, and one entry per finding the first pass keeps, naming the path, the matched text, a fragment of its line and the reason, with no line number. It keeps three kinds of finding: a match that is not a task reference (on main these are among the 31 comment lines listed above); a reference in a docstring that is output (the module docstring of a script that passes __doc__ to argparse or prints it, and a docstring a test asserts on), which the first pass leaves because rewriting it changes what a command prints or what a test reads; and the generated comment at infra/secret-scan/gitleaks.toml:2 ((A1b)), whose source is the string at extract_fingerprints.py:119, which the first pass leaves so that the generator and its output stay one pair (the generator reads credentials and is not run). An entry that matches nothing fails the check naming the entry, and an entry without a reason fails, so the list holds only what is there and says why. (4) The sweep: every finding the check makes at the cut that the list does not keep, and every comment line where the independent measure (Acceptance) finds a written shape that the check does not read, rewritten as the reason it records, one by one, never deleted blindly. A parenthetical that only names the card that wrote the code goes (apps/web/src/shell/auth/LoginPage.tsx:16's (card V2b)). A key that stands for a fact becomes the fact (infra/backup/pg-backup.sh:2's A2's backup sidecar, A6's knobs). A pointer to an item or a record says what it points at, and names a record by its path under docs/ops/ (infra/compose/compose.prod.yml:89-90). A label from the imported plan goes (apps/api/app/backend/tests/test_recreate_service.py:102's (M3 S2)). A route docstring that FastAPI copies into /openapi.json is rewritten like any other comment: the tests that read app.openapi() use its paths and operations to find routes, not its text. A test that reads source text through inspect.getsource or read_text keeps passing. No code, no string other than a docstring that serves as a comment, no identifier or test name, no file, job or branch name and no behaviour changes. (5) A4's additions, measured again at the cut with the check itself, are swept by the same rules: the comments in spikes/intelligence-data-path/** and infra/smoke/{pressure.sh,pressure_report.py,test-pressure.sh}, among them open item #75 at lib/order.cjs:251 and sql/bootstrap/llm_reader.sql:10. Its strings, JSON values and three test titles stay for the later card (Out of scope), among them the parity harness's follow-up sentences that name A9, which are written into the record docs/spikes/a4-parity.csv and asserted at test/parity-manifest.test.cjs:875. Where a spike file's bytes are pinned by another file or a test, the pin is regenerated by its own tool. (6) CI: a task-refs job in .github/workflows/ci.yml with no needs: changes and no path filter, which runs the checker's own test and then the check, joins the ci aggregate's needs and loop (ci.yml:883, :890 on main today), and gets its row in docs/CI.md as "always". It runs always, as secrets does, because the set it guards is every tracked file outside the list's exclusions, and a positive filter would miss a new top-level tree: spikes/ arrives with A4, and no filter key on main names it. (7) infra/scripts/test-task-refs.py, the checker's behavioural test on fixture text, as the acceptance states it. (8) docs/ops/a35-acceptance.md: the counts per shape, per directory and per origin at the cut, before and after; the reviewed list with every reason; the proof that behaviour did not change; the check's output on the base sources and on the branch, beside an independent measure of the same tree; and a count on the branch, per directory, of what this first pass leaves for the later card: the bare keys in comment prose by the wider measure of the Context, and the strings, output docstrings and test names it lists.
Interfaces. Exposes python3 infra/scripts/task-refs.py check <root>, the reviewed list infra/scripts/task-refs-allow.json, the task-refs job in the ci aggregate, and the rule in AGENTS.md. Consumes git ls-files and nothing else. Unchanged: the behaviour of every file, every identifier and test name, every string except a docstring that serves as a comment, every docstring that is output, every file, job and branch name, every applied migration byte for byte, the mirrors and their overlays, and everything under docs/.
Acceptance. python3 infra/scripts/task-refs.py check . reports nothing and exits 0 on the branch. On the base sources at the cut it exits 1 and names every finding, and its findings equal, line for line, an independent measure of the same shapes in the same tree that the record carries (a per-language scan written for the record, not the checker, which finds comments by another method, such as the TypeScript compiler's own scanner for TypeScript and JavaScript); every line where the two differ is explained in the record, which is what says the check reads the tree and not a list, and the same measure on the branch finds nothing that the list does not keep. python3 infra/scripts/test-task-refs.py green, proving on fixture text that each written shape (card <key>, a key that opens a parenthesis, <key>'s, a key as a comment's label, an item number, open-items, Phase <n>, and a wave alone and joined to a slice) is refused, with a sentence naming the shape, in each context the check reads (a Python comment, a Python docstring, a TypeScript line comment, a JSDoc block, a TSX {/* */}, a comment after code, a shell comment, a YAML comment, an SQL comment, a supervisord.conf ; comment and a tsconfig comment); that each non-reference and each context the first pass leaves passes (a record path under docs/ops/, refresh-s2.sh, spike-a4, r2_key, S3Client, a lowercase v3, a bare key in running comment prose, every shape inside a Python, TypeScript and shell string, a JSON value, a Python test name, a test title, and a comment marker inside a string, as in 'http://host' and "# not a comment"); that an allowed finding passes, a stale entry fails naming the entry, and an entry without a reason fails; that a file of a kind with no reader fails naming the file; and that a file in a new top-level tree is read and an excluded tree is not. Each refusal case fails against a checker that reads nothing, so the test cannot pass by matching nothing. Behaviour unchanged, proven once and recorded: for every changed Python file the ast.dump of base and branch is equal once docstrings are removed, and no docstring that is output changed; for every changed TypeScript or JavaScript file the token stream without comments is equal; every other changed line is a comment line or a pin regenerated by its own tool (deliverable 5); git diff --name-only <base> -- db/migrations infra/import/mirrors/overlays docs is empty, apart from the record. The pull request edits ci.yml, so every job runs (docs/CI.md:24), and every job the ci aggregate needs is green, api (with its source-reading tests and test_launch_package.py), mirrors (with test-refresh-s2.sh), spike-a4 and the new task-refs among them. The item's own shape, grep -rE "\bcards? [AVSX][0-9]+[a-z]?\b" apps/web/src apps/api/app infra --exclude='*.md', returns only the three output lines this first pass leaves, each named in the record for the later card: the echo at infra/smoke/proxy-io.sh:430 and the two report lines of infra/smoke/pressure_report.py (:515 and :529 at 8ac6089, the first of them :315 on main today). gitleaks detect --no-git --source . --config infra/secret-scan/gitleaks.toml --redact clean. Record docs/ops/a35-acceptance.md; open item #99 closed, and the later card's scope raised as a new open item from the record's measure of what is left.
Out of scope. Everything under docs/ and Markdown anywhere: the records, runbooks and ADRs are where a key belongs, the shipped prompt files are pinned by sha256, and a README's use of keys (apps/web/src/README.md:53,59) is a documentation question. The four read-only mirrors under services/: the card that ends a mirror's read-only status lifts its exclusion and meets the check. Applied migrations, whose comments stay because their bytes are pinned. What this first pass leaves, each for its stated reason. User-facing strings (operator messages, report and echo lines, page copy, contract descriptions, JSON and YAML values, and A4's strings and JSON values): they are output that operators, pages, schemas and records read, some of them asserted (refresh-mirror.sh:102 at test-refresh-s2.sh:1117) or written out again elsewhere (e2e/production-states.spec.ts:128 in e2e/README.md:119), and a bare key in a string is as often a value as a reference. Docstrings that are output: rewriting one changes what a command prints or what a test asserts. The generated comment at gitleaks.toml:2, whose source is a string. Test names (the eleven X1 to X7 Python tests and A4's three titles): a test name is an identifier that a run selects and a failure report names, so renaming one is a code change. Bare keys in running comment prose that no written shape matches (# M1 lifecycle., the S7 wire shape): in prose the same letters are as often a name as a reference (the 31 lines above), so the rule in AGENTS.md covers them and review still reads them. A comment after code where a quote, backtick or slash comes before the marker: the check does not read it, and the sweep clears the ones the independent measure finds at the cut. That cleanup is a later card, scoped once this card's comment rule is in place, from the record's measure of what is left. Lowercase keys in file, job and branch names. Labels outside the grammar, which the check does not read: the imported plan's F0 to F6 where no milestone precedes them (F6 is also the keyboard key at apps/web/src/components/ui/toast.tsx:16), R-10, and section marks such as §8.6. The lane letters R, I, D, B and MC: R2 is the object store's name across the code and would bury the check, those cards run outside this repository, and no source line names a D key. The page copy in apps/web/src/components/ExpandAuditPanel.tsx:247,257,266,530,565 that names an old plan's phases: it is page text, and whether it is still true (it calls the conformance review unshipped while apps/api/app/pipeline/expand.py:7 calls it real) is its own item. A pre-commit hook. The card set, the briefs and the import JSON outside the repository.
Waits on / Unblocks. A4's pull request #83 merged: A4's branch adds references in comments that this check refuses (among the 63 lines in 32 files above) and edits the ci aggregate, so a check landed first would turn #83 red on its next run and collide in the aggregate. V43 is not a dependency: both edit .github/workflows/ci.yml, AGENTS.md and docs/CI.md, so the merge order puts V43 first, and this card is rebased after V43 if V43 merges first. A9, A9a and A9b, if open at the cut, rebase onto this card and meet the check on their own branches. If S37 has not merged at the cut, A35 leaves apps/api/app/pipeline/storyboard_llm.py to S37; if the scopes overlap after that, A35 is rebased after S37 and does not duplicate the removal. / Every later review, which stops spending a must-fix on a written reference in a comment (V34, V41 and V42 each spent one); every later card, which meets the rule in CI instead of in review; each mirror's merge slice, which lifts its exclusion and meets the check; and the later card that takes up what this first pass leaves.
Built as (PR #123 → main 4c9da68, 2026-09-20). AGENTS.md gains the rule that a comment, and a docstring that serves as one, explains why in plain words and carries no card key, item number, phase, slice or wave reference, and that a comment which needs a record names the record's path under docs/ops/. infra/scripts/task-refs.py holds it: standard library only, it reads every tracked file, reads comment text alone, parses no language, and takes Python comments and docstrings from the standard tokenizer and syntax tree and every other kind from the shapes a comment opens in. A tracked file of a kind it has no reader for fails the check by name. The task-refs job runs the check's own test and then the check, with no path filter, so it runs on every pull request from here. Eight written shapes are refused, and a bare key in running prose is left to the rule and to review, because in prose the same letters are as often a name as a reference. At the cut the check found 499 matches on 475 lines in 247 files; the sweep rewrote 476 of them, and the reviewed list keeps 23, each with its reason, beside 37 exclusions with theirs. An independent per-language measure, written for the record and finding comments by another method, agrees with the check line for line, shape for shape and match for match on both trees, which also says that no comment the check cannot read carried a written shape at the cut. Behaviour did not change, and the proof is per kind: the syntax trees are equal once every docstring is removed over 160 Python files, the token streams are equal over 38 TypeScript and JavaScript files taken from the compiler's own parse, and every changed line of the other 35 is a comment line of that file. No applied migration byte and no mirror overlay moved, and no docstring a command prints changed. Two pins over swept files were missed on the first pass and both were regenerated by the tools that own them, a generator digest the parity header carries and a second digest that no CI run reads back. The check's own test is 234 cases, and every refusal case is re-run against a checker that reads nothing, so no case can pass by matching nothing. Record docs/ops/a35-acceptance.md; open item #99 closed. Three findings filed: open item #122, two blocks marked as temporary debugging that were to go after a confirming run that never came; open item #123, everything the first pass leaves, with the decision each kind needs and the lexical reader a later card needs to read a trailing comment safely; and open item #126, the pin that no CI run reads back.
A36 · The e2e job builds its two images from the cache the deploy already warms · A · M · S2-W0 · chore/a/a36-e2e-image-cache (surfaced while measuring the CI wall on 2026-09-20, open item #117; carded 2026-09-20)
Context. The e2e job is the pipeline's long pole in 22 of the last 30 ci.yml runs, at a median of 636 s over the 22 runs where it executed, p90 665 s, max 685 s (run 35472744744), against a run wall of 656 s at p50 and 696 s at p90 over the 21 successful runs. Its six steps, medians over those 22 runs: the env file 157 s, the SPA build 32 s, the stack bring-up 188 s, the health wait 4 s, the runner and browser install 23 s, the browser suites 224 s. Two image builds hold 316 s of that. Read inside one run, 35473356283 (e2e 637 s, job 105978256318): infra/scripts/env.sh dev spends 154 s building apps/api/Dockerfile with no cache at all, and its layers are the ones a cache exists for, apt with supervisor and ffmpeg 48.3 s, the torch CPU wheels 33.1 s, requirements.txt 27.1 s, the easyocr warm-up 6.1 s, the demucs warm-up 2.6 s, the frontend stage's npm ci 27.7 s and its npm run build 33.2 s, and the export and naming 30.2 s; and the bring-up's 188 s is 21 s of postgres pull, migrate and bridge stub, then 162 s building infra/caddy/Dockerfile, of which the xcaddy build layer alone is 148.9 s (infra/caddy/Dockerfile:15-17) and that image's own frontend stage is a further npm ci of 21.2 s and npm run build of 35.9 s, then 4 s to start the containers and seed. The api image is NOT rebuilt at bring-up, and the note that said it was is wrong at this head: env.sh:65-66 tags its build skynet-dev-api and skynet-dev-migrate, which are exactly the compose project's own default image names for the api and migrate services of project skynet-dev (infra/compose/docker-compose.dev.yml:6, :30-33, :68-71), and bridge-stub names skynet-dev-api outright (:50), so the only image compose builds at bring-up is skynet-dev-caddy, which the log confirms is the single naming to line in that step. What is genuinely built three times is the SPA: once in the api image's frontend stage (apps/api/Dockerfile:5-10, 61 s), once on the host for the bind mount (ci.yml:764-768, 34 s, of which npm ci is 16 s and tsc -b 16 s), and once in the caddy image's frontend stage (infra/caddy/Dockerfile:5-10, 57 s), and only the host copy reaches a browser, because the compose file mounts apps/web/dist over /srv/web (docker-compose.dev.yml:120). The precedent for the fix is already in the tree and already measured. The gateway job builds the same caddy Dockerfile from the same context through docker/build-push-action@v6 with cache-from and cache-to: type=gha, scope=skynet-web-ci (ci.yml:389-399), and its build step runs 24 s at p50 over 13 runs, 5 s at its fastest and 303 s on the one cold scope (run 35458689492). deploy.yml builds the api image with cache-from and cache-to: type=gha, scope=skynet-api (deploy.yml:83-84) and the web image reading both skynet-web and skynet-web-ci (deploy.yml:123-127); it runs on every push to main with BUILD_IMAGES_ENABLED true, so both scopes are rewritten at every merge, and over the last 10 deploy runs its api build step is 56 s at p50, 12 s at its fastest (run 35475072310) and 262 s at its slowest (run 35458689452), and its web build step 47 s at p50. A pull request can restore the default branch's scope, so those entries are readable from every e2e run. What a read-only cache-from gives and what it does not: it reuses the layers whose cache keys the branch leaves unchanged, which for a change under apps/api/app/** is everything up to the late copies at apps/api/Dockerfile:113, but it preserves nothing the pull request itself builds, so a layer the branch changes is rebuilt on every push to that branch and after every rebase, and a branch that changes requirements.txt or apps/web/package-lock.json pays for the layers below it each time. The three scopes are written elsewhere and stay that way under this card: the deploy writes skynet-api and skynet-web at mode=max (deploy.yml:83-84, :123-127) and the gateway job writes skynet-web-ci (ci.yml:389-399). One budget rules out a second write scope: the repository's Actions cache holds 7.31 GB in 510 entries, 6.82 GB of it 382 buildkit blobs, measured 2026-09-20 through the repository cache API, against a 10 GB limit past which GitHub evicts least-recently-used entries, so a second mode=max copy of a torch-carrying image would evict the scopes the deploy itself depends on. The SPA path has a free cache too: the e2e job's actions/setup-node step sets no cache key (ci.yml:755-757) while the web job's sets cache: npm against apps/web/package-lock.json (ci.yml:262-267), where npm ci costs 8 s at p50 against the e2e job's 16 s, and reading that same key adds no new cache entry. What this cannot move: the browser suites at 224 s, and infra, which is second in 6 of the 22 pole runs at 460 s median and whose pressure.sh step alone is 257 s at p50, 76 s at its fastest and 331 s at its slowest (run 35473356283) although its test stubs sleep to a no-op (infra/smoke/test-pressure.sh:284).
Read first. .github/workflows/ci.yml whole file, and inside it the e2e job (:743-860, the env step :758-759, the SPA step :764-768, the bring-up :773-780, the health wait :781-800, the runner install :801-805), the gateway job (:382-409, the cached build at :390-399), the infra job (:418-513, pressure.sh at :510-511), the web job (:253-316, the npm cache at :262-267), the image-frontend job (:873-892), the e2e and gateway and image_frontend and infra path filters (:77-92, :110-115, :146-153, :127-139) and the ci aggregate (:962-978). infra/scripts/env.sh (whole file, the image guard and its build at :56-71). infra/compose/docker-compose.dev.yml (whole file, the project name :6, migrate :30-42, bridge-stub :44-66, api :68-103, caddy :105-130, the dist mount :120). apps/api/Dockerfile (the frontend stage :5-10, the heavy layers :42, :74, :76, :94, :109, the late copies :113-143). infra/caddy/Dockerfile (whole file). .github/workflows/deploy.yml:46-127 (both builds and their cache scopes). e2e/playwright.config.ts and e2e/README.md from ## Two suites, two servers (:164) to the end. docs/CI.md rows for e2e, gateway and image-frontend (:16, :19, :20) and the section ### The images' SPA stage (image-frontend) (:43-55), whose sentence at :45 about what e2e builds is the one this card makes true again. infra/scripts/image-frontend-gate.py (whole file, the one-build rule :117-167, the image_frontend filter rule :230-242, the aggregate rule :244-253, the infra filter rule :254-262) and infra/scripts/test-image-frontend-gate.py (whole file, the shape-matched line rewrites :43-45, the filter-entry scope :57-61, the literal edits :85, :91, :94, and the loud failure when a mutation no longer matches :182-196). Open items #117 and #109.
Deliverables. (1) The e2e job gets docker/setup-buildx-action@v3 and one step that builds apps/api/Dockerfile from the repository root before env.sh runs, through docker/build-push-action@v6, with load: true, both tags skynet-dev-api and skynet-dev-migrate, cache-from: type=gha, scope=skynet-api, and no cache-to. Both tags are required, and the card proves why: without skynet-dev-migrate the bring-up's compose run --rm migrate rebuilds the whole image. The image is still built from the pull request's own tree and is never pulled from a registry, because the job exists to prove the pull request's runtime; a cache replaces unchanged layers and nothing else, and the record states that. (2) infra/scripts/env.sh is not changed. Its guard at :60 is docker image inspect skynet-dev-api, so a pre-tagged image makes it skip straight to the fill container, and its own build stays for a workstation that has no image. The card proves that path rather than assuming it, by showing the step's log line for the fill and the absence of building the api image (first run)…. (3) A second step builds infra/caddy/Dockerfile from the repository root the same way, tagged skynet-dev-caddy, with cache-from reading type=gha, scope=skynet-web-ci and type=gha, scope=skynet-web, and no cache-to. After it the bring-up step builds no image at all. (4) No new cache scope is written by any of this, and both the gain and its limit are stated in the workflow comment and in the record: reading skynet-api, skynet-web-ci and skynet-web reuses the layers whose keys the branch leaves unchanged, and it preserves nothing the pull request builds, so a layer the branch changes is rebuilt on every push to that branch and after every rebase. The reason not to write one is the budget: the repository cache is at 7.31 GB of 10 GB, and a second mode=max copy of the api image would evict the deploy's own scopes. If the acceptance runs show that the rebuilt layers cost more than the card claims, the follow-on is one mode=min scope for the CI side with the new repository cache total recorded beside it; this card never adds a mode=max one. (5) The e2e job's actions/setup-node step gets cache: npm and cache-dependency-path: apps/web/package-lock.json, the same two keys the web job carries, so the job restores a key the web job already writes and adds no entry. The e2e/ install at :804 is left alone: it is 5 packages and 0.8 s. (6) The edit respects every check that reads ci.yml, and the card states each rule it kept. Three strings must each still occur exactly once in the file, -f "$DOCKERFILE" ., --target frontend-build -f "$DOCKERFILE" and docker build --progress=plain, or the image gate's test reports that its mutation no longer matches. The image-frontend job must still hold exactly one line that starts with docker build or docker buildx build, carrying only --progress, --platform, --target and -f, each once, and one context; the new steps use the action and not a run: line, so they are outside that rule, and the card proves it by running the gate. The aggregate's needs: [...] and its for job in …; do must each stay on one line in their present shape, and a path filter's name stays at twelve spaces with its entries and its in-block comments at fourteen, written - 'pattern', or the filter mutations lose their scope. The e2e filter already holds apps/api/**, apps/web/**, infra/caddy/** and the root .dockerignore (:77-92), so no filter key changes. (7) The measurement, broken out so that the cost of load: true is a measured number and not folded into a build. The branch does work the base does not, a Buildx setup step and two build-and-load steps, so a table of the base's six steps cannot separate it, and the record holds instead one row per named phase across three columns, the base run, the branch's first push whose cache for the changed layers is cold, and a later push whose cache is warm, each column headed by its own run id. The phases are: checkout and setup-node; Buildx setup, which is absent on the base and whose base cell says so; the api image build and load; the caddy image build and load; env.sh, which on the branch is the fill container alone; the host SPA build; the stack bring-up; the health wait; the runner and browser install; the browser suites; and the job total. Inside each of the two build phases the record splits the build from the load, reading BuildKit's own export and load lines out of that step's log, and states each separately with the number of layers that were cached and the number that were rebuilt, so a reader can see what the docker-container driver's export and import cost on an image this size. A second table gives every job's duration in the base run and in both branch runs, so the record shows the pole rather than asserting it. The card then says by how much the run wall falls in each case the sample holds, rather than one number: the ceiling on any saving is the gap to the second-longest job of that run, which is 290 s at p50 over the 22 pole runs, 342 s at p50 on the 5 runs where api is second, 295 s on the 10 where web is second, and only 172 s at p50 and 29 s at its narrowest on the 6 where infra is second. (8) docs/CI.md: the e2e row says the job builds both images once each from the deploy's cache scopes and that the bring-up builds nothing, and the sentence at :45 that describes what e2e used to build is corrected. A comment at the head of the two new steps says which scope each reads, why neither writes, and that the image is the pull request's own; it names no card and no item.
Interfaces. Exposes the two tags skynet-dev-api plus skynet-dev-migrate and skynet-dev-caddy as the interface the dev compose file resolves its four services through, so any later card that adds a service to docker-compose.dev.yml with a build: block must either tag its image in the job or accept a build at bring-up. Consumes the deploy's skynet-api and skynet-web cache scopes and the gateway job's skynet-web-ci scope, all three of which exist and are refreshed at every merge to main, and consumes env.sh's existing image guard without changing it. Unchanged: every spec the browser suites run, the health wait, the magic-token mint, the image-frontend job and its gate, the gateway job, and the dev stack's shape.
Acceptance. The e2e job green on the pull request, with the same specs passing as on the base run, and the phase tables of deliverable (7) filled for all three runs, every cell carrying its run id and the two build phases each split into build and load. Two numbers decide whether the card merges at all, and they are stated before the run rather than read off it afterwards: the api phase, build plus load together, must come in below the 154 s that the uncached build costs today (run 35473356283), and the caddy phase, build plus load together, below that job's 162 s. If either fails, the export and import cost of the docker-container driver has eaten the cache's gain on an image that size, and the card is reshaped and re-reviewed rather than merged; the record names the alternative it would then take, which is to give the runner a containerd image store so that a loaded build needs no tar round trip, and records the measured load figure that forced it. The bring-up step's log carries no naming to line, which is the proof that compose built nothing. The api image the stack runs is the one the new step built: the image id the build step reports equals the id docker compose -p skynet-dev images reports for api, migrate and bridge-stub, and the caddy id equals the one the second step built, all four printed into the log. env.sh's log shows the fill container and not its own build. Two branch runs are recorded and both pass, the first push, whose cache for the changed layers is cold, and a later push whose cache is warm, because a reader who sees only the warm number will be surprised by the cold one, and because the read-only cache preserves nothing either push builds. The repository's Actions cache total is measured before and after through the cache API and both numbers are in the record; if the total has risen, the card says by how much and against which scope. actionlint -shellcheck=$(command -v shellcheck) .github/workflows/ci.yml .github/workflows/deploy.yml clean, python3 infra/scripts/test-image-frontend-gate.py green with every case still applying its mutation, and python3 infra/scripts/image-frontend-gate.py check . green, all three run locally with the pinned yq before the push and again in the infra job. python3 infra/scripts/test-compose-limits.py and the compose config step still green. The whole ci aggregate green. No comment the card adds names a card, an item or a phase. Record docs/ops/a36-acceptance.md, which states the pole after the change and files as its own open item whatever remains the longest step, with its measured number; open item #117 closed.
Out of scope. pressure.sh, which is 257 s at p50 of the infra job's 425 s and becomes the pole on an infra change; its 76 s to 331 s spread with sleep stubbed is unexplained and gets its own open item from this card's record rather than a guess here. The web job's three type-check passes, which are open item #109. Removing either image's frontend stage, which would change what the images ship and is the image-frontend gate's territory, not a cache card's. Caching the Playwright browser download: the step is 23 s, its apt half is 12 s and not cacheable, and the 10 s of downloads do not pay for a new cache entry against the 7.31 GB total. Splitting the browser suites across workers, which e2e/playwright.config.ts:13-19 forbids on purpose because every leg logs in against a per-address rate limit. Any change to deploy.yml's own cache scopes. Any change to the number of jobs or to a path filter key.
Waits on / Unblocks. Nothing: both cache scopes and the gateway job's pattern are on main at 92a360f, and no open card edits the e2e job. It shares ci.yml with the per-job timeout-minutes card, so whichever of the two merges second is rebased on the first and re-run under rule 1, and if this card merges first the timeout card derives e2e's number from the new maximum, while if it merges second this card's record re-derives that one number and the branch carries the change / every later card whose diff touches apps/api/**, apps/web/**, db/** or e2e/**, which is most of them, and the carding of whatever step the record names as the new longest.
Built as (PR #116 → main b005937, 2026-09-20). The e2e job builds both images itself, from the cache the deploy already warms, before the bring-up runs. Two build steps read the scopes the deploy and the gateway job write, and neither writes one: the repository's Actions cache already holds 7.31 GB of a 10 GB budget, and a second full copy of an image that carries the model wheels would evict the scopes the deploy itself depends on. Both steps name the platform rather than taking the runner's own, because a cache key carries the platform it was built for. The two tags on the api build are exactly what compose resolves for four of its five services, read out of the compose file itself, and without the second tag the bring-up's migrate run finds no image and rebuilds the whole thing. The environment script is deliberately unchanged: its own build is guarded on the image already existing, so it skips to the fill container, and the step's log is the proof. A rebuild at bring-up would be silent, so a step after it compares the two build steps' image ids against the three tags and the three running containers and fails unless all six match. Two thresholds were stated before the runs and both are met on both runs: the api phase, build plus load together, is 94 s cold and 85 s warm against the 154 s the uncached build costs on the base, and the gateway phase is 6 s on each against that job's 162 s. The job falls from 637 s to 409 s and 436 s, and the run wall from 692 s to 546 s and 509 s. The record is honest about what the two branch runs do not show: both read the same scopes and produced byte-identical images, so the difference between the two columns is runner variation and not cache state, and a branch that changes an image input rebuilds from that layer down on every push. The cache total did not rise across either run, and the only two entries added are the gateway job's own, each written before that run's first build step started. Record docs/ops/a36-acceptance.md; open item #117 closed. The record names what is left: the infra job is the new pole, its pressure step alone is 257 s at p50 and spreads from 76 s to 331 s with nothing explaining the spread, and the browser suites inside e2e are not carded, because the configuration forbids parallel workers on purpose.
A37 · Every job says how long it may run, and a check holds it to that · A · S · S2-W0 · chore/a/a37-job-timeouts (surfaced while measuring the CI wall on 2026-09-20, open item #118; carded 2026-09-20)
Context. No job in either workflow sets timeout-minutes: grep -rn 'timeout-minutes' .github/ over 92a360f returns nothing, across the 16 jobs of .github/workflows/ci.yml and the 7 of .github/workflows/deploy.yml. GitHub's default is then 360 minutes per job, so a job that hangs rather than fails holds a runner for six hours and holds the required ci check pending for the same six hours. The aggregate does not bound it: ci (ci.yml:962-978) needs every job, and timeout-minutes counts from the moment a runner picks a job up, so the aggregate's own limit bounds only the aggregate, and there is no workflow-level limit to set instead. The risk is not theoretical for this pipeline. Four of its jobs wait on something they do not control: e2e loops 90 times on curl against the gateway (:781-800) and 30 times on compose logs for the sign-in line (:818-838), and either loop ends, but the docker compose up, compose run and npm test around them do not; infra pulls a published image and runs drills against live Postgres containers (:485-503); deploy-staging and deploy-prod hold an SSH session to a box (deploy.yml:168, :342); and the session already misread a healthy run as a two-hour hang once, on 2026-09-19, and cancelled it at nine minutes. It also has a merge rule that makes a false red expensive: a red run on main freezes every merge until its cause is fixed, so a timeout set too low costs the whole train, while a timeout set generously costs only runner minutes on a genuine hang. The numbers, measured over the last 30 ci.yml runs and the last 10 deploy.yml runs, counting only jobs that executed and were not cancelled, from each job's own start and completion timestamps. In ci.yml, by maximum with its run id: e2e 685 s (35472744744, p50 636, p90 665, n=22), infra 516 s (35462701721, p50 425, p90 503, n=13), web 373 s (35471866195, p50 344, n=21), gateway 335 s (35458689492, p50 51, p90 264, n=13), api 318 s (35458092375, p50 275, n=19), scraper 205 s (35473356283, n=13), mirrors 143 s (35466701174, n=13), contracts-consumers 93 s (35458092375, n=19), runtime 79 s (35466772156, n=13), workos 63 s (35458689492, n=13), image-frontend 62 s (35467308764, n=42 across two legs), spike-a4 61 s (35472744744, n=11), ci 39 s (35471954337, p50 4, n=30), contracts 29 s (35470865282, n=24), secrets 25 s (35474944532, n=29), changes 14 s (35475072295, n=29). In deploy.yml: build-api 286 s (35458689452, p50 81, n=10), build-web 105 s (35373768252, p50 75, n=10), deploy-staging 62 s (35458689452, n=10), verify-tags 27 s (35354274012, n=10), smoke-staging 17 s (35473356286, n=10), notify 4 s (35473356286, n=10), and deploy-prod with no sample at all, because DEPLOY_PROD_ENABLED is not set (deploy.yml:345) and the job has never run. Three of those deploy jobs already declare a budget of their own, and a limit derived from observed durations alone would kill a run the workflow itself permits. Each holds an appleboy/ssh-action@v1.0.0 step with an explicit command_timeout: deploy-staging 30m (deploy.yml:192, :203), smoke-staging 15m (:327, :335), and deploy-prod 30m (:366, :377). A remote script is allowed to spend that whole budget, so deploy-staging's honest ceiling is 30 minutes and not the 62 s it has happened to take, and a job limit of 8 minutes would cancel a legitimately slow deploy at the worst moment, with the box half changed. Nothing in ci.yml declares a budget of this kind: grep -rn 'command_timeout\|timeout:' .github/ returns only those three lines, so the observed-duration term alone governs all 16 ci.yml jobs. The spread between a warm run and a cold one is the thing a margin has to survive, and the sample holds three measured cases of it: build-api ran 41 s and 286 s (runs 35475072310 and 35458689452), a factor of 7.0; gateway ran 38 s and 335 s (runs 35457733720 and 35458689492), a factor of 8.8, because its image build is 5 s on a warm scope and 303 s on a cold one; infra ran 275 s and 516 s (runs 35458689492 and 35462701721), a factor of 1.9, because pressure.sh alone moves between 76 s and 331 s although its test stubs sleep to a no-op (infra/smoke/test-pressure.sh:284). The suite also has declared room to grow: the handoff's rule 4 puts about 360 s of headroom in the api job and about 280 s in the web job before a run gets any longer, so a margin that a growing suite would immediately breach is no margin.
Read first. .github/workflows/ci.yml whole file, and in particular every jobs.<id>: header and the ci aggregate (:962-978), the e2e job's two wait loops (:781-800, :818-838) and the infra job's container drills (:485-503). .github/workflows/deploy.yml whole file, and in particular its 7 job headers (:46, :86, :129, :168, :320, :342, :513) and the three gate variables its if: lines read (:3-6, :48, :88, :173, :345). infra/scripts/image-frontend-gate.py whole file, as the house pattern this check copies: standard library only, both workflows read through the pinned yq (:36-42), one line per finding and exit 1 on any (:185-265), the self-reference that makes the infra filter hold the check and its own test (:31, :254-262). infra/scripts/test-image-frontend-gate.py whole file, as the house pattern the new test copies: throwaway copies of what the check reads (:155-167), one case per sentence, a mutation that no longer matches its tree failing its case loudly rather than passing for the wrong reason (:182-196), and the line rewrites written against a line's shape rather than its exact text so that a new job or filter entry does not disarm them (:15-20, :43-54, :57-61). .github/workflows/ci.yml:127-139 (the infra path filter the two new files must join) and docs/CI.md:17 (the infra row) and :49 (the sentence that lists what the image gate's infra claim covers). Open item #118.
Deliverables. (1) One margin rule with two terms, written once in docs/CI.md and in the check's own docstring, and applied to every job: a job's timeout-minutes is the smallest whole number of minutes not below the larger of (a) twice the job's measured maximum plus five minutes, and (b) the sum of every timeout budget the job's own steps declare, plus the job's measured maximum, plus five minutes. Term (b) exists because a step that carries its own timeout is permitted to run to it, and a job limit below that permission cancels a run the workflow itself allows; the job's measured maximum stands for the rest of the job, which still has to run and to tear down around that step, and the five minutes covers checkout and the action's own connect and teardown. Term (b) governs three jobs today and no others, and the record names them with their declared budgets. The doubling in term (a) is not read off any single measurement: it says a job may take twice its worst observed run, and then five minutes more, before anything cancels it. The warm-to-cold spreads are separate evidence that the shape is sufficient rather than the source of the factor, and the record validates the rule against all three of them by deriving each job's limit from the warm half of its own sample alone and checking that the measured cold run still fits. That validation also shows which term is doing the work where: gateway's spread is 8.8, far past a doubling, and it fits only because the five-minute term dominates a job whose warm maximum is 38 s, while infra's spread of 1.9 fits on the doubling alone. Taking only the warm half of each spread as the sample, the rule gives build-api 7 minutes against a cold run of 286 s, gateway 7 minutes against a cold run of 335 s, and infra 15 minutes against a slow run of 516 s, so each measured cold run still passes a limit derived from warm data alone. The five-minute term covers a short job whose whole cost is setup, where a slow archive or a slow release download is a larger multiple than any of those three. The rule is also checked against the declared growth headroom rather than assumed to cover it: api at 16 minutes holds its 318 s maximum plus rule 4's 360 s, and web at 18 minutes holds its 373 s plus 280 s. (2) ci.yml gets a job-level timeout-minutes on all 16 jobs, derived from the maxima above: changes 6, contracts 6, contracts-consumers 9, api 16, web 18, scraper 12, runtime 8, workos 8, mirrors 10, gateway 17, infra 23, e2e 28, image-frontend 8, spike-a4 8, secrets 6, ci 7. On a matrix job the limit applies to each leg, which is what image-frontend's 8 means. (3) deploy.yml gets one on all 7: build-api 15, build-web 9, verify-tags 6, notify 6 from term (a), and deploy-staging 37, smoke-staging 21 and deploy-prod 40 from term (b), which are the three jobs whose SSH step declares its own budget. The card chooses to set the job limits above the declared budgets rather than to lower the budgets, and says why: lowering the production deploy's 30 minutes needs evidence from production deploy runs, and deploy-prod has never run, so there is no evidence to lower it with; a deploy cancelled mid-script leaves a box half changed, which is a worse failure than a runner held for forty minutes; and tuning a deploy's remote budget is a deploy card's work, not a timeout card's. deploy-prod is also the one job no duration measurement supports, so term (b) takes the highest sampled maximum in that workflow, build-api's 286 s, as the stand-in for the rest of the job, because nothing measured says it is shorter; the record marks the number provisional, says it is re-derived once the job has run three times, and files that as its own open item. (4) infra/scripts/workflow-timeouts.py check [<root>], standard library only, both workflows read through yq the way the image gate reads them, one line per finding and exit 1 on any. It holds five things. Every job of both workflows declares a job-level timeout-minutes, and a job that does not is named with the sentence that says it would run to the six-hour default. The value is a literal integer, never an expression, because an expression can evaluate to anything and a limit that is not readable is not a limit. The value is at least 1 and at most a ceiling named beside the rule with its reason, which is 45 today because the largest number the rule gives is 40, so a limit that is really the default written out longhand is refused and moving the ceiling is a deliberate edit. No step declares a timeout-minutes above its job's, because a step limit above the job's is dead text. And, the rule that would have caught this card's own first draft, the check derives each job's number from both terms itself and requires the declared timeout-minutes to equal it exactly, never merely to clear it. Equality is the point: a limit one minute under the derived number is a rule nobody applied, and a limit above it is a number nobody can re-derive, and both are refused with the derived value named in the finding. To compute term (b) the check reads every timeout budget a job's steps declare, counting both a step-level timeout-minutes and an action input that is a duration budget, which today means with.command_timeout on the SSH action; it names each such input in a table beside the code with the reason it is there, reads a duration written with an s, m or h suffix or as bare seconds, and refuses one whose unit it cannot read rather than ignoring it, because a budget silently read as zero is the failure this rule exists to stop. To compute the measured-runtime part of both terms it reads a committed floor file, infra/scripts/workflow-timeouts.json, holding one entry per job with that job's measured maximum in seconds and the run id it was read from. The floor file is itself checked, or it becomes the place the rule rots: every job of both workflows has exactly one entry, no entry names a job neither workflow has, each maximum is a positive integer and each run id is present and non-empty, and a job with no sample carries the name of the job it borrowed its stand-in from, whose entry must exist, must not itself be a stand-in, and whose maximum and run id the borrowing entry must repeat exactly. Naming a source is not enough on its own: an entry that named build-api and carried any number it liked would be a free hand dressed as a derivation, so the check compares both fields and its finding names the borrowing job, the source job and the two values that differ. That is what holds deploy-prod, the one job of either workflow that has never run while carrying a real 30-minute allowance (deploy.yml:342-377). Deriving rather than comparing is what closes the two holes a clearing rule leaves open: a declared 36 where the rule gives 37 fails, and raising a step's command_timeout from 30m to 45m changes the derived number so the unchanged 37 fails, instead of the runtime term silently dropping out. The infra job's step prints the whole derivation, one line per job with both terms, the governing term and the derived value, so the log is the table and nobody re-types it. The check makes the same self-reference the image gate makes: the infra job's path filter must hold this check and its own test, or a change to either does not run it. (5) infra/scripts/test-workflow-timeouts.py, in the shape of the image gate's test: throwaway copies of both workflows in a temporary directory, the copies as they are passing, and one broken copy per sentence, each exiting 1 with every FAIL line matching that case's pattern. Its cases are a job with the key deleted; a value written as an expression; a value of 0; a value above the ceiling; a value that is not an integer; a step limit above its job's; a job whose limit is below its step's declared command_timeout; a job whose limit is above that budget but inside the five-minute overhead term; a job whose limit is one minute under the derived number, which is the case that separates deriving from clearing; a job whose limit is above the derived number; a command_timeout raised so that the derived number moves while the declared limit does not; a command_timeout written in a unit the check cannot read, which must be refused and not skipped; a second step with its own budget in the same job, so that the rule is proven to sum them rather than take the largest; a floor file missing a job's entry, naming a job neither workflow has, carrying a maximum that is not a positive integer, or carrying an entry with no run id; a stand-in entry that names a source job but carries a maximum differing from it, and one that carries a differing run id, each of which must fail naming the borrowing job, the source and both values; a stand-in naming a source that has no entry, and one naming a source that is itself a stand-in; and the infra filter with this check's own entry removed. Every edit that touches a list or one entry of a named filter is written against the shape of the line and not its exact text, as :43-61 of the existing test are, so that adding a job or a filter entry does not silently disarm a case, and a mutation that no longer matches fails its case loudly. (6) The infra job runs both, a behaviour step then a check step, in the order the image gate's two steps take at ci.yml:456-459, and the infra path filter gains the two new files as entries at fourteen spaces in single quotes, below - 'apps/api/Dockerfile' so that the existing filter mutations keep their scope. Adding them is not enough on its own: infra/** already matches both, and the entries exist so the claim the check makes about its own inputs is readable next to the image gate's. (7) The edit respects every check that reads ci.yml, and the card names each rule it kept: the three strings -f "$DOCKERFILE" ., --target frontend-build -f "$DOCKERFILE" and docker build --progress=plain each still occur exactly once; the aggregate's needs: [...] and its for job in …; do each stay on one line in their present shape; a filter name stays at twelve spaces with its entries and in-block comments at fourteen. (8) docs/CI.md: both terms of the rule, the floor file and what keeps it honest, the ceiling with its reason, the table of all 23 numbers with each job's measured maximum, its run id, any budget its steps declare and which term governed it, and the sentence that a card which changes a job's shape, or which changes a declared step budget, re-derives that job's number and records the new maximum. A comment above the infra job's two new steps says what the check holds; it names no card and no item.
Interfaces. Exposes workflow-timeouts.py and its rule as the place every later workflow job declares its limit, so a card that adds a job to either workflow fails the infra job until it derives and writes one. Consumes the infra job, its yq pin (ci.yml:428, :431-436) and its path filter, all of which exist. Unchanged: every job's steps, every path filter key, the aggregate's needs and result loop, and the image gate and its test, which read the same two workflows and must both stay green beside the new check.
Acceptance. python3 infra/scripts/test-workflow-timeouts.py green, with the unbroken copies passing and each of the nineteen broken copies exiting 1 with every FAIL line matching that case's sentence, and each case proven to fail on the base sources for its own reason: on the base every job is missing the key, so the deleted-key case and the ceiling, integer, zero, expression and declared-budget cases have nothing to edit and fail loudly as unapplied mutations rather than passing, which is the proof that the cases read the tree and not a list. python3 infra/scripts/workflow-timeouts.py check . green on the branch and exiting 1 on the base with 23 findings, one per job, and that base transcript is in the record. Removing any one of the 23 timeout-minutes lines on the branch fails the check and names that job; the transcript of one such removal is in the record. Four mutations on deploy-staging, whose derived number is 37, each failing the check and each transcript in the record, because together they are the only proof that term (b) is live and derived rather than arithmetic somebody did once: the limit lowered to 36, one minute under the derived number, which a rule that only proved the job clears its summed budgets would have passed; the limit lowered to 35; the limit raised to 38; and the step's command_timeout raised from 30m to 45m with the limit left at 37, which must fail because the derived number moved. Three more on the floor file: deploy-staging's entry removed; its maximum edited, which must change the derived number and so fail the unchanged 37; and deploy-prod's stand-in left naming build-api while its maximum is changed from that entry's 286 s, which must fail naming the borrowing job, the source and both values, because a stand-in nobody can check against its source is a number somebody chose. python3 infra/scripts/test-image-frontend-gate.py and python3 infra/scripts/image-frontend-gate.py check . still green, run locally with the pinned yq before the push, which is the proof that the filter entries and the new steps did not disarm the existing mutations. actionlint -shellcheck=$(command -v shellcheck) .github/workflows/ci.yml .github/workflows/deploy.yml clean and shellcheck clean over the infra scripts. The whole ci aggregate green on the pull request, with every job finishing inside its new limit and the branch run's per-job durations recorded beside the limits, so the record shows the margin each job actually had. The deploy run on the merge commit green, with the same table for its 6 sampled jobs. No comment the card adds names a card, an item or a phase. Record docs/ops/a37-acceptance.md, holding the rule, its validation against the three measured warm-to-cold spreads, the table of 23 jobs with maximum, run id, any declared step budget, which term governed the number, the derived limit and the observed margin, taken from the derivation the infra step prints rather than re-typed, and the provisional deploy-prod row filed as its own open item; open item #118 closed.
Out of scope. Making any job faster, which is the e2e cache card's work and open item #109's. Hand-maintaining the numbers: deriving each one and requiring the declared value to equal it exactly is the check's whole purpose, not something held back from it, so a changed floor or a changed step budget moves the derived number and forces the matching workflow edit in the same commit, where a reviewer sees it. What stays outside the check is re-measuring: it reads the floor file and never a run history, so a maximum that has gone stale is caught by the card that changes that job's shape and by the re-derivation sentence in docs/CI.md, not by the check. Step-level timeout-minutes as a design, beyond counting it into term (b) and refusing one that exceeds its job's. Lowering the three declared SSH command_timeout budgets (deploy.yml:203, :335, :377): 30 minutes for a remote deploy and 15 for the smoke run are this workflow's existing decisions, the production one has no run behind it to argue from, and a deploy cancelled part way leaves a box half changed, so this card raises the job limits to clear those budgets and leaves the budgets alone; a card that wants them lower brings evidence from deploy runs. timeout-minutes on any workflow this repository does not own, and on composite actions. A retry or a rerun policy, which the handoff's rule 5 already settles. Investigating pressure.sh's 76 s to 331 s spread or gateway's cold-scope 303 s, both of which this card only measures. Changing DEPLOY_PROD_ENABLED or anything that would make deploy-prod run.
Waits on / Unblocks. Nothing: both workflows and the infra job are on main at 92a360f, and no open card edits deploy.yml. It shares ci.yml with the e2e cache card, so whichever merges second is rebased on the first and re-run under rule 1; if the cache card merges first this card derives e2e's number from the new maximum instead of 685 s, and if this card merges first the cache card's record re-derives that one number and its branch carries the change / every later card that adds a job to either workflow, which then declares its own limit from the same rule, and every future hang, which now costs minutes rather than six hours of a held runner and a pending required check.
Built as (PR #130 → main e1da5c4, 2026-09-20). All 24 jobs of the two workflows declare a timeout-minutes, no number was chosen by eye, and infra/scripts/workflow-timeouts.py derives each one and refuses a declared value that does not equal it. The rule takes the larger of two terms: twice the job's measured maximum plus five minutes, and the sum of the budgets the job's own steps declare plus that maximum plus five minutes. The second term exists because a step that carries its own budget is permitted to run to it, and a job limit under that permission cancels a run the workflow itself allows. The ceiling is 45 and the largest number the rule gives today is 41, so it refuses only a limit that is the six-hour default written out longhand. infra/scripts/workflow-timeouts.json is the committed floor of measured maxima, each with the run id it was read from, taken over the last 30 runs of each workflow on main, 674 job rows in all, and every number is a fresh re-measurement because two merged cards had already changed the jobs at the cut. The three jobs the second term governs are the three that carry a remote command budget, and those budgets are kept as they are: lowering the production deploy's needs evidence from a job that has never run, and a deploy cancelled part way leaves a box half changed. That job's floor entry is a stand-in naming the highest sampled job of its workflow, and the check refuses a stand-in that does not repeat both of its source's fields, so nobody can put a number of their choosing behind a source's name. The rule is validated against the warm-to-cold spread rather than assumed to cover it: every job with at least four samples gets a limit derived from its warm half alone, and every measured cold maximum still fits under it. A thin sample is answered by the re-derivation rule in the docs, and the check refuses the commit until the workflow and the floor file agree again, so the repair cannot be left half done. The check's own test is 54 cases, 30 sentences and 24 broken copies, and every broken copy is also run against a checker that reads nothing and must fail there. Every mutation's transcript is in the record, including the one that clears the remote budget by eight minutes and is refused anyway, which is what separates deriving a number from clearing a budget. Record docs/ops/a37-acceptance.md; open item #118 closed. Two findings filed: open item #130, the production deploy's provisional number, to be re-measured once that job has run three times, and open item #131, four helpers now duplicated across two workflow checkers.
A38 · The replay keeps its schedule, and the report says what it never offered · A · S · S2-W2 · fix/a/a38-replay-keeps-its-schedule (surfaced while building A31 on 2026-09-19, pre-existing on A4's branch, open item #106; carded 2026-09-20)
Context. The database replay driver throws away the load it failed to offer. Measured at main 92a360f, where A4 and A28 to A32 are merged (8d529d2). Each worker keeps its own pace in spikes/intelligence-data-path/pressure/drivers/db-driver.cjs: intervalMs = (1000 / targetRatePerS) * concurrency at :377, an anchor next = Date.now() at :378 taken after its connect (:369-375), and a tail at :403-406 that adds one interval, waits out the remainder, and, when the remainder is not positive, runs else next = Date.now(). That last branch is the defect. A statement that overruns its share moves the whole schedule forward by however long it overran, so every deadline that passed while the worker was busy is dropped and is never offered. This is coordinated omission: the driver quietly reduces the load to whatever the box can take, and then the report judges the box by a rate the driver itself lowered. The numbers the pressure run uses: the mix states 40 statements a second (corpus/queries/local-mix.json:4), the multiplier is 2 (db-driver.cjs:201, from the environment at :514, set in pressure/compose.intel.yml:188), so the target is 80 (:215); concurrency is 4 (:199, :512, compose.intel.yml:186) and the window is 1,200 s (:504, compose.intel.yml:187). So each worker owes one statement every 50 ms, each worker's schedule holds 24,000 deadlines, and the run's schedule holds 96,000. That is the count the healthy fixture asserts (infra/smoke/test-pressure.sh:478, | database | u | 96000 | 79.9 | 80 | 0 | at :483), and it is 960 laps of the mix, whose eight weights sum to 100 (local-mix.json:15, :24, :31, :38, :47, :54, :65, :72). What the report sees is one number: achieved_rate_per_s, the successful statements divided by the whole run (pressure/lib/result.cjs:40), judged against the target with a 2% allowance (infra/smoke/pressure_report.py:74, the rule at :521-526), a floor of 78.4 at target 80. Measured while building A31, under load: about 70.8 statements a second with the caps on and 71.7 with them off, and 79.0 with less load. Over 1,200 s those are 84,960, 86,040 and 94,800 statements, so 11,040, 9,960 and 1,200 of the schedule's 96,000 deadlines were dropped. The first two end the run INVALID on rate, and nothing in the report tells the operator whether the driver reset its own schedule or the box could not answer. The third is above 78.4, so the run reads valid while 1,200 statements, 1.25% of the load, were never offered and never named. P-1 runs this leg for 1,200 s under deliberate load, and it is X7's T-1 step on the release candidate. Two properties the fix must keep. A30's premise is that a paced driver cannot run ahead of its target (pressure_report.py:66-73); an absolute schedule keeps that true, because the window holds a fixed number of deadlines, so a late worker catching up can only recover deadlines that already passed and can never add one, and the most the run can issue stays 96,000. A28's floor is the other; past the deadline the workers finish their minimum work with no pace at all (:400-402), the result says so in floored (:473) and floor_note (:474), and the report skips the rate comparison for such a leg (pressure_report.py:515-519). The anchor is the third thing to keep honest: a worker anchors at its own first pass, after its connect, so a worker that connected late owes fewer deadlines, and the part of a shortfall that belongs to the connect and the setup is the part A30's 2% was sized for.
Read first. spikes/intelligence-data-path/pressure/drivers/db-driver.cjs:196-215 (the options, the target and the multiplier), :244-256 (the counters, startedAt, deadline, cursor, pastDeadline), :258-278 (A28's floor and its reasons), :366-408 (the worker: the connect, the pace at :376-378, the window check and the floor break at :384-386, the claim at :387-388, the statement at :393-398, the unpaced tail past the deadline at :400-402, and the pacing tail at :403-406), :445-489 (the result and every field under extra), and :503-523 (the settings the rig passes). spikes/intelligence-data-path/pressure/lib/result.cjs:23-47 (the fifteen fields the writer always writes, the achieved rate at :40, and the extra spread at :46). spikes/intelligence-data-path/pressure/corpus/queries/local-mix.json:1-14 (the target and the setup) and its eight weights. spikes/intelligence-data-path/pressure/compose.intel.yml:176-199 (the rig's settings for this driver). infra/smoke/pressure_report.py:57-74 (P-1's bar and the tolerance with its reason), :197-224 (load_drivers), :241-273 (is_measure and RESULT_SHAPE), :480-534 (every driver rule, the floored skip at :515-519 and the rate rule at :521-526) and :675-691 (the drivers table, the skip sentences and the client sentences). spikes/intelligence-data-path/pressure/README.md:265-305 (the floor, what the pace does inside the window and what a floored rate means). spikes/intelligence-data-path/pressure/test/db-driver.test.cjs:40-148 (the stub client with its queryMs, the late factory and the activity fixture) and :173-300 (the rate case, the determinism case and A28's floor cases). infra/smoke/test-pressure.sh:456-530 (the driver fixtures, write_driver, healthy_drivers and the rate edge cases). /root/development-processes/skynet/a4-p1-command-sheet.md § 3 (the drivers table paragraph, which names 80 and 78.4, and the floored skip sentence). Open item #106, and A28's and A30's cards in skynet-task-specs.md.
Deliverables. (1) The schedule keeps its deadlines. The reset at :406 goes: inside the window next only ever advances by one interval, so a worker that overran issues its overdue statements one after another until its next deadline is in the future, and it is back on the pace it was given. The catch-up needs no bound of its own, because the window holds a fixed number of deadlines and the recovery can only fill deadlines that have already passed; the comment there says that, and says why a reset loses load for good. Nothing about the unpaced tail past the deadline (:400-402) or A28's floor changes. (2) The driver counts what its schedule held and what it offered, inside the window only: the deadlines each worker's schedule held between its anchor and the run's deadline, the ones it issued, and the ones still unissued when the window closed. The anchor stays each worker's own first pass, and the comment says why: the run's deadline is fixed before the setup runs (:249-250) while a worker cannot take a pace until it has connected (:366-378), so a schedule anchored at the start gate would hand every worker a backlog it was never able to work, and the driver would burst at the head of the window over a debt the box never owed. It also states the window's own count, the target times the window, so a reader can separate the deadlines a late connect or a slow setup meant the schedule never held from the ones a worker held and failed to issue. The count is of statements issued, as cursor is (:388), not of statements that succeeded, which is what units is (:395). (3) The result carries those counts under extra, beside lap_statements and floored (:463-487), with the schema number unchanged (result.cjs:21), and it carries a sentence of its own, in the manner of floor_note (:175-183), whenever the run closed with deadlines unissued: how many of how many, what share of the load that is, and that those statements were never offered, so the achieved rate is short by at least that much before the box is blamed. A run that offered every deadline carries a null there, as an unfloored run carries a null floor_note. (4) The analyser reads them and the report shows them. RESULT_SHAPE (pressure_report.py:259-273) gains a row for each new field the analyser reads, each optional and each holding null or the type the writer writes, so a driver that does not write them is judged exactly as today. A line under the drivers table, beside the floored skip sentences (:682-683), states the deficit and its two parts when there is one, and prints nothing when there is none. No new verdict and no new threshold: RATE_SHORTFALL_TOLERANCE (:74), the rate rule (:521-526), the target 80 and the sheet's 78.4 are untouched. The contract the change buys is narrower than the rate, and the line says exactly that much: an overdue deadline is no longer discarded in silence, and the report separates the deadlines a late connect or a slow setup meant the schedule never held from the deadlines a worker held and failed to issue. The rate itself stays what it always was, successful statements over the whole interval from the gate to the end (lib/result.cjs:23-46), so the setup and the connect still lower it, and A30's 2% is still what covers them. (5) README.md:289-299 states the new rule where it states the old one: the schedule keeps its deadlines, a late worker catches up and cannot pass its target, the fields the result now carries and what the sentence means. (6) The P-1 command sheet's § 3 paragraph on the drivers table names the deficit line so the operator reads it beside the rate, and says that a deficit of zero is what a healthy leg reports. No number, threshold, cap or verdict word in the sheet changes. (7) The record docs/ops/a38-acceptance.md with every measurement below.
Interfaces. Exposes the schedule's own counts and their sentence under extra in the database driver's result, and the report line that reads them, which the analyser holds to a shape from its first commit. Consumes the writer's extra spread (lib/result.cjs:46), A28's floor and floored, A30's tolerance and floored skip, and A32's shape check, all on main. Unchanged: the result schema number, the target and the multiplier, the concurrency, the window, the tolerance, the floor, the probe and its steps, the verdict words, the exit codes, the drivers table's columns and the report's wording for every existing line.
Acceptance. cd spikes/intelligence-data-path && npm run test:pressure green under Node 24 (113 tests across pressure/test/ today, 30 of them in db-driver.test.cjs), with new cases in db-driver.test.cjs that each fail on the base for their own reason. The behaviour case: a stub whose one statement takes a stated multiple of the interval, in a window with room to recover afterwards, issues the schedule's full count on the branch and that many fewer on the base, both asserted against the schedule's own count and never against a clock reading, in A28's manner of asserting the contract. The accounting case: issued plus unissued equals the deadlines the schedule held, on a healthy run, on the stall case and on a floored run. The deficit case: a stall that runs to the end of the window leaves a non-zero count and the sentence, and a fast stub leaves zero and a null; on the base neither field exists. The no-overrun case: a stub faster than the pace never issues more than the schedule holds, so the driver still cannot pass its target. A28's and A31's existing cases pass unchanged, including the floored cases and the determinism case at :200. bash infra/smoke/test-pressure.sh green (114 check call sites today, one of them inside cannot_judge, which runs eleven times), with a case that a database result carrying a deficit prints the line under the table and one that a zero deficit prints nothing, each failing on the base because the base prints neither, and with :483's drivers table row and :515-524's two rate edge cases passing word for word. The real proof, in the manner of A30's record: the driver run against a throwaway postgres:17 container at its default target for a stated window, recorded with its rate, its deficit and the box's load, removed by exact name with its anonymous volume afterwards; then the same run with one statement deliberately stalled, where the base loses a measured number of statements for good and the branch recovers them. python3 -m py_compile infra/smoke/pressure_report.py, bash -n infra/smoke/pressure.sh, and gitleaks detect --no-git --source . --config infra/secret-scan/gitleaks.toml --redact clean. The infra and spike-a4 jobs green on the pull request (.github/workflows/ci.yml:511, :936). No comment the card adds names a card, an item or a phase. Record docs/ops/a38-acceptance.md; open item #106 closed.
Out of scope. The engine and transcriber drivers, which keep no pace: the engine is a do … while over its window (drivers/engine-driver.cjs) and the transcriber is a shell driver that writes its result through lib/write-driver-result.cjs, so neither has a schedule to drop. Any change to the tolerance, the target, the multiplier, the concurrency or the window, which the P-1 command sheet names and which this card must leave exactly as they are. A verdict of its own for a deficit: a strict rule on a measured quantity is what A30 had to undo, the deficit and the rate shortfall are the same shortfall in different units once the schedule is absolute, and one rule for it is enough. A28's floor and A31's probe, which are correct and stay. The analyser's shape check beyond the rows the new fields need, which is A32's. The loader's marker namespace, which is open item #108 and its own card.
Waits on / Unblocks. Waits on nothing: A4 with A28 to A32 is on main at 8d529d2, and the driver, the analyser and both test files are there. Shares infra/smoke/pressure_report.py and infra/smoke/test-pressure.sh with open item #108's card, which changes the container every driver rule reads, so that card merges first and this one is cut from main after it; whichever merges second re-runs infra/smoke/test-pressure.sh and the infra and spike-a4 jobs on the merge commit. Unblocks X7's T-1, the P-1 run on the release candidate, which must not run before this card lands, because until it does the run can drop part of its own load with nothing in the report to say so.
Built as (PR #127 → main 3665d9a, 2026-09-20). The database replay driver keeps its schedule. The reset at the pacing tail is gone, so inside the window a worker's next deadline only ever advances by one interval, and a statement that overran its share no longer costs the schedule the deadlines that passed. The driver counts the deadlines the window itself holds, the deadlines its workers' schedules held, the ones they issued and the ones the window closed on, and carries a sentence of its own when the last count is not zero. The analyser reads the four counts through four optional shape rows and prints one line under the drivers table, stating the deficit in the two parts it has: the deadlines the schedules never held, which is the cost of the connect and the setup, and the deadlines a worker held and did not issue. The line is not a finding and no exit code depends on it. The real proof was run once against a throwaway database on the box, four paced runs of sixty seconds each, with one statement of the mix held for five seconds in two of them. The base issued 355 fewer statements with the hold than without it; the branch issued two more, and its rate was the same in both. The sharper number is the base's run with nothing held at all, which reached 78.387 statements a second against a floor of 78.4, so a healthy box read INVALID on rate and nothing in the report said that the driver's own schedule had dropped 84 statements. Two defects were found in review of the branch's own diff and fixed here. The deficit line told a floored leg that the rate allowance covered its shortfall, where the rate rule had deliberately skipped that comparison one line above; the closing sentence now reads the same two conditions the rate rule reads, in the same order, and names the allowance only where the comparison ran. And the window's own count was the target rounded over the whole window while each worker rounds a deadline up from its anchor, so on a window that is not a whole number of intervals the workers could hold more than the rounded target, which a clamp hid; both counts now come from one rule, so the window's is an upper bound by construction and the clamp is gone. Five driver cases and four harness cases are new, each failing on the base for its own reason, and the overrun case is sized against a number worked out from the settings rather than read off a result, after three earlier shapes proved unreliable on a shared box. Record docs/ops/a38-acceptance.md; open item #106 closed.
A39 · An analysis that was stopped is not a verdict · A · S · S2-W2 · fix/a/a39-analysis-not-done-exit (surfaced while building A32 on 2026-09-19, pre-existing on A4's branch, open item #107; carded 2026-09-20)
Context. The pressure run has three verdicts and three exit codes, and a fourth thing can happen that it has no answer for. Measured at main 92a360f, where A4 with A28 to A32 is merged (8d529d2). The analyser's contract is 0 for PASS, 1 for FAIL and 2 for INVALID (infra/smoke/pressure_report.py:19-27), and it keeps to it: main returns those three (:811-835), returns 2 for missing arguments (:814), returns 2 for a report path that is taken (:825), and catches Exception so its own fault is INVALID with exit 2 rather than the FAIL that an uncaught exception would be (:836-838, fault at :787-808). A32 chose Exception and not BaseException on purpose, and said why in the code: an interrupt or a termination is not a completed judgement of any kind, so it is not made into one (:815-818). Nothing downstream finishes that thought. infra/smoke/pressure.sh runs the analyser at :912, keeps its status at :913, and its case at :920-933 has three arms: 0 prints PASSED, 1 prints FAILED when a report exists and INVALID with exit 2 when it does not, and *) prints INVALID. Then :934 is exit "$rc", so whatever the analyser's status was leaves the script unchanged. Measured on this box with Python 3.13: an unhandled KeyboardInterrupt ends the interpreter with the shell status 130, and a SIGTERM with 143. Measured in a copy of the script's own construction, set +e, the command, rc=$?, set -e: when only the analyser is signalled, the case runs, the *) arm prints PRESSURE TEST INVALID … the run proves nothing; read the INVALID lines while no INVALID line was ever printed, and the script exits 143. When the signal reaches the whole process group, which is what a terminal does on Ctrl-C, bash ends the script at the child, the EXIT trap at :218 still runs its cleanup (rig_down and scrub_work, :211-217), and the case never runs at all, so the operator gets no verdict line and a status of 130. Either way the operator's sheet has no answer. The P-1 command sheet says the run ends with one of PRESSURE TEST PASSED, PRESSURE TEST FAILED or PRESSURE TEST INVALID and the line exit code 0, 1 or 2 in that order (/root/development-processes/skynet/a4-p1-command-sheet.md:170), and its only word on an interrupt is that the script then removes what it started and the run counts as not done (:174); it has no table of codes at all. The sheet's own command reads the status through ${PIPESTATUS[0]} past a tee (:165), so whatever the script exits with is what the operator writes down and sends. And the claim the *) arm makes is not harmless: INVALID in this system means the run was judged and the judgement is that it proves nothing, which is a statement about the run; a stopped analysis is a statement about the analysis, and a partly written report may be sitting beside it. P-1 is X7's T-1 step on the release candidate, run by hand in a 35 minute window, so the one place this shows is the one place nobody should have to guess.
Read first. infra/smoke/pressure.sh:158-163 (fail and invalid, the two statuses the script chooses for itself), :205-218 (scrub_work, on_exit and the EXIT trap, the only trap the script sets), :911-934 (the verdict step: the analyser, the status, the comment that states the exit-1 rule, the case and exit "$rc"). infra/smoke/pressure_report.py:1-43 (the module contract and its three codes), :722-741 (write_new), :742-777 (fault_sentence and fault_report), :778-808 (say and fault, which returns 2), :811-838 (main, its three returns of 2 and the except Exception with A32's reason above it) and :840-841. infra/smoke/test-pressure.sh:299-330 (check, run and newest_report), :766-800 (the analyser run on its own over a kept work directory), :817-846 (A32's analyser-fault cases and, at :838-845, the stub analyser beside a copy of the script, which is where the script's own half is tested). spikes/intelligence-data-path/pressure/README.md:36-46 (what a PASS and a FAIL mean for the go-live) and its verdict paragraphs, which name no exit code. /root/development-processes/skynet/a4-p1-command-sheet.md § 3, especially :165 (the command and PIPESTATUS), :170 (the three codes in prose) and :174 (Ctrl-C). docs/ops/RESOURCE_LIMITS.md:242-262. Open item #107 and A32's card in skynet-task-specs.md.
Deliverables. (1) The contract, decided and written where each half of it is read. The analyser returns 0, 1 and 2 and nothing else, for every path including missing arguments, a taken report path and its own fault; its docstring (pressure_report.py:19-27) says so in those words, so any other status from that command is either one of the two signals the script can answer for or a status the contract does not define. A signal is not a verdict: the analyser did not judge the run, so the run has no verdict, and the script never prints one for it. The contract recognises exactly two inherited signals, the interrupt and the termination, and nothing else. Those are the two the script can trap, clean up for and re-raise, so they are the two it can promise the same behaviour for on every path; a signal that cannot be trapped cannot have its cleanup promised, and SIGKILL at 137 is the plain case, so the contract does not pretend to own it. Every other status is named as what it is, a status the analyser has no such exit for, and is INVALID. This closes the set: 0, 1, 2, 130, 143, anything else, each with one outcome and no overlap. (2) The script's verdict step reads exactly 130 as an interrupt and 143 as a termination, and prints one line of its own: PRESSURE TEST NOT DONE, the stack sha as the other three lines carry it, that the analysis was interrupted or terminated before it judged anything, and that the run is therefore not judged rather than judged to prove nothing. The line says whether a report exists at the path, and that a report written before the signal may be partial and is left exactly as it is, never deleted and never overwritten. (3) The script then ends by that same signal on itself, after the EXIT trap has removed the rig and scrubbed the work directory: the trap for that signal is cleared and the script kills itself with it, so the caller reads the conventional 130 for an interrupt and 143 for a termination, and a parent that inspects the wait status sees a death by signal and not a code the script chose. Measured in a copy of this construction: the line prints, the EXIT trap runs, and the caller sees the signal. Only these two statuses are re-raised, and the cleanup is the reason the contract stops there: the EXIT trap at :211-218 can run for an interrupt and for a termination and can never run for a kill, so the script promises a clean end only for the signals it can keep that promise for. (4) The same line on the other path. One handler on INT and TERM prints that line and re-raises in the same way, so a terminal interrupt during the baseline, the load or the analysis ends with the same sentence and the same status as an interrupt of the analyser alone, instead of ending with no line at all. (5) Any other status, which is anything that is not 0, 1, 2, 130 or 143, is INVALID with exit 2 and a sentence naming the code it saw, saying that the analyser has no such exit and that the analysis therefore did not finish, so the run proves nothing. A status above 128 that is not 130 or 143, a kill at 137 among them, lands here and is named by its number, so no code has two answers. The three existing arms keep their wording exactly, including A32's exit-1 rule at :922-927. (6) The P-1 command sheet: the prose at :170 becomes a short table that covers every path once and leaves none with two answers, one row each for 0 PASSED, 1 FAILED, 2 INVALID, 130 NOT DONE after an interrupt and 143 NOT DONE after a termination, and a last row for every other code, which the script reports as INVALID with exit 2 and the code named in the line; and :174's paragraph says what the operator sees on Ctrl-C, that the run is not judged, and which line to send. No threshold, cap, verdict word or number the sheet already names changes. (7) spikes/intelligence-data-path/pressure/README.md states the same table in its verdict paragraphs, which name no code today. (8) The record docs/ops/a39-acceptance.md, with the measured status of each case.
Interfaces. Exposes one new operator-facing line and the two statuses that carry it, and the rule that the analyser's status set is closed, which every later caller of the analyser reads. Consumes A32's exit-1 rule and its fault path, both on main. Unchanged: the analyser's own code, every verdict word, the three verdict lines and their wording, the report and its path, the EXIT trap's cleanup, and every threshold and cap the sheet names.
Acceptance. bash infra/smoke/test-pressure.sh green (114 check call sites today, one of them inside cannot_judge, which runs eleven times), with new cases beside A32's at :834-846, each built on the same stand-in analyser beside a copy of the script and each failing on the base for its own reason. A stand-in that kills itself with SIGINT: on the branch one PRESSURE TEST NOT DONE line, no PRESSURE TEST INVALID and no PRESSURE TEST FAILED, and the script itself dead by that signal, status 130; on the base, PRESSURE TEST INVALID … the run proves nothing; read the INVALID lines with no INVALID line above it, and status 130. The same with SIGTERM and status 143. A stand-in that writes a report and then kills itself: the NOT DONE line names the report and says it may be partial, and the file is byte for byte what the stand-in wrote; on the base the INVALID line claims the report holds INVALID lines. A stand-in that exits 3: INVALID with exit 2 and a sentence naming 3 and saying the analysis did not finish; on the base, exit 3 and the INVALID line that claims the run was judged and proves nothing. A stand-in killed with SIGKILL, which no trap can answer: INVALID with exit 2 and the same sentence naming 137, and never a NOT DONE line, which proves the two contracts do not overlap at a status above 128; on the base, exit 137 and the same false INVALID claim. One case for the group path, the terminal's own: the copied script is run in a session of its own so the group signal reaches only it, and the case reads the script's wait status rather than the status of the command that started it, because setsid --wait does not pass a death by signal through, measured here as exit 2 with a message about the child not exiting normally; on the branch that run prints the NOT DONE line and dies by the signal, on the base it prints no verdict line at all. Every existing case passes with its wording unchanged, including A32's two stand-in cases at :842 and :845 and the analyser-fault cases at :827 and :833. bash -n infra/smoke/pressure.sh, shellcheck infra/smoke/pressure.sh and shellcheck infra/smoke/test-pressure.sh clean at the level the repository already runs them, python3 -m py_compile infra/smoke/pressure_report.py, and gitleaks detect --no-git --source . --config infra/secret-scan/gitleaks.toml --redact clean. The infra job green on the pull request (.github/workflows/ci.yml:511). The sheet's table checked against the branch, code by code, with the status each one produced written in the record. No comment the card adds names a card, an item or a phase. Record docs/ops/a39-acceptance.md; open item #107 closed.
Out of scope. The analyser's own signal handling: it keeps catching Exception and never BaseException, for A32's stated reason, so an interrupt still ends it as it would have. Turning the interrupt or the termination into a verdict, which is the defect and not the fix: neither becomes PASS or FAIL, neither is remapped to 2, and neither prints an INVALID line. A status the contract does not define is a different thing, and deliverable (5) makes it INVALID because the run was not judged and there is nothing else true the script can say about it. Deleting or truncating a report written before a signal, which is evidence. The rig cleanup and the work-directory scrub, which already run on every exit path and are unchanged. Retrying or resuming an interrupted analysis over a kept work directory, which the operator already does by running the analyser on its own (test-pressure.sh:766-800) and which needs nothing new. Every threshold and cap the sheet names, which this card must leave exactly as they are. The driver's schedule (open item #106) and the loader's markers (open item #108), each its own card.
Waits on / Unblocks. Waits on nothing: A4 with A28 to A32 is on main at 8d529d2, and both the script and the stand-in mechanism this card tests with are there. It is the only one of the three pressure cards that does not touch infra/smoke/pressure_report.py, so it can be built beside either of them; it shares infra/smoke/test-pressure.sh with both, so whichever merges second rebases and re-runs that suite and the infra job on the merge commit. Unblocks X7's T-1, the P-1 run on the release candidate, which must not run before this card lands, because the sheet the operator follows does not define what they would be looking at.
Built as (PR #128 → main c01d822, 2026-09-20). The pressure run's set of endings is closed, and the fourth thing that could happen to it has a line and a status of its own. The analyser's module docstring now says that 0, 1 and 2 are the whole set and that every path returns one of them, missing arguments, a taken report path and the analyser's own fault included, so a caller that reads any other status is not reading a verdict; no code in the analyser moved. The script reads that as a closed set: the arm that used to catch 2 and everything else is now the arm for 2 with its wording unchanged, two new arms answer an analyser stopped by an interrupt or by a termination, and the new last arm calls any other status INVALID and names the status it saw. One handler covers both ways a signal reaches the run, the analyser alone and the whole process group. It prints one line saying the run is not judged rather than judged to prove nothing, says whether a report exists and that a report written before the signal is left exactly as it is, runs the same cleanup the exit trap would, clears that trap so the cleanup cannot run twice, and then sends the signal to itself, so a parent reads a death by signal rather than a number the script chose. A kill is never in a not-done row: the two signals recognised are the two the exit trap can run for, and an untrappable kill is read as what it is, a status the analyser has no exit for. Six new cases prove it, four of them ending with the script dead by a signal, which the shell cannot tell apart from a chosen exit, so those runs go through a small parent that reads the wait status as the kernel set it. On the base all six drew a false INVALID line, or no line at all. The base run is 132 cases ok with exactly those six failed; the branch is 138 ok. Every row of the command sheet's new exit-code table has a case behind it and the measured status beside it, and the sheet now says that the operator's own shell can drop the exit-code line after an interrupt, so the printed line is the one to send. Record docs/ops/a39-acceptance.md; open item #107 closed.
A40 · The loader's markers sit where no driver field can imitate them · A · S · S2-W2 · fix/a/a40-loader-markers-off-the-result (surfaced while building A32 on 2026-09-19, pre-existing on A4's branch, open item #108; carded 2026-09-20)
Context. The pressure analyser says three things about a driver result by writing them into a dict that the driver also fills. Measured at main 92a360f, where A4 with A28 to A32 is merged (8d529d2). load_drivers in infra/smoke/pressure_report.py:197-224 appends {"driver": f.stem, "unreadable": True} for a file it cannot parse (:208), {"driver": f.stem, "reserved_only": True} for a file that still holds a reservation (:213-214), {"driver": result_leg(data, f.stem), "malformed": problem} for a result that fails A32's shape check (:219-221), and otherwise appends the driver's own dict as it is (:223). The rules then ask the same dicts for those names: driver.get("unreadable") at :485, driver.get("reserved_only") at :488 and driver.get("malformed") at :492. So the loader's vocabulary and the drivers' field names are one namespace, and the drivers fill it generously. spikes/intelligence-data-path/pressure/lib/result.cjs:46 spreads fields.extra straight into the top level of the result beside the fifteen fields the writer always writes (:30-45), so the database result carries 34 top-level fields, nineteen of them from its own extra block (drivers/db-driver.cjs:463-487), the engine result 22 with seven of its own (drivers/engine-driver.cjs:180-193) and the transcriber result 19 with four (drivers/transcriber-driver.sh:119, :160-161). The transcriber's four are the sharp part: lib/write-driver-result.cjs:30-37 builds that driver's extra from the environment, turning every variable named A4RX_<key> or A4RJ_<key> into a top-level field called <key> in lower case, so the name of a field in a result file is decided by the name of an environment variable at run time. Read against the rules, a collision reads like this. A healthy result carrying unreadable gets the database driver's result file could not be read, a sentence about a file that was read perfectly. One carrying reserved_only gets the database driver reserved its result file and never wrote one, about a leg that finished. One carrying malformed is worse than either: :493 appends the field's own text as the analyser's finding, so a driver result writes a line of the report's verdict. Each one ends the run INVALID, so no collision can turn a bad run into a PASS, and that is the only reason this is a small card; what it costs is the truth about which leg did what, in the report that decides whether Intelligence activates. A fourth name sits in the same namespace from the other side: the reservation is recognised by any truthy placeholder (:213), while the reserver writes one exact string, pressure-driver-result-reserved (lib/result.cjs:53, written at :99), so a finished result that carried a placeholder field of any kind would be read as a leg that never came back. No result any driver writes today carries any of the four names, measured over the three extra blocks and the fifteen fixed fields, so nothing is misjudged now. The defect is the namespace, and the namespace is what a later card, a later driver field or a run with one more environment variable will walk into.
Read first. infra/smoke/pressure_report.py:1-43 (the module contract, the exit codes and what INVALID means), :197-224 (load_drivers, the three markers and the reservation test), :227-273 (is_finite_number, is_measure and RESULT_SHAPE), :276-312 (held, result_leg and A32's result_shape_problem, which decides the leg name a marked record carries), :335-345 (analyse and where drivers is built), :480-534 (every driver rule in order: the leg name at :484, the three marker arms at :485-494, the idle rule with A30's setup note at :495-505, the rate rules at :506-526, the refusals, the errors and the connection peak at :527-534), :535-551 (the one-result-per-leg rule, which counts by the same field), :553-556 (the run that drove nothing), :571 (the drivers key of the analysis dict) and :675-691 (the drivers table, the skip sentences and the client sentences). spikes/intelligence-data-path/pressure/lib/result.cjs:23-47 (the fifteen fixed fields and the extra spread) and :50-105 (the reservation, its sentinel and its claim). spikes/intelligence-data-path/pressure/lib/write-driver-result.cjs:10-37 (the environment-named extras) and spikes/intelligence-data-path/pressure/drivers/transcriber-driver.sh:115-125 and :155-165. spikes/intelligence-data-path/pressure/drivers/db-driver.cjs:463-487 and drivers/engine-driver.cjs:180-193 (the other two extra blocks). infra/smoke/test-pressure.sh:456-530 (write_driver, healthy_drivers and the driver fixtures), :571-633 (A32's shape section with set_field at :580 and cannot_judge at :591) and :299-330 (check, run, newest_report). Open item #108 and A32's card in skynet-task-specs.md.
Deliverables. (1) The loader hands each rule a record, never the driver's dict. One small type beside load_drivers, from the standard library alone as the module requires (:31), holding the leg the file stands for, the driver's own result or nothing when there is none, and the loader's verdict on the file: read, unreadable, a reservation only, or malformed with A32's sentence. The verdict is a closed set named in one place, with a comment saying why it cannot live in the result: the result's field names come from three drivers and, for one of them, from the environment. (2) Every rule reads the record. The leg name comes from the record (:484, :542), decided once by result_leg (:282-286) as it is today, so an unreadable file still stands for its file name and a malformed result still stands for its own driver name. The three marker arms (:485-494) read the record's verdict. Every other rule reads the record's result and is otherwise unchanged in order and in wording, including the idle sentence with its setup note, both rate rules, the refusals, the errors, the connection peak, the one-result-per-leg count and the test for a run that drove no load at all. (3) The reservation is recognised by the reserver's exact sentinel and not by any truthy value: the string at lib/result.cjs:53, named once in the analyser beside the reason. A file whose placeholder holds anything else is a result like any other and meets A32's shape check, which refuses it with a sentence if it is not one. (4) The report does not move. The Markdown of a healthy run is byte for byte what it is today, and a marked record renders the same row of dashes it renders now; the drivers table (:676-679), the skip sentences (:682-683), the connection line (:684-686) and the client sentences (:687-690) all read the record. The drivers key of the analysis dict (:571) carries the record's shape, which changes what --json prints; nothing reads that output today, measured by searching infra/smoke/pressure.sh, infra/smoke/test-pressure.sh, docs/ and .github/workflows/ for the flag, and the record says so. (5) A collision test for each name. (6) The record docs/ops/a40-acceptance.md.
Interfaces. Exposes the loader's record as the one thing every driver rule reads, and the closed set of the loader's own verdicts, which any later marker joins instead of being written into a result. Consumes A32's shape check and its sentences, A30's tolerance and floored skip, and the drivers' result shape, all on main. Unchanged: every driver, lib/result.cjs, the result schema number, the fields a result carries, the reservation and its claim, the verdict words, the exit codes 0, 1 and 2, the P-1 bar, the report's Markdown and every INVALID sentence.
Acceptance. bash infra/smoke/test-pressure.sh green (114 check call sites today, one of them inside cannot_judge, which runs eleven times), with new cases built on healthy_drivers and set_field (:474, :580) that each fail on the base for their own reason. A healthy database result carrying unreadable true, one carrying reserved_only true, one carrying malformed with a sentence of its own, and one carrying all three: on the branch each run is PASS with exit 0 and the healthy drivers table, and on the base each is INVALID with exit 2 and its own false sentence, the third printing the field's own text as the analyser's finding. A healthy result whose placeholder is a string that is not the reserver's sentinel: on the branch it is judged as the result it is, on the base it is reported as a leg that reserved its file and never came back. The three real cases keep their sentences word for word: a file that does not parse, a file that still holds a real reservation written by the reserver, and each of A32's eleven cannot_judge cases at :592-633. A malformed result still counts as its leg's one result, so no run adds the sentence that the leg left none. The healthy report is compared byte for byte with the report the base produces for the same fixtures, and the record carries that comparison. python3 -m py_compile infra/smoke/pressure_report.py, bash -n infra/smoke/pressure.sh, and gitleaks detect --no-git --source . --config infra/secret-scan/gitleaks.toml --redact clean. The infra job green on the pull request (.github/workflows/ci.yml:511). No comment the card adds names a card, an item or a phase. Record docs/ops/a40-acceptance.md; open item #108 closed.
Out of scope. Every driver and every writer: no result changes, no field is renamed and no field is forbidden, because the fix is that a result's fields stop being read as the loader's words. The environment-named extras of lib/write-driver-result.cjs, which stay as they are and are the reason the namespace cannot be defended by naming conventions. A32's shape check itself, beyond being read through the record. The wording of the three marker sentences and of every other INVALID line, which stays as it is. The report's Markdown, which must not move at all. The --json output's consumers, of which there are none today. The driver's schedule (open item #106) and the script's exit codes for a stopped analysis (open item #107), each its own card.
Waits on / Unblocks. Waits on nothing: A4 with A28 to A32 is on main at 8d529d2, and the loader, the rules and A32's fixtures are there. It changes the container every driver rule reads, so it is built and merged before open item #106's card, which adds a rule and a report line of its own on the same section of infra/smoke/pressure_report.py; that card is then cut from main after this one, and whichever of the three pressure cards merges second re-runs infra/smoke/test-pressure.sh and the infra job on the merge commit, which all three share. Unblocks X7's T-1, the P-1 run on the release candidate, which must not run before this card lands, because until it does the report can name the wrong leg and can print a driver's own text as its finding.
Built as (PR #122 → main 50c1dda, 2026-09-20). The analyser's loader hands every rule a record instead of the driver's own dict. The record is a frozen dataclass of four fields, and its verdict is one member of a closed enum, so the loader's own words no longer share a namespace with a result's fields. They could be imitated before: the shell drivers' environment turns a named variable into a top-level result field, so a field the loader wrote is one a run can grow by adding one variable, and the text of one such field was appended as the analyser's own finding, which let a driver result write a line of the verdict. No result any driver writes today carried any of the four names, so nothing was misjudged; what the collision cost was the truth about which leg did what. A reservation is now recognised by the whole of the file the reserver writes, its three keys and nothing else with the sentinel in place, and never by a value a larger file can carry, because that sentinel can be put into a finished result through the same environment door. Fifteen rules read the record, and their order, their wording and the idle sentence with its setup note are unchanged. No driver changed, no result field was renamed, and no verdict word, exit code or threshold moved. The healthy run's report is byte for byte what the base produces for the same fixtures, and a marked record renders the same row of dashes, measured on one work directory with both analysers over three replaced results. Eight harness cases are new and seven of them fail on the base, each with the sentence the base drew; the eighth pins the sentence for a file that does not parse, which had no case of its own. The writer guard binds the analyser's rule to the reserver itself, so a key added or dropped on either side fails a test. The suite goes from 124 cases to 132. Record docs/ops/a40-acceptance.md; open item #108 closed.
A2f · The scraper engine and transcriber images are published and pinned by digest · A · L · S2-W3 · feat/a/a2f-publish-engine-and-transcriber-images (surfaced while re-measuring A2d; carded 2026-09-24)
Context. Measured at main 78b90301. A2d puts the scraper engine and the transcriber into both stacks, and a stack service runs an image pinned by digest (AGENTS.md rule 8, "no latest image tags at deploy time"). No workflow builds either image today. .github/workflows/deploy.yml (579 lines) builds two: build-api (:48-87, timeout-minutes: 15, the pushed digest as the job output at :56-57, the tags main-{{sha}}, main-${{ github.sha }} and latest on the default branch only and the branch name on any ref, docker/build-push-action at :75-87 with context . and apps/api/Dockerfile) and build-web (:89-131, timeout-minutes: 17). verify-tags (:133-171, needs both) asks the registry whether main-<full sha> resolves to the digest each build pushed (check() at :159, the two calls at :169-170); on any ref but main it fails, because the main- tags are written on the default branch only, but check() records a failure and goes on, so every check runs. deploy-staging (:173-357, needs build-api, build-web and verify-tags, command_timeout: 30m, timeout-minutes: 36) refuses an empty digest at :245 and writes .env.release at :326 as IMAGE_TAG, API_IMAGE_DIGEST, WEB_IMAGE_DIGEST and BACKUP_GID; deploy-prod (:382-556) does the same at :424 and :500; notify (:558) needs every job. infra/deploy/rollback.sh resolves one image pair under one tag form (resolve_pair, :52-59, the pulls at :54-55, the digests at :56-57) and writes .env.release at :85; infra/deploy/test-rollback-images.sh (167 lines, run by the infra job at ci.yml:680-681) proves that resolution. The CI render writes a stub .env.release at ci.yml:641 and infra/deploy/preflight.sh:74-75 renders with zero digests; both name the two digests only. The scraper image builds from services/scraper (AGENTS.md names docker build -f services/scraper/Dockerfile services/scraper): the production stage is mcr.microsoft.com/playwright v1.58.1 on noble (Dockerfile:67), with pm2@7.0.4 at :97, USER pwuser at :147 and CMD /app/start.sh at :159, and a local build measured 3.61 GB (docs/ops/a9-acceptance.md:2484-2487). The transcriber builds from services/transcriber (python:3.12-slim, EXPOSE 9010, uvicorn), which is still a locked mirror (AGENTS.md:22), so its image is built from the mirror's bytes unchanged. A9's import gate has three parts (docs/ops/a9-acceptance.md:3557-3566); the third, the built-image scan (no directory of any forbidden package in the image or the admin's traced output, no shipped file naming one, and the process manager at the version the gate is proved against), is run by hand because no CI job builds this image (:3751-3754), and it closes before A2d's staging acceptance. The forbidden list is FORBIDDEN and isForbidden in services/scraper/test/import-gate.test.mjs:51-63. infra/scripts/image-frontend-gate.py reads every build-push step of deploy.yml and gates only a Dockerfile that copies apps/web; the scraper Dockerfile copies its own admin, not apps/web. docs/CI.md describes the pipeline at :130-143 (verify-tags "for both images"; BUILD_IMAGES_ENABLED "build + push skynet-api and skynet-web"; and at :143 "Sidecar images … never built here until their merge slice (ADR-006)", where ADR-006 is bindings-implementation and says nothing about images) and holds the deploy.yml floor table at :112-120. The floor file infra/scripts/workflow-timeouts.json holds build-api 286 s (run 35458689452), build-web 105 s (run 35373768252, mark 344 s), verify-tags 38 s (run 35498945579), deploy-staging 59 s (run 35510105388), deploy-prod as a stand-in for build-api, and notify 4 s (run 35501427260); ci.yml's infra job holds 280 s (run 35618886010).
Read first. .github/workflows/deploy.yml whole file; infra/deploy/rollback.sh whole file and infra/deploy/test-rollback-images.sh; infra/deploy/preflight.sh:60-90; .github/workflows/ci.yml:553-700 (the infra job, the stub env at :641, the renders at :643-651); services/scraper/Dockerfile whole file; services/transcriber/Dockerfile; services/scraper/test/import-gate.test.mjs:1-120 and :1900-1930 (the forbidden list, the scanned file set, the process manager's version); docs/ops/a9-acceptance.md:2480-2530 (the manual build and scan) and :3557-3566, :3751-3754 (the gate's third part and why it is open); infra/scripts/image-frontend-gate.py; docs/CI.md:75-87 (the floor rule), :112-120 and :130-143; infra/scripts/workflow-timeouts.json (the deploy.yml entries and infra); docs/runbooks/DEPLOY.md and docs/runbooks/ROLLBACK.md; AGENTS.md:22-24 and rule 8.
Deliverables. (1) Two build jobs in deploy.yml, written like build-api and gated by the same BUILD_IMAGES_ENABLED: build-scraper (image skynet-scraper, context services/scraper, services/scraper/Dockerfile, its own gha cache scope) and build-transcriber (image skynet-transcriber, context services/transcriber, services/transcriber/Dockerfile, its own cache scope). Each carries the same four tag rules as build-api and outputs its pushed digest. Two jobs, not a matrix, for the reason the comment above build-api gives. (2) The built-image scan in CI: build-scraper pulls the image it pushed by digest, exports its filesystem, and runs node services/scraper/tools/image-scan.mjs <root>, which fails on a directory of a forbidden package anywhere in the image (the admin's traced output included), a shipped file that names one, or a process manager at any version but the one the Dockerfile installs. The forbidden list moves out of import-gate.test.mjs:51-63 into one module that the gate's test and the scan both import, so the two cannot drift. The job fails when the scan fails, and it fails before any box is touched. This closes the gate's third part (docs/ops/a9-acceptance.md:3751-3754). (3) verify-tags needs the two new jobs and checks four images, skynet-api, skynet-web, skynet-scraper and skynet-transcriber, each at main-<full sha> against its own job's digest. (4) The release contract carries four digests. deploy-staging and deploy-prod need the two new jobs, pass SCRAPER_DIGEST and TRANSCRIBER_DIGEST in env and envs, refuse an empty one in the digest guard (:245, :424), and write SCRAPER_IMAGE_DIGEST and TRANSCRIBER_IMAGE_DIGEST into .env.release (:326, :500). notify needs the two new jobs. The CI stub at ci.yml:641 and the zero-digest render at preflight.sh:74-75 carry the two new names, so a compose file that references them renders in CI and on the box. (5) rollback.sh resolves the two Intelligence images under the same tag form as the api and web pair and writes their digests into .env.release. A target commit whose compose files name no Intelligence service needs neither image, and its rollback is unchanged; a target commit whose compose files name either service refuses when that image does not resolve, and names the image and the tag forms it tried. test-rollback-images.sh gains those cases. (6) docs/CI.md: the stage list, the verify-tags paragraph and the BUILD_IMAGES_ENABLED row name four images; the sentence at :143 is replaced by what is true (the scraper engine and the transcriber are built and pinned here; the other sidecars are not); the floor table gains the two new rows. docs/runbooks/DEPLOY.md and docs/runbooks/ROLLBACK.md state the four-digest release. (7) Floors, by the rule at docs/CI.md:75-87, one treatment per changed job, each carried in infra/scripts/workflow-timeouts.json, the derived timeout-minutes and its docs/CI.md row in the same commit, and each listed in the record with its reason. build-scraper and build-transcriber (new jobs): measured from a workflow_dispatch run of deploy.yml on this card's branch, and only from a run in which that job, at the exact head SHA the pull request carries, ended in success after the real build, the push to the registry and, for build-scraper, the scan; the first such run has a cold cache for both images. The record names the event (workflow_dispatch), the run id and the head SHA. A later head that changes either Dockerfile, either build context's contents or either job is measured again the same way. verify-tags: its work goes from two registry look-ups to four, so its measured 38 s (run 35498945579) no longer bounds it, and a branch run fails on purpose at the missing main- tag, so no branch run is a floor. Its entry becomes an estimate: max_seconds 76 (the measured two-image run, doubled for twice the look-ups), with the estimate sentence naming this card's record. The first verify-tags run on main that prints ok for all four images, which is the deploy run of this card's merge commit, is the run that replaces it; A2g writes that replacement, because it is the first card after this one that measures the pipeline on main. deploy-staging: this card changes its shape in four places, each enumerated in the record: (a) needs gains the two build jobs, which delays the job's start and adds nothing to its own duration; (b) env and envs gain SCRAPER_DIGEST and TRANSCRIBER_DIGEST; (c) the digest guard at :245 tests four values instead of two; (d) the .env.release printf at :326 writes six fields instead of four. (b) to (d) are shell and environment work with no network or disk I/O beyond one small file. Its entry becomes an estimate: max_seconds 59 (run 35510105388) plus 0 s for each of (a) to (d), with the estimate sentence naming the record; A2d's estimate for its own shape supersedes it, and A2g replaces that with a measured run. deploy-prod: the same four changes at :382-556 (its needs, env and envs, the guard at :424, the printf at :500). It runs only at X7, so no run of either shape exists; it stays a stand-in for build-api, and the record states that (a) to (d) add no measurable time and that X7's first production deploy measures it. notify: needs gains two jobs and its step reads two more entries of needs; measured from the same dispatch run, which it ends in success whatever the other jobs' results, with the run id and head SHA in the record. The infra job gains the new test step of (8), so it is re-measured from the pull request's own successful ci run at the head SHA the record names. (8) infra/scripts/test-release-images.py, run in the infra job, reads deploy.yml through the pinned yq and proves the contract: four build jobs each with a digest output; verify-tags needing all four and checking all four; both deploy jobs needing all four, guarding all four digests and writing all four into .env.release; notify needing every job; and no build-push step for an Intelligence image with any tag that is not one of the four rules. (9) Record docs/ops/a2f-acceptance.md.
Interfaces. Exposes ghcr.io/rescale-media/skynet-scraper and ghcr.io/rescale-media/skynet-transcriber, each tagged main-<sha> on main, and the job outputs build-scraper.outputs.digest and build-transcriber.outputs.digest. Exposes SCRAPER_IMAGE_DIGEST and TRANSCRIBER_IMAGE_DIGEST in every .env.release that the deploy, the rollback, the CI render and the preflight write, which A2d's compose services interpolate. Exposes the image scan and the one forbidden-package module. Consumes the scraper and transcriber Dockerfiles as they are on main. Unchanged: every byte of services/transcriber, the two existing images, their tags and caches, what the boxes run (no compose file names the new images until A2d), smoke-staging, and the variables that gate each stage.
Acceptance. Locally the builder runs only targeted tests, red-on-base proofs, generators and fast static checks: python3 infra/scripts/test-release-images.py, bash infra/deploy/test-rollback-images.sh, python3 infra/scripts/workflow-timeouts.py check ., python3 infra/scripts/test-image-frontend-gate.py and python3 infra/scripts/image-frontend-gate.py check . (ci.yml:598-601), in services/scraper node --test test/import-gate.test.mjs and the scan's own test, and one local docker build of each image with the scan run on the scraper image's export. Full suites run only in GitHub CI (owner rule). RED ON THE BASE: test-release-images.py against the base's deploy.yml fails on each missing job, need, guard and .env.release field, each with its own sentence; the scan's test over fixture roots written at run time fails on the base because the scan does not exist, and on the branch fails a root holding node_modules/@supabase/auth-js, a root whose shipped file names @supabase/storage-js, and a root whose pm2 is at another version, and passes a clean root; the new cases of test-rollback-images.sh fail against the base's rollback.sh (it resolves two images and writes two digests). GUARD: import-gate.test.mjs passes unchanged in what it asserts, reading the list from the new module; image-frontend-gate.py passes with the two new build-push steps; git diff --name-only <base> -- services/transcriber infra/compose is empty; the rollback of a commit before this card still resolves two images and writes a release the old compose files render; python3 infra/scripts/workflow-timeouts.py check . green with the new entries and each timeout-minutes equal to its derived value; the record lists every changed job of (7) with its treatment (measured with event, run id and head SHA, estimated with its sentence, or a stand-in with its reason), and no floor is taken from a run that did not end in success. Before merge, the record carries the successful dispatch run's scan output and both digests. The first deploy of the merge commit on main (build-scraper passing the scan, verify-tags printing ok for four images, and the staging box's .env.release holding four digests equal to the four build outputs) is read and recorded by A2g, so this card's branch has no commit after its merge. The whole ci aggregate green on the pull request, with infra and scraper among the green jobs. Record docs/ops/a2f-acceptance.md.
Out of scope. Any compose service for either image, and running either image on a box, which is A2d. Any change to services/transcriber, which is a locked mirror; a non-root user or a health tool inside the transcriber image belongs to its merge slice. Pushing a latest tag that anything reads at deploy time. A build matrix. A registry retention policy for the new images. Moving the other sidecars into this pipeline.
Waits on / Unblocks. Waits on nothing open: the build pipeline, A9's import gate and the scraper's merge slice are all on main at 78b90301. It shares docs/CI.md with A43 and A2d and deploy.yml with A2d; whichever of two cards on a shared surface merges second is re-tested on the merge commit. Unblocks A2d, which interpolates the digests this card publishes and does not start before this card merges, and A2g, which replaces this card's verify-tags estimate with the first successful four-image run on main.
A2d · Scraper engine + transcriber as stack services with hard limits · A · L · S2-W3 · feat/a/a2d-engine-and-transcriber-in-the-stack
Context. Measured at main 78b90301. The two Intelligence services are not in either stack yet. infra/compose/compose.staging.yml (201 lines, project skynet-staging at :10) runs caddy (:13-29, the only published port, 127.0.0.1:38001:80 at :16, the staging gateway, which routes to the api alone), api (:31-79, env_file ../../.env.staging, a Python urllib health check at :68-73, 6 CPU / 12 GiB), postgres (:81-114, max_connections=80 at :94, 4 / 12 GiB), migrate (:116-132, profiles: ["ops"] at :125), backup (:140-162) and synthetic (:171-188), on the external network skynet-staging-net (:198-201); compose.prod.yml is the same stack on 127.0.0.1:38000:80 (:20-21), and its header (:1-3) says "no sidecars". The steady-state sum is 12 CPU / 26 GiB. The caps are already decided and live in the checker: infra/scripts/compose-limits.py holds the tenant budget of 16 CPU / 42 GiB (:35-40) and RESERVED (:47-50): scraper-engine 2 CPU / 4 GiB (service names scraper-engine and engine) and transcriber 2 CPU / 6 GiB. It counts a reserved service in the budget and in prod-staging parity only when both files carry it (_reserved_in_both, :147-173), requires restart: unless-stopped for it (:206-208), a mem_reservation below the limit (:111) and memswap_limit equal to it (:113), compares shm_size and restart between the files (_limit_block, :176-183), and passes today with "0 of 2 reserved services present". With both services in, the CPU budget is used exactly: 12 + 2 + 2 = 16 of 16, and memory is 36 of 42 GiB (docs/ops/RESOURCE_LIMITS.md:36-47). ADR-020 (docs/decisions/ADR-020-intelligence-data-path.md:847-852) records the caps and names this card as the change that enforces them. The scraper image runs two processes from one entry: services/scraper/Dockerfile ends in ENTRYPOINT ["dumb-init", "--"] and CMD ["/app/start.sh"] (:158-159), exposes 8080 and 3000 (:150), has curl (:75-83) and a HEALTHCHECK on localhost:8080/health (:153-154); ecosystem.config.cjs is copied but unused. start.sh (98 lines) starts the engine (PORT=8080 exec node server.js &, :55) and the admin (:64), waits with wait -n (:80-85), kills the other (:89) and exits 1 when a service ended with 0 (:94-98); it has no TERM trap, which A54 and A56 left to this card (docs/ops/a54-acceptance.md:148, docs/ops/a56-acceptance.md:100), and services/scraper/test/start-sh.test.mjs already has an admin stand-in that ignores TERM (:57-75) and a SIGTERM case (:318-322). server.js answers /health at :258-264 without a query, and start() opens the data path, then calls registerSchedules() (:2400-2412, eleven timers) at :2423 with no switch, then listens at :2425. The data path refuses to start without DATABASE_URL, WORKSPACE_SLUG or STORAGE_PROVIDER (services/scraper/packages/db/data-path.cjs:53, :62, :90); the engine pool is 8 backends and a child's is 2, half and half of a 16-backend budget for the image (lib/data-path.cjs:24-34). The engine also reads TRANSCRIBER_URL (lib/transcription/client.cjs:11), R2_*, SCRAPER_API_KEY, INTEGRATION_API_KEY, OPENROUTER_API_KEY, MARKET_INTEL_* and DEFAULT_PROXY_GEO; the residential proxy is read from meta_ads.settings, which tools/seed-residential-settings.cjs:38-43 seeds from DECODO_RES_GATEWAY, DECODO_RES_PORT, DECODO_RES_USER, DECODO_RES_PASS, DECODO_RES_TEMPLATE, DECODO_RES_SESSION_MIN and DECODO_RES_COUNTRY; DECODO_AUTH_TOKEN is in services/scraper/.env.example:29. The admin reads SCRAPER_URL, SCRAPER_API_KEY, JWT_SECRET and R2_*, and JWT_SECRET is also the api's key in the stack env (infra/env/staging.env.example:31), so the engine cannot take the stack env file whole. None of these names is in infra/deploy/required-keys.txt or the env templates. The transcriber image (services/transcriber/Dockerfile, python:3.12-slim, HF_HOME=/models, port 9010, no curl) answers /healthz (app.py:56-63) and sizes its threads from WHISPER_CPU_THREADS, 0 meaning every host core (app.py:25); it is still a locked mirror (AGENTS.md:22). The database roles: A9a declares the application-role identity, SKYNET_APPLICATION_DB_ROLE, and did not provision a non-superuser or non-owner role on staging. On staging that role is skynet, the DATABASE_URL user (infra/env/staging.env.example:18-27, docs/MIGRATIONS.md:126), which is the server's bootstrap superuser (docs/runbooks/META_ADS_MIGRATION.md:450-456); meta_ads is applied and replaced by the migration role skynet_a9_migrate (:249-250), which is not the application role (:22); production's role is decided at the go-live (docs/MIGRATIONS.md:127), and X7 owns that split; ADR-023 makes the application layer the boundary with one application role; and A9's record leaves which role the engine connects as on staging to this card (docs/ops/a9-acceptance.md:2800-2808). So staging can prove which role the engine connects as and that the role owns no relevant object, but it cannot prove a least-privilege boundary, because a superuser passes every grant. The architecture design's resource table (development-processes/skynet/final/skynet-architecture-and-deployment-design.md, § 9, header at :120) is behind in both Intelligence rows: row :128 (scraper-engine) says "the competitor-ads engine only, no Next admin", which is not true because the admin runs in the same container, and both :128 and :129 (transcriber) carry "below the limit, set by A2d" in mem_reservation, "set by A2d" in pids_limit, and "lands in the compose files with A2d" or "lands with A2d" in Notes. The deploy: deploy-staging (.github/workflows/deploy.yml:173-357) builds COMPOSE at :333, pulls, migrates, seeds, runs up -d and waits for 127.0.0.1:38001/api/healthz alone; deploy-prod (:382-556) runs the same on project skynet (:507). The CI render (ci.yml:636-652) and the box preflight (infra/deploy/preflight.sh:74-75) render with --profile ops only. docs/runbooks/STAGING.md:290 says staging runs "no scrapers or agents", and docs/GO_LIVE.md:107 (row 3) and :115 (row 10b, P-1) say the engine and the transcriber start disabled in production until P-1 passes. The images and their digests come from A2f: SCRAPER_IMAGE_DIGEST and TRANSCRIBER_IMAGE_DIGEST in .env.release.
Read first. infra/compose/compose.staging.yml and compose.prod.yml whole files; infra/scripts/compose-limits.py whole file and infra/scripts/test-compose-limits.py; docs/ops/RESOURCE_LIMITS.md:1-50; ADR-020 :840-860 and ADR-023; services/scraper/Dockerfile, start.sh, test/start-sh.test.mjs, server.js:255-266 and :2395-2430, lib/data-path.cjs, services/scraper/packages/db/data-path.cjs:40-100, tools/seed-residential-settings.cjs, lib/deep-crawl/session.cjs, lib/transcription/client.cjs, .env.example and docker-compose.yml (the old single container); services/transcriber/Dockerfile and app.py; .github/workflows/deploy.yml:173-357 and :382-556, infra/deploy/rollback.sh, infra/deploy/preflight.sh, .github/workflows/ci.yml:553-700; infra/env/staging.env.example, infra/env/prod.env.example, infra/deploy/required-keys.txt; docs/runbooks/META_ADS_MIGRATION.md § 7 (the staging proof, the freeze at :430-446, the fence at :448-460, the role preflight at :502-520) and docs/runbooks/INTELLIGENCE_DATA_MIGRATION.md § 5a; docs/ops/a9-acceptance.md:2800-2808 and :3557-3566; docs/runbooks/STAGING.md:280-300; docs/GO_LIVE.md:107 and :115; infra/smoke/pressure.sh:60-80; docs/CI.md:75-120; A2f's card and record docs/ops/a2f-acceptance.md; the old stack's /opt/meta-ads/docker-compose.yml on the rescale box, read by the owner at the gate.
Deliverables. (1) Both compose files gain two services under one profile, profiles: ["intelligence"]: scraper-engine, image ghcr.io/rescale-media/skynet-scraper@${SCRAPER_IMAGE_DIGEST:?set by .env.release}, running the image's own entry (engine and admin in one container), cpus: 2, mem_limit: 4g, memswap_limit: 4g, mem_reservation: 2g, pids_limit: 1024, shm_size: 1g, stop_grace_period: 30s, restart: unless-stopped; and transcriber, image ghcr.io/rescale-media/skynet-transcriber@${TRANSCRIBER_IMAGE_DIGEST:?set by .env.release}, cpus: 2, mem_limit: 6g, memswap_limit: 6g, mem_reservation: 3g, pids_limit: 256, WHISPER_CPU_THREADS=2, a named volume at /models, restart: unless-stopped. Neither publishes a port; each is on the stack's own network only, and the engine reaches the transcriber at TRANSCRIBER_URL=http://transcriber:9010. Health checks run inside each container: the engine curl -sf http://127.0.0.1:8080/health and curl -s -o /dev/null http://127.0.0.1:3000/; the transcriber python -c with urllib.request.urlopen('http://127.0.0.1:9010/healthz', timeout=5). The header comments of both files stop saying "no sidecars" and state the profile rule. (2) compose-limits.py: a reserved service must be present in both files (the passing line reads "2 of 2 reserved services present"), must carry the intelligence profile, and must publish no port; each is refused with its own sentence. The comment at :42-46 stops saying the caps are held "before the compose files carry them". RESERVED's numbers do not change. (3) The CI render (ci.yml:643-651) and the box preflight (preflight.sh:74-75) render with --profile ops --profile intelligence, so the checker and the host check see the whole budget. (4) The profile is started on staging and never in production. deploy-staging passes --profile intelligence to pull and up -d, and its health wait also waits for both services to report healthy within a bound of 240 s, printing compose ps and each service's last log lines on a miss. deploy-prod passes no intelligence profile, and rollback.sh passes it only for the staging stack and only when the target commit's compose file defines it. X7 starts the profile in production after P-1. (5) The scheduler switch, a second lock: server.js calls registerSchedules() only when ENGINE_SCHEDULER_ENABLED is exactly true, and logs one line saying which way it went; any other value, unset included, registers nothing and the engine still serves HTTP. The switch is absent from the production env template and set in no Git file. (6) The TERM trap in start.sh: on TERM or INT it forwards TERM to both services, waits for both up to STOP_GRACE_SECONDS (default 20, below the 30 s grace period), sends KILL to any service still running at the bound, and exits 0 when both ended within the bound and 1, naming the service it killed, when it had to kill one. A service that ends on its own still ends the container as today (:94-98). (7) The engine's environment is an explicit list, never the stack env file whole. The deploy writes .env.intelligence (mode 600, like .env.synthetic at deploy.yml:327-329) from the stack env by one script, infra/deploy/intelligence-env.sh, used by deploy-staging, deploy-prod and rollback.sh, holding a fixed key list: the engine's and the admin's keys named in Context, the proxy and Decodo names below, and JWT_SECRET written from the stack env's SCRAPER_ADMIN_JWT_SECRET, so the admin never holds the api's secret. The script refuses when the DATABASE_URL it writes has any user but SKYNET_APPLICATION_DB_ROLE, or has the migration role's name (skynet_a9_migrate, or SKYNET_MIGRATION_DB_ROLE when the env declares one), so the engine connects as the declared application role and never as the migration role; that it owns no meta_ads object is proved on staging (Acceptance). (8) The env contract gains names only: DECODO_RES_GATEWAY, DECODO_RES_PORT, DECODO_RES_USER, DECODO_RES_PASS, DECODO_RES_TEMPLATE, DECODO_RES_SESSION_MIN, DECODO_RES_COUNTRY, DECODO_AUTH_TOKEN, DEFAULT_PROXY_GEO and SCRAPER_ADMIN_JWT_SECRET go into the key list of (7) and into infra/env/staging.env.example and infra/env/prod.env.example with empty values and one comment each. No value and no default for any of them goes into Git. They do not join required-keys.txt, which gates every deploy including production's, where the profile is off. The owner supplies the values at the staging gate, after A9's rotation of the proxy password and the model-router key. (9) Docs: docs/ops/RESOURCE_LIMITS.md drops the exception paragraph (:15-18) and shows the two rows as running limits in both files, with the CPU sum stated as exactly 16 of 16 and memory as 36 of 42 GiB; docs/runbooks/STAGING.md:290 states that staging runs the engine and the transcriber with the scheduler off except in a proof window; docs/runbooks/DEPLOY.md states the profile rule, the env file and the scheduler switch; docs/CI.md states the render's two profiles; and both Intelligence rows of the resource table in development-processes/skynet/final/skynet-architecture-and-deployment-design.md (§ 9) are corrected cell by cell. Row :128 (scraper-engine): mem_reservation "below the limit, set by A2d" becomes 2 GB; pids_limit "set by A2d" becomes 1024; in Notes, "the competitor-ads engine only, no Next admin" becomes "the competitor-ads engine and its admin, in one container that publishes no port", and "lands in the compose files with A2d" becomes "in both compose files, under the intelligence profile, with no published port". Row :129 (transcriber): mem_reservation becomes 3 GB; pids_limit becomes 256; in Notes, "lands with A2d" becomes "in both compose files, under the intelligence profile, with no published port". The cpus and mem_limit cells, the reservation date and the enforcement sentence stay. (10) Floors by the rule at docs/CI.md:75-87. The infra job gains test cases and steps, so it is re-measured from the pull request's own successful ci run, with the run id and head SHA in the record. deploy-staging changes shape (the intelligence profile on pull and up -d, a first pull of both images, the 240 s health wait for two more services, and the write of .env.intelligence), and it runs on main alone, so no run of the new shape can exist before the merge. Its entry becomes a bounded estimate: A2f's estimate (59 s, run 35510105388) plus the pull of both images at their compressed sizes as the registry reports them for A2f's digests, at a stated conservative transfer rate, plus the 240 s health bound, plus the .env.intelligence write; the estimate sentence names this card's record, where the sum is shown term by term, and names A2g as the card that replaces it with a measured run. The derived timeout-minutes, the docs/CI.md row and python3 infra/scripts/workflow-timeouts.py check . land in the same commit. deploy-prod's shape does not change (no profile), and its stand-in stays. (11) Record docs/ops/a2d-acceptance.md, written before the merge and never after it: the local and CI evidence, the estimate's terms, and a staging-readiness section that lists what A2g needs and in what state it stands at the merge (A2f merged and its digests in .env.release; A9's meta_ads present on staging; the owner's values for the new env names, after A9's rotation of the proxy password and the model-router key; ENGINE_SCHEDULER_ENABLED unset; the exact commands A2g runs). Added at A2f's review: the comment at infra/compose/compose.prod.yml:6, which names only the api and web digest fields, names the four digest fields that .env.release carries once this card adds the two services. Amended at review. (12) Staging runs the profile only behind the switch STAGING_INTELLIGENCE_ENABLED in the staging stack env, on only for exactly true and off at merge; A2g turns it on for its proof window once the owner has set the values. Off, deploy-staging and rollback.sh write no .env.intelligence, pass no profile, wait for nothing and stop (never remove) a running engine or transcriber with infra/deploy/intelligence-stop.sh; on, intelligence-env.sh --check refuses before the preflight and any compose command when a required key is absent. (13) One R2 identity: R2_ENDPOINT_URL (https://<32 hex>.r2.cloudflarestorage.com) is the single source and the engine's R2_ACCOUNT_ID is derived from it; another host, a path, an EU endpoint, a stack R2_ACCOUNT_ID naming another account or one without an endpoint is refused. (14) The key list is no longer "ten names": the engine env takes 24 copied keys plus two written ones (R2_ACCOUNT_ID derived, JWT_SECRET from SCRAPER_ADMIN_JWT_SECRET), under the contract table in docs/runbooks/DEPLOY.md that classes every name as required or optional, gives its source or default and marks the names only A2g's proof needs; nine keys are required, MARKET_INTEL_AUTHOR_SALT only when MARKET_INTEL_ENABLED=true, and DECODO_AUTH_TOKEN is dropped because nothing reads it. (15) The deploy-staging estimate is 505 s, the on-path maximum (the off path is about 59 s); its timeout is 44 minutes by term (b), 1800 s SSH command timeout + 505 + 300 = 2605 s, and it holds while the estimate stays at or below 540 s. A2g replaces the estimate with a measured run.
Interfaces. Exposes the services scraper-engine and transcriber in both stacks under the intelligence profile, the switch ENGINE_SCHEDULER_ENABLED, the staging switch STAGING_INTELLIGENCE_ENABLED and infra/deploy/intelligence-stop.sh, the grace setting STOP_GRACE_SECONDS, the deploy-written .env.intelligence and its key list, and the new env names. Consumes A2f's two images and the SCRAPER_IMAGE_DIGEST and TRANSCRIBER_IMAGE_DIGEST release fields, the caps in compose-limits.py:47-50, the application role of apps/api/migrate.py and A9's meta_ads on staging. Unchanged: every cap number, the budget, every existing service and its limits, the published ports (38001 staging, 38000 production), the api's env file and JWT_SECRET, required-keys.txt, every byte of services/transcriber, what production runs, and the old stack at /opt/meta-ads, which stays scheduled and authoritative until X7.
Acceptance. Locally the builder runs only targeted tests, red-on-base proofs, generators and fast static checks: python3 infra/scripts/test-compose-limits.py, the two renders of (3) with compose-limits.py check, the new intelligence-env.sh test, python3 infra/scripts/workflow-timeouts.py check ., in services/scraper node --test test/start-sh.test.mjs and the switch's test, and bash infra/deploy/test-rollback-images.sh. Full suites run only in GitHub CI (owner rule). RED ON THE BASE: test-compose-limits.py's new cases fail against the base checker (a reserved service with a port, without the profile, or in one file only, each passes the base); the switch's test fails on the base, where ENGINE_SCHEDULER_ENABLED unset still registers the eleven timers; the new start-sh.test.mjs cases fail on the base: TERM with both services stopping promptly does not exit 0, and TERM with the admin ignoring it does not end within STOP_GRACE_SECONDS plus two seconds with exit 1; the intelligence-env.sh test fails on the base because the script does not exist. GUARD: the renders of both files pass compose-limits.py check with "2 of 2 reserved services present" and the budget respected at 16 of 16 CPU; docker compose config of compose.prod.yml without the profile lists neither service; deploy-prod's script names no intelligence profile, proved by a test over deploy.yml; intelligence-env.sh refuses a DATABASE_URL whose user is not the application role, refuses skynet_a9_migrate, writes JWT_SECRET from SCRAPER_ADMIN_JWT_SECRET and never the api's, and writes no key outside its list; git grep finds no value for any new env name in the tree; git diff --name-only <base> -- services/transcriber is empty; after the card, grep -nE 'no Next admin|set by A2d' development-processes/skynet/final/skynet-architecture-and-deployment-design.md returns nothing, and rows :128 and :129 carry the mem_reservation and pids_limit values that the two compose files carry. In CI the whole ci aggregate is green on the pull request, with infra and scraper among the green jobs. No step of this card's acceptance runs on staging and no commit lands on its branch after the merge: the staging proof and the measured deploy-staging floor are A2g's. workflow-timeouts.py check . green with the deploy-staging estimate and the re-measured infra entry. Record docs/ops/a2d-acceptance.md with its staging-readiness section.
Out of scope. Starting the profile or the scheduler in production, the final source switch and the old stack's retirement, which are X7's after P-1 (docs/GO_LIVE.md:115). Dual writes of any kind. Any cap, the budget or RESERVED's numbers, which are decided. Splitting the engine and the admin into two containers. A published port or a gateway route to either service, and any /api/intelligence route in the api. A non-root user or a new health tool in the transcriber image, which is a locked mirror. Building or publishing the images, which is A2f. Production's database role split, which is X7's. Provisioning a non-superuser or non-owner role on staging. The staging proof, the parity proof, the pressure sample and the measured deploy-staging floor, which are A2g's.
Waits on / Unblocks. Waits on A2f, which publishes and pins both images and merges before this card starts, and on A9, merged. It shares deploy.yml and docs/CI.md with A2f and services/scraper and docs/CI.md with A43; whichever of two cards on a shared surface merges second is re-tested on the merge commit. Unblocks A2g, the staging verification that runs after this card merges and its deploy reaches staging, and through it X7's activation of the Intelligence services after P-1, and every card that runs the engine on staging.
A2g · The engine and the transcriber are proved on staging, and the deploy floors are measured · A · M · S2-W3 · test/a/a2g-intelligence-proved-on-staging (surfaced while re-measuring A2d; carded 2026-09-24)
Context. Measured at main 78b90301. A2f publishes and pins the skynet-scraper and skynet-transcriber images, and A2d puts both services into both compose files under the intelligence profile, which only deploy-staging starts. Neither card can prove its work on staging before it merges, because deploy-staging runs on main alone (.github/workflows/deploy.yml:178, if: vars.DEPLOY_STAGING_ENABLED == 'true' && github.ref == 'refs/heads/main'), and neither card's branch takes a commit after its merge. This card is where that proof and those floors land. What it proves is what the two cards could not: the unified-runtime staging proof A9 handed over (the engine and the admin give the same browse, intel and research answers as the old stack on a controlled same-day snapshot, and the proof exercises scheduler state, watermarks, retries, queue leases and idempotency), and the container-level facts of A2d's deliverables (no published port, health inside the container, the configured role, the bounded stop, the caps under load). The floors it writes: A2f leaves verify-tags as an estimate (76 s, the two-image run 35498945579 doubled for four look-ups) because a branch run fails on purpose at the missing main- tag, and A2d leaves deploy-staging as a bounded estimate because no run of its new shape can exist before the merge; infra/scripts/workflow-timeouts.json holds both with an estimate sentence naming the records docs/ops/a2f-acceptance.md and docs/ops/a2d-acceptance.md, and the rule at docs/CI.md:75-87 says an estimate stays until a run of the target shape measures it. The role facts: A9a declares the application-role identity, SKYNET_APPLICATION_DB_ROLE, and did not provision a non-superuser or non-owner role on staging; on staging that role is skynet (docs/MIGRATIONS.md:126), the server's bootstrap superuser (docs/runbooks/META_ADS_MIGRATION.md:450-456), and meta_ads is applied by the migration role skynet_a9_migrate (:249-250). So staging can prove which role the engine connects as and that it owns no relevant object, and it cannot prove a least-privilege boundary; production's role split is X7's (docs/MIGRATIONS.md:127). The old stack at /opt/meta-ads stays scheduled and authoritative until X7 (docs/GO_LIVE.md:115).
Read first. docs/ops/a2d-acceptance.md whole file, in particular its staging-readiness section and the deploy-staging estimate's terms; docs/ops/a2f-acceptance.md (the dispatch run, the scan output, the verify-tags estimate); A2d's and A2f's cards; infra/compose/compose.staging.yml as A2d leaves it; .github/workflows/deploy.yml:133-357; infra/scripts/workflow-timeouts.json (the deploy.yml entries); docs/CI.md:75-87 and :112-120; docs/runbooks/META_ADS_MIGRATION.md § 7 (the staging proof, the freeze at :430-446, the fence at :448-460) and docs/runbooks/INTELLIGENCE_DATA_MIGRATION.md § 5a; docs/ops/a9-acceptance.md:2800-2808; docs/runbooks/STAGING.md as A2d leaves it; infra/smoke/pressure.sh:60-80; services/scraper/tools/seed-residential-settings.cjs; docs/GO_LIVE.md:107 and :115.
Deliverables. (1) The readiness check, before anything runs: every item of docs/ops/a2d-acceptance.md's staging-readiness section is read on the box and recorded with its state (A2f and A2d merged and deployed; the four digests in .env.release equal the build outputs of the deploy run that wrote them; A9's meta_ads present on staging; the owner's values for the new env names set, after A9's rotation of the proxy password and the model-router key; ENGINE_SCHEDULER_ENABLED unset). A missing item stops the card with the item named. (2) The release evidence A2f's card hands over, read from the first deploy run on main after A2f merged: build-scraper passed the image scan, verify-tags printed ok for four images, and the box's .env.release holds four digests equal to the four build outputs. (3) The container proof, from the first deploy of A2d's merge commit or a later one: docker compose -p skynet-staging --profile intelligence ps shows scraper-engine and transcriber healthy; docker port shows no published port for either; the engine's /health, the admin on port 3000 and the transcriber's /healthz answer from docker compose exec inside the containers and from another container on skynet-staging-net, and no route of 127.0.0.1:38001 reaches either; the engine logs that no timer was registered while ENGINE_SCHEDULER_ENABLED is unset. (4) The role proof: in the engine, select session_user, current_user returns skynet, the configured SKYNET_APPLICATION_DB_ROLE, for both, never skynet_a9_migrate; the count of meta_ads relations, sequences and routines owned by session_user is 0. The record states, beside the result, that skynet is the bootstrap superuser, so this proves the configured identity and the ownership split and does NOT prove a least-privilege boundary, which on staging is the application layer's (ADR-023), and that production's role split is X7's. (5) The bounded stop: docker compose -p skynet-staging stop scraper-engine ends within 30 s with exit status 0, never 137, and the log shows both services stopped; the service is started again and reaches healthy. (6) The parity proof: the residential settings are seeded in the engine container with node tools/seed-residential-settings.cjs from the owner's values; on a controlled same-day snapshot, inside the scheduler window of deliverable (8), the engine and the admin give the same browse, intel and research answers as the old stack, and the proof exercises scheduler state, watermarks, retries, queue leases and idempotency. The record names the snapshot, the request list, the commands, the normalisation of volatile fields and each difference with its cause. (7) The pressure sample: during the proof, bash infra/smoke/pressure.sh in its no-spend mode (no --i-accept-the-spend) samples the stack; each service stays within its cap, docker inspect shows OOMKilled=false for both, and the peak of pg_stat_activity over the proof, by application name, stays under max_connections=80 less the reserved slots. (8) The scheduler window, with evidence at each edge read on the box. Before the run: ENGINE_SCHEDULER_ENABLED is absent from the engine's environment (docker compose -p skynet-staging exec scraper-engine printenv ENGINE_SCHEDULER_ENABLED exits 1 and prints nothing) and the engine's start log carries the line that no timer was registered. Open: the owner sets ENGINE_SCHEDULER_ENABLED=true in the staging env secret and a deploy of main runs; the record names that run, the printenv output true, the start log line that the timers were registered, and the UTC time the window opened. Close: the owner removes the value and a second deploy of main runs; the record names that run, the printenv result (exit 1, nothing printed), the start log line that no timer was registered, and the UTC time the window closed. The parity proof of (6) runs only between the two times, and no other deploy of staging runs inside the window. (9) The failure-safe outcome. If any of (2) to (8) fails, before the remediation card is opened: the intelligence profile is stopped (docker compose -p skynet-staging --profile intelligence stop scraper-engine transcriber, then ps showing both exited); the scheduler switch is returned to its state before the run, disabled, by removing the value from the staging env secret, and shown by the engine's environment and log as in (8) when the engine is next started; every other staging service (caddy, api, postgres, backup, synthetic) is shown healthy or running by docker compose -p skynet-staging ps, and curl -sf http://127.0.0.1:38001/api/healthz passes on the box; and the record captures that cleanup with each command and its output, the failed proof, and its cause as far as it is known. Only then is the remediation card opened, and this card stays open until that card merges and the failed proof passes. (10) The floors, by the rule at docs/CI.md:75-87: deploy-staging's estimate is replaced by the largest measured duration among the successful deploy-staging runs on main of A2d's shape read for this card, with its run_id; verify-tags's estimate is replaced the same way from the successful four-image runs on main. Each entry drops its estimate sentence, carries max_seconds and run_id, and the derived timeout-minutes in deploy.yml and the docs/CI.md row move with it in the same commit; where the measured value is below the estimate, the record says the estimate is gone and why, and no mark is kept for a number nobody measured. (11) Record docs/ops/a2g-acceptance.md. This card's branch holds the record and the floor edit, and nothing else.
Interfaces. Exposes the measured deploy-staging and verify-tags entries in infra/scripts/workflow-timeouts.json, their derived timeout-minutes in deploy.yml, their docs/CI.md rows, and the record. Consumes A2f's images and release evidence, A2d's services, switch, env file, stop trap and readiness record, the staging box, and the owner's values. Unchanged: every compose file, every script, every cap, the intelligence profile's absence from production, the scheduler switch's default, and the old stack.
Acceptance. Locally the builder runs only fast static checks: python3 infra/scripts/workflow-timeouts.py check . green with the two measured entries, and each timeout-minutes equal to its derived value. Full suites run only in GitHub CI (owner rule). RED ON THE BASE: on the base, workflow-timeouts.json holds deploy-staging and verify-tags as estimates with no run_id, and the check's derivation table shows each as an estimate; on the branch both show a run_id of a successful run on main. GUARD: git diff --name-only <base> holds only infra/scripts/workflow-timeouts.json, .github/workflows/deploy.yml (the two timeout-minutes values alone), docs/CI.md (the two rows alone) and docs/ops/a2g-acceptance.md; every run id in the record opens a run that ended in success on main; the record carries each of (1) to (8) with its command and its output, and the least-privilege statement of (4) word for word; the record shows the scheduler disabled before the window, enabled only between the two named times, and disabled after it, each edge with its deploy run, its printenv result and its log line; at the end ENGINE_SCHEDULER_ENABLED is absent from the engine's environment on the box. In CI the whole ci aggregate is green on the pull request. If any of (2) to (8) fails, the cleanup of (9) is done and recorded before the remediation card is opened, and this card stays open. Record docs/ops/a2g-acceptance.md.
Out of scope. Any change to the compose files, the engine, the admin, the transcriber, the deploy scripts or the env writer, which are A2d's and A2f's; a fix a failed proof needs becomes its own card. Starting the profile or the scheduler in production, the final source switch and the old stack's retirement, which are X7's. Provisioning a non-superuser or non-owner role on staging, and production's role split, which is X7's. Dual writes. Any floor other than deploy-staging and verify-tags.
Waits on / Unblocks. Waits on A2d merged and its main-only deploy reaching staging, and through it on A2f; the readiness items of deliverable (1) are read at the start. It shares infra/scripts/workflow-timeouts.json, deploy.yml and docs/CI.md with any card that changes a deploy.yml job; whichever merges second is re-tested on the merge commit. Unblocks X7's activation of the Intelligence services after P-1, and every card that runs the engine on staging.
Amended at review (2026-09-24). Split. Deliverables (6) parity, (7) pressure, (8) the scheduler window and (9) the failure-safe run only inside the scheduler window, and their inputs do not exist yet: a same-day snapshot of the old stack (a dump of the hosted Supabase project, owner only), a second A9 L-F full-replacement load on staging under STAGING_DEPLOY_FROZEN, admin logins on both sides (staging meta_ads.admin_users is empty), and the owner's acceptance of the model and proxy spend while the scheduler runs. They move whole to A2h. This card merges with (1) to (5) and the two measured floors: verify-tags from push run 35988279326 and deploy-staging from workflow_dispatch run 35988686240 on refs/heads/main, the first run with the intelligence profile on. The guard is widened to the docs/CI.md prose lines that named the two entries as estimates. Deliverable (2) reads the release evidence from run 35953671876; that run's .env.release was overwritten by later deploys, so the digest equality is read against the dispatch run. The record states that (6) to (9) were not run here and carries the step-(8) plan as the hand-over to A2h.
A2h · Parity and the scheduler window are proved on staging · A · M · S2-W3 · test/a/a2h-parity-and-scheduler-window (split from A2g at review; carded 2026-09-24)
Context. A2g proved on staging that the engine and the transcriber run healthy, unpublished, as the configured role, and stop within bounds, and it measured the verify-tags and deploy-staging floors. The proofs that need the engine's scheduler running did not run there, because their inputs did not exist: a same-day snapshot of the old stack, a fresh full-replacement load of it into staging, admin logins on both sides, and the owner's acceptance of the spend while the scheduler runs. This card runs them, inside one bounded scheduler window, and leaves staging with the scheduler off. The old stack stays scheduled and authoritative until X7.
Read first. A2g's card and docs/ops/a2g-acceptance.md whole, in particular "Hand-over to A2h"; A2g's Read-first list; docs/runbooks/META_ADS_MIGRATION.md § 7 (the staging proof, the freeze, the fence) and the L-F procedure in docs/ops/a9-acceptance.md; docs/runbooks/INTELLIGENCE_DATA_MIGRATION.md § 5a; infra/smoke/pressure.sh; services/scraper/tools/seed-residential-settings.cjs; docs/CI.md:75-87.
Deliverables. (1) The owner items are present and recorded before anything runs: O1 a same-day dump of the old hosted Supabase project; O2 the owner's go for the second L-F full-replacement load under STAGING_DEPLOY_FROZEN and for the model and proxy spend while the scheduler runs; O3 an admin login on the old stack and on staging. A missing item stops the card with the item named. (2) The L-F load of O1 into staging, by the A9 procedure with its attempt raised, under the freeze, with its closed-run evidence. (3) A2g's deliverables (6) parity, (7) pressure, (8) the scheduler window and (9) the failure-safe, exactly as A2g's card words them, run by A2g's step-(8) plan: the scheduler enabled through the env secret and a workflow_dispatch deploy on main, open for at most 60 minutes, then disabled the same way, each edge with its deploy run, its printenv result and its log line. (4) If a window deploy's deploy staging job ran longer than the recorded maximum, the deploy-staging floor is re-measured from it. (5) At the end, ENGINE_SCHEDULER_ENABLED is absent from the engine's environment on the box, the freeze is off, and the scratch files are removed.
Interfaces. Consumes A2g's record and floors, the staging box, the env secret and the owner items O1 to O3. Exposes the parity and pressure evidence X7 needs, and at most a re-measured deploy-staging entry. Unchanged: every compose file, script, cap, the scheduler switch's default and the old stack.
Acceptance. Locally only fast static checks: python3 infra/scripts/workflow-timeouts.py check . green. GUARD: the diff holds only docs/ops/a2h-acceptance.md, and, when (4) applies, the deploy-staging entry, its timeout-minutes and its docs/CI.md row. Every run id in the record opens a successful run on main. The record carries A2g's (6) to (9) with commands and outputs. If any proof fails, (9)'s cleanup is done and recorded before the remediation card is opened, and this card stays open. Record docs/ops/a2h-acceptance.md.
Out of scope. Any change to the services, compose files, scripts or env writer; starting the profile or the scheduler in production; the final source switch; the old stack's retirement (all X7's or their own cards).
Waits on / Unblocks. Waits on A2g merged and on O1, O2 and O3 from the owner. Unblocks X7's activation of the Intelligence services after P-1.
Amended at A18b (2026-09-24). The staging run also records the A18b MCP proof: an MCP client against the staging engine lists the four /mcp/pubmed tools and pubmed_search returns ranked rows; the output goes in this card's record with a reference to A18b.
Amended at A13a (2026-09-24). The staging manifest is regenerated, not carried. infra/migrate/meta_ads/staging-manifest.json pins the files of the A9 staging run and does not list db/migrations/unified/meta_ads/003_competitors.sql (A13a) nor the replacement tool's generated-table module. Before anything touches staging, the preflight regenerates the manifest with python3 infra/migrate/meta_ads/staging/manifest.py generate --rev from the exact commit the A2h run deploys (the pushed SHA, after the git write-tree step in docs/runbooks/META_ADS_MIGRATION.md § 7), commits it on the card's branch, and the record states that SHA. The preflight refuses to run when the manifest is missing, when its commit is not the deployed SHA, or when its pinned migration inputs do not include every file under db/migrations/unified/meta_ads/ at that SHA, 003_competitors.sql among them. The regenerated manifest is a permitted diff line next to the record. Deliverable (2)'s L-F load runs the replacement tool with competitors and competitor_lenses generated from brands (A13a's generated.py) and counts them under generated in the closed-run evidence.
A13a · Competitors, lenses, schedules — model + migration · A · M · S2-W3
Context. The functional design's model: competitors are workspace entities linked to products, each link carrying market/locale settings; lenses = Ads-Library page, keyword query, domain; schedules per link.
Read first. Functional design §3.0, §3.1; brands table + is_research_lens; admin/components/{AddBrandForm,BrandScheduleCard}.tsx; scheduler cron (server.js:1516); D21 rules.
Deliverables. Migration meta_ads/002_competitors.sql: competitors(id, workspace_id, name, domain, notes), competitor_lenses(competitor_id, kind ∈ page|keyword|domain, value, country), product_competitors(product_id, competitor_id, market, locale, auto_recreate_rule jsonb, alert_rule jsonb); data migration brands → competitors + lenses (1:1, a compatibility view for the engine); the scheduler reads product_competitors (interval per link); pytest apps/api/tests/intelligence/test_competitor_product_mapping.py.
Acceptance. Test green with one product ↔ three competitors, distinct lenses/schedules; migration verify (brand count = lens count).
Bridge impact. The lander/destination filter proven in bridge I1/I2 becomes lens semantics here (two landers per brand).
Out of scope. Alerts, auto-recreate, APIs (A13b).
Amended at D21 (2026-09-24). Per docs/discovery/s2-operator-rules.md: the cadence is stored per competitor, not per link, as two intervals, quick (default 7 days) and deep (default 30 days); the scheduler starts deep crawls on their own schedule, and an operator can override either interval for one competitor. A competitor carries a reversible status (active, retired); retire never deletes and a retired competitor keeps its history. The migration's brand→competitor step maps today's single interval to the quick interval and sets deep to 30 days.
A13b · Traction alerts + auto-recreate rules + competitor APIs · A · M · S2-W4
Read first. A13a model; D21 rules; static-ads competitor_brand_mappings, auto_expand_runner.py; Discord/Telegram senders (X3).
Deliverables. Alert job: traction rule per link (new family ≥ N days running / rank rise) → Discord/Telegram + an outbox event (inbox in S3); auto-recreate rule → Production Expand session via the S1 Expand entry; API /api/intelligence/competitors* (CRUD, link/unlink, rules); archive/merge suggestions on competitor links ("silent for N days → archive?", "same family / same library entity as an existing competitor → merge?") as explicit suggestion records, never auto-applied; alert rules create inbox/event artefacts only, never direct actions; pytest apps/api/tests/intelligence/test_competitor_rules.py.
Acceptance. Test green; one alert fired and one Expand session created on the fixture.
Amended at D21 (2026-09-24). Per docs/discovery/s2-operator-rules.md: the traction rule is a new family live ≥ 14 days, and nothing more (the owner was offered a rank-rise trigger and did not choose it, so rank rise is removed from this card). The archive suggestion is on families: "silent" means 90 days since the last positive observation of the family from a successful scan, and a failed or missing scan never makes a family silent; the suggestion is accept-only and never deletes, and archived families stay searchable. The merge suggestion stays a suggestion. Permissions: researcher and strategist hold intelligence.competitors.write for additive edits; a new intelligence.competitors.retire is granted to admins only and drives the reversible retire status change; routes authorize by permission, never by role name.
V22 · Intelligence native pages, group 1 · V · L · S2-W3
Read first. Appendix A.1 rows for brands, browse, families, transcripts, intel, gold, settings, tools; the old pages /brands*, /ads, /intel, /intel/gold, /settings, /users; the A9 API; A13 model.
Deliverables. apps/web/src/intelligence/: Competitors list + detail (lenses, schedules, metrics, families), Ads browse (label filters, status, span, collation, family collapse, running-now, video/transcript, sorts, tags, signed media), Intel dashboards (coverage, label distributions, compare), Gold labelling (blind item, label, run evaluation), Settings (models, cost caps, residential, proxies), Tools (manual runs: scrape, crawl, classify, transcribe; backfill family keys; classifier calibration; keyword seeding helpers; probe runs), Rank page (/rank + history, freshness diff, rank snapshots), an Intelligence users view that deep-links to Skynet core membership management (login = core auth; the fold is explicit in the checklist). Auth = Skynet roles (researcher, admin); the old admin_users retired with the Next app.
Acceptance. cd apps/web && npm test -- intelligence-group1 (apps/web is not a pnpm workspace member); parity checklist docs/checklists/intelligence-group1.md with every A.1 row in scope green (screenshots), including Quick Scrape / rank, Settings/users/login and Tools.
V22b · Intelligence group 1, withheld controls · V · L · S2-W8
Read first. docs/checklists/intelligence-group1.md, its acceptance record, the legacy admin intel, gold and tool handlers under services/scraper/admin, the corpus-access boundary in docs/ops/a12b-acceptance.md, the replacement fence in infra/migrate/meta_ads/replace.py, the existing competitor-rule migration, and the engine routes and workers.
Deliverables.
- Replace the ported routine signatures that need workspace scope. Drop and recreate
ads_browseandads_browse_meta,intel_coverage,intel_label_distributions_v2,gold_sample_draw,gold_next_item, andgold_scoring_rowswithworkspace_arg. Every source, representative-ad lookup, label, transcript, classification, archive, and batch join is scoped by that argument. The browse-meta routine delegates to the scoped browse routine. Compare is two scoped distribution calls, never a post-filtered corpus result.
- Replace the workspace-sensitive enqueue routines for classification and transcription. Each accepts
workspace_argand only creates queue rows for that workspace.
- Apply the replaced-routine privilege pattern: revoke
PUBLIC, revoke a differently identified legacyapprole, grant the declared application role resolved by OID, and verify the resulting ACLs. Do not addSECURITY DEFINERroutines.
- Move gold actors off
meta_ads.admin_users.labeller_id,claimed_by, andcreated_byremain UUID identity columns without cross-schema foreign keys. Guarded engine routes receive the authenticated core-user subject and verify workspace membership before creating, claiming, saving, skipping, drawing, or evaluating a batch.
- Make the intel date, media, confidence, fallback, compare, gold draw, evaluate, blind-label, save, and skip controls native. The label view never returns predictions before a person saves their label.
- Make classification and transcription runs and stats workspace-scoped. A tick takes an explicit workspace; its enqueue, stale recovery, select-before-limit, claim, update, release, and stats queries all constrain that workspace. Manual routes derive it from the guarded request. The scheduler enumerates eligible workspaces and runs bounded scoped ticks, without a corpus-wide claim.
- Add guarded engine routes and native controls for family-key backfill, classifier calibration, research-keyword seeding, and market-intel sample seeding, each with bounded workspace input and output. Add guarded service-scope routes for residential and TikTok probes; keep them outside workspace pages and record why their operational result is not workspace data.
- Add every route to the fixed proxy table with its permission key. Add two-workspace isolation tests for every changed routine, route, worker claim, and scheduler invocation.
Acceptance. cd apps/web && npm test -- intelligence-group1 is extended and green. Focused scraper tests prove that no scoped routine, native route, enqueue, claim, update, stat, or scheduled tick reads or changes another workspace’s rows. The migration parity pair and ACL checks are restamped. The withheld checklist rows are green with staging screenshots. docs/ops/v22b-acceptance.md records the migration signatures, grants, route permissions, service-scope probe rationale, tests, and staging evidence.
Out of scope. Changes to the legacy admin, group-2 Intelligence pages, the core membership page, rollup repair, settings-reader repair, existing feasible browse controls, and job-attribution repair.
Origin. The native Intelligence pages currently withhold controls whose legacy implementations query the corpus, use retired user records, or let workers claim rows without a workspace. This work supplies bounded workspace routines, routes, and ticks for those controls.
Amended at the brief co-plan (2026-09-25). Two facts narrow deliverables 6 and 7: the engine serves one configured workspace (services/scraper/lib/workspace-guard.cjs, packages/db/data-path.cjs), so the scheduler runs one bounded scoped tick for that workspace per firing and does not enumerate workspaces; and TAXONOMY_SOURCE_DSN is reserved for hand-run tools (docs/runbooks/DEPLOY.md), so classifier calibration stays a CLI outside the engine, with the native control showing the command and a recorded summary only once a workspace-owned result model exists. Every named RPC caller gains workspace_arg as its first argument.
A14 · Research native backend + ranked opportunities · A · M · S2-W4
Read first. lib/research/{runner,cluster,score}.cjs, research_* tables, research_products_ranked view, admin/app/research/*, tools/seed-research-keywords.cjs; D21.
Deliverables. Research runs as Skynet jobs (same engine code over lib/db.cjs), keywords/niches per workspace, suggested keywords from tracked-competitor terms and the VOC corpus into a review queue (nothing auto-seeds), runs resumable at boot, ranked products with reasons and gaps, decision tracking on each opportunity (status, reason_code, decision_note, decided_by, decided_at, cooldown_until — default 30 days, hidden from the active view until new evidence); API /api/intelligence/research/*; pytest apps/api/tests/intelligence/test_research_native.py.
Acceptance. Test green; a staging run over a known keyword set produces the ranked list with reasons.
Amended at D21 (2026-09-24). Per docs/discovery/s2-operator-rules.md: reason_code ∈ saturated_market, cannot_source, margin_too_low, off_brand, weak_evidence (the five names of § 8, used as one enum in the CHECK, the API and the tests), and decision_note is required. The cool-down is 30 days and ends early when a newly computed score is strictly greater than the score stored with the rejection, or when the first qualifying market observation after the rejection is recorded. Keyword permissions: intelligence.research.keywords.request (strategists request keywords) and intelligence.research.keywords.review (review and decision writes only); the card seeds an additive role keyword_reviewer holding only the review permission, which an admin adds to a user as an extra role (core.workspace_member_roles holds several roles per user), so no per-user permission editing is needed.
A15 · Promote to product · A · M · S2-W5
Read first. D22; static-ads product model (routes/products.py:43,151,248,282); workos LISTS.products, Product ID; S8a bridge module (extend with POST /products create-or-link).
Deliverables. Action from a ranked opportunity, gated: the researcher recommends, a strategist approves (Mathew for pilot products); on approval create the Skynet product (name, market, brand identity skeleton, docs/looks/offer placeholders flagged incomplete, plus the accelerator's client-file fields: proof we are allowed to use, what died / dead angles, current winners), link the competitors and intel from the run, create or link the ClickUp Product card through the bridge (Product ID = naming product number from rescale_service.next_product_number), write lineage research → product, and create a product readiness checklist (researcher: niche/competitors/evidence; strategist: positioning/claims/brand truth; ops: workflow fields) that must be complete before the product can enter script generation; pytest apps/api/tests/intelligence/test_promote_to_product.py.
Acceptance. Test green; one recorded staging promotion (product id, linked competitors, ClickUp card id).
Amended at D21 (2026-09-24). Per docs/discovery/s2-operator-rules.md, the approver is a permission, not a person: the card adds intelligence.research.promote.approve, which the researcher role does not get, and the existing intelligence.research.promote now means "recommend" only. It seeds an additive role promotion_approver holding only the approve permission; an admin adds it to a user as an extra role (for example Mathew for the pilot products). Before approval, an evidence checklist is mandatory in all four items: the niche, three or more competitors, the score breakdown, and a manual sourceability attestation that records who made it and when; there is no minimum score. The readiness checklist after promotion is unchanged.
Amended at D22's draft (2026-09-24). D22's document (docs/discovery/s2-product-bridge-fields.md) fixes the fields, directions and owners this card builds against, and its eleven questions are the inputs this card waits for (questions 1, 2, 3, 4, 8, 9 and 10 block the build). Its question 1 decides when the Product ID is drawn: (a) at approval through a new bridge route, as this card says, or (b) at Testing by workos, the card created in candidate without a number and Skynet storing product_number when it exists (the default until answered); if (b) is confirmed, this card's "Product ID = naming product number from rescale_service.next_product_number" reads "the card is created without a number and Skynet stores it when workos draws it at Testing; script generation waits for it". No POST /products exists on the bridge today; this card adds create-or-link with a closed field allowlist, an idempotency key and a search by name and market. The client-file fields (proof we are allowed to use, what died / dead angles, current winners) and the readiness checklist have no schema today; this card adds them. The lineage node types for research and product do not exist today; this card adds them (the source type's name is D22's question 10). The test path is apps/api/app/backend/tests/core/test_promote_to_product.py (the apps/api/tests/intelligence/ layout does not exist).
Amended at A15's co-plan (2026-09-24). Facts settled with the brief, on D22's defaults until the confirmation replaces them: (1) the recommendation and the approval are two rows of an append-only actions table under a promotion header (updates and deletes refused), and the approval requires an open recommendation; (2) the evidence checklist is built by the server — the competitors are validated as distinct, three or more, in the workspace and linked to the run, the score breakdown is copied from the run's scores, and the sourceability attestation records the caller and the time — never accepted from the request; (3) the approval is one Python-side transaction on one connection (the product, the link records per market, the competitor links, the client-file fields, the readiness items, the lineage nodes and edge through a no-event path, the decision, exactly one event), with no object-store write inside it, and the bridge is called afterwards: a bridge failure, timeout, absent route or "card exists" leaves the link record withheld and is resolved by a retry or a link action, never by repeating the approval; (4) the three client-file fields are rows in Postgres, audited; "incomplete" is an open derived readiness item computed from the data by a database function and triggers (brand identity, looks, offer, documents, the three fields, the product number), next to the attested items each role closes; the script-generation gate is a service function until S15 calls it; (5) the public brand-identity update no longer accepts the product number: the card refresh through the bridge is its only writer; (6) every grant to the application role is by OID in all three schemas, proved by a declared-role test; (7) the workos routes POST /products, GET /products and GET /products/:task_id carry a closed field allowlist, a claim-based idempotency record with reconciliation, run-time option ids, a real email-to-user resolver, and draw no number; they ship as a separate workos PR merged by Sven, while Skynet's client treats an absent route as withheld; the contract is a canonical Skynet schema with a byte-identical workos copy and lock files, compared at the review gate; (8) the build starts when D22's questions 1, 2, 3, 4, 8, 9 and 10 are answered; (9) the card's recorded staging promotion is made through V15b's pages and recorded in V15b's record with a reference to this card.
A12a · Teardown native jobs + artefact pages · A · M · S2-W5
Read first. lib/teardown/{worker,prompts}.cjs, ad_teardowns, enqueue_brand_teardowns, /breakdowns.
Deliverables. Teardown per family + brand fan-out as Skynet jobs (same worker over lib/db.cjs, cost cap + model from settings); the breakdown prompt becomes a versioned prompt asset (core.prompt_assets, Mathew-editable — the first use of the asset model V14 completes in S3); API /api/intelligence/teardowns*; artefact view data for V15; pytest apps/api/tests/intelligence/test_teardown_jobs.py.
Acceptance. Test green; one brand fan-out drains on staging; the prompt asset version is recorded on each artefact.
Bridge impact. The Grok lfs_extract prompt + schema from bridge I1 is the seed for the teardown prompt asset.
Out of scope. The two actions (A12b).
Amended at A12b (2026-09-24). The staging proof (one brand fan-out drains on staging) runs in V15's staging walkthrough after staging carries the intelligence data (A2h); its ids go in V15's record with a reference to this card. This card's own proof is local: the docker Postgres and a stubbed model client, the ids in docs/ops/a12a-acceptance.md.
A12b · Create script / Recreate actions + brief stub / Expand hand-off · A · M · S2-W6
Read first. A12a; functional design user stories 1–2; the S1 Expand entry in apps/api/production; studio.briefs stub (until S3).
Deliverables. Create script from this → a script brief entity (studio.briefs: teardown ref, evidence packet, product, status) with lineage teardown → brief; Recreate → a Production Expand session seeded with the ad's media, lineage ad → session; Add to brief → append the teardown/VOC evidence packet to an existing brief; Tag for teammate → an inbox item with the selected evidence packet and assignee; when a teardown or research artefact becomes a brief, auto-attach the matching VOC packet (top phrases/threads for the product and selected group, with source metadata and date window), with add/remove controls preserved in the brief payload; pytest apps/api/tests/intelligence/test_teardown_actions.py.
Acceptance. Test green; one teardown → one brief + one Expand session on staging (ids attached).
Amended at D21 (2026-09-24). Per docs/discovery/s2-operator-rules.md: a researcher adds a finding to a draft brief, and only a holder of a new intelligence.briefs.send permission (granted to strategists) sends the brief to the creative team; the send is a status change on the brief, with who and when recorded.
Amended at A12b (2026-09-24). The staging proof (one teardown → one brief + one Expand session, ids attached) runs in V15's staging walkthrough after staging carries the intelligence data (A2h); its ids go in V15's record with a reference to this card. This card's own proof is local: the docker Postgres with the R2 client stubbed, the ids in docs/ops/a12b-acceptance.md. Per docs/discovery/s2-operator-rules.md § 4 a researcher adds an ad, a family or a teardown to a draft brief, so the brief takes four source kinds (teardown, research, ad, family) and the actions exist for each; the VOC packet attaches for the group the action names, and an action that names no group is refused.
A18 · Reddit VOC pipeline · A · M · S2-W6
Read first. D23; lib/market-intel/{reddit-client,ingest-worker}.cjs, mi_scopes, mi_documents, mi_document_scopes, crons 5,20,35,50 * * * * + retention 40 3 * * *.
Deliverables. mi_keyword_groups(workspace_id, name, keywords[], scopes[]) (first: cravings, blood sugar); continuous collection per group (scope runs tagged with the group); retention: never purge documents that belong to a group; export GET /api/intelligence/voc/export?group&from&to → CSV/JSON (columns per D23); UI in V15.
Acceptance. node services/scraper/scripts/run-reddit-voc-smoke.mjs --group cravings collects and the export returns rows; sample attached.
Amended at D23 (2026-09-24). A18 extends the existing normalized, workspace-scoped mi_keyword_groups and mi_keyword_terms tables (one row per term); it does not create a keywords[], scopes[] table. It adds an explicit group-to-collection-scope association, revisioned historical attribution (each attribution stores the matched term and an immutable group/scope revision), and permission-gated add / pause / resume / soft-remove APIs for subreddit scopes, search scopes, groups and terms under a new intelligence.voc.manage key, granted in its migration to admin, researcher and strategist. A term edit appends or revises attribution; it never deletes old attribution (today's retag deletes and rewrites). Pause changes future collection only; exports and V15 history include paused and soft-removed groups when selected (today the export reads enabled groups only). Group-matched documents persist; unmatched documents expire 365 days after their last source write (today every document expires at 365 days), with a migration/backfill for the existing rows before the first matched document reaches its purge date. Seed per D23: the two groups and their terms, the nine subreddits, the three search scopes paused; the existing postpartum group and run-time postpartum scopes are preserved unchanged.
A18b · PubMed evidence adapter + six-lens scopes + MCP + export · A · M · S2-W7
Context. Reddit tells the team how the customer talks about the problem; PubMed tells them why it happens. Six lenses (mechanism, root cause, symptoms, ingredients, failed solutions, desired outcomes) are searched per keyword group and the findings are classified by evidence strength, so briefs and scripts can carry fact-based hooks, authority statements, belief shifts, symptoms to call out, and reasons other solutions fail — in plain customer language, with a citation on every row.
Read first. D23b; development-processes/pubmed-evidence-layer-plan.md (§2 lenses + classifier, §3 schema, §4 MCP); lib/market-intel/{reddit-client,ingest-worker,amazon-worker}.cjs, mi_scopes (product_name), mi_documents, mi_document_scopes; NCBI E-utilities (esearch / efetch / elink, datetype=edat, tool + email required, NCBI_API_KEY for 10 req/s).
Deliverables. lib/market-intel/{pubmed-client,normalize-pubmed,pubmed-classifier,pubmed-worker,pubmed-corpus}.cjs (plain fetch + XML, retry/backoff, rate limit); migration add_market_intel_pubmed.sql — mi_scopes.source gains pubmed, scope_type gains pubmed_query, mi_scopes.group_key (FK to A18's mi_keyword_groups) + index, params = {lens, template_version, terms}; mi_documents.doc_type gains article, new citation TEXT and evidence JSONB (doi, journal, pub_types[], mesh[], humans, animals, study_design[], sample_n, sample_n_confidence, evidence_class, pmc_id, lens[]) + index on evidence_class; body = abstract or title fallback (metrics.has_abstract=false), author_hash null (public authorship), purge_after never set; classifier = publication types + MeSH Humans/Animals + study-design cues + sample-size extractor → classes strong / moderate / preclinical / case-report / narrative-review / traditional-use / unclassified; worker watermark on the Entrez indexing date with a 7-day overlap + monthly full re-walk per scope, watermark_gap never set; six lens scopes per keyword group seeded from D23b; thin MCP adapter lib/mcp/pubmed-mcp.cjs mounted at /mcp/pubmed (Streamable HTTP, miAuth token; tools pubmed_search, pubmed_get, pubmed_evidence_corpus, pubmed_lenses); /market-intel/pubmed/{run,stats}; admin page admin/app/market-intel/pubmed; documents route + service types gain the article fields; export GET /api/intelligence/evidence/export?group&lens&class&from&to → CSV/JSON (columns per D23b); test/market-intel-pubmed.test.mjs with recorded fixtures (a no-abstract record, an animal study, a meta-analysis), the classifier table, sample-size cases, edat overlap, backoff.
Acceptance. Test green; node services/scraper/scripts/run-pubmed-evidence-smoke.mjs --group cravings collects all six lenses and the export returns classified rows; an MCP client lists the four tools and pubmed_search returns ranked rows on staging; sample attached.
Bridge impact. If R7a is built in the standalone, this card is a port of the proven adapter + MCP into the unified market-intel spine, not a build.
Out of scope. The evidence packet in briefs (A18c); the /evidence-layer skill (RescaleOS now, Studio via A11).
Amended at D23 (2026-09-24). Lens scopes hold typed term sets (concern, mechanism, ingredient, common_solution) per docs/discovery/s2-evidence-lens-seed.md; MeSH only as verified descriptors, other phrases as [tiab]. A18b stores the generated query, the template version and the scope revision per run; monthly re-walks are windowed by Entrez date so each window stays below PubMed's 9,999-result cap, and PMIDs are deduplicated across windows. It manages lens scopes and terms through a new intelligence.evidence.manage key, granted in its migration to admin, researcher and strategist, and it emits all four claims-policy flags (form-mismatch, route-mismatch, combination-mismatch, disease-population). The first proof product is Aleniva Triphala.
Amended at A18b (2026-09-24). R7a was never built, so this card is a build. Six facts replace older wording: (1) the group link is A18's mi_keyword_group_scopes membership (one live membership per lens scope), not a new mi_scopes.group_key column; (2) the migrations are meta_ads/0NN_pubmed_evidence.sql and core/0NN_evidence_permissions.sql in the unified runner (the name add_market_intel_pubmed.sql predates the runner); (3) the term set is named common_solution, as the seed doc says; (4) an article links to its group through the lens scope's membership and to the lens scope revision that found it through mi_document_scopes.scope_revision; PubMed rows never enter mi_document_keyword_groups; (5) the smoke script lives in services/scraper/tools/, as A18's does; (6) the staging MCP proof (an MCP client lists the four tools and pubmed_search returns ranked rows) runs in A2h's staging run, the box that carries the scraper engine, with its output in A2h's record and a reference to this card; the card's own proof is local: the same MCP client against the local engine with the recorded fixtures, in docs/ops/a18b-acceptance.md.
V15 · Intelligence native pages, group 2 + tracked-ad actions · V · L · S2-W7
Read first. A12, A14, A15, A18, A18b APIs; old pages /research*, /breakdowns, /market-intel/*.
Deliverables. Research (keywords, runs, ranked products with reasons, Promote to product), Breakdowns (list, detail, brand fan-out), Market intel (scopes, documents, threads, aliases, stats; VOC groups + export; evidence view with pubmed source filter, lens + evidence-class filters, export), tracked-ad actions on any ad/family: Expand (Production), Create script, Recreate; static-ads' scraped-ads pages retired (collections become tags on the one corpus).
Acceptance. cd apps/web && npm test -- intelligence-group2 (apps/web is not a pnpm workspace member); staging walkthrough research → product → tracked ad → Expand / script (recording).
Amended at D23 (2026-09-24). V15 adds the managed VOC and evidence controls: add, pause, resume and soft remove of scopes, groups and terms (gated by intelligence.voc.manage and intelligence.evidence.manage), their paused and removed state with history, and export by selected group including paused and removed groups.
Amended at A12b (2026-09-24). The staging walkthrough also records the ids that prove A12a (one brand fan-out drained) and A12b (one teardown → one brief + one Expand session), each with a reference to its card.
Amended at A23 (2026-09-24). The VOC search and export source filter renders telegram and discord next to reddit and pubmed, reading A23's API filter contract (the closed source list of the export and documents routes); a chat scope shows its consent state (the latest grant or withdrawal with actor and time) and the consent action beside pause, resume and soft remove; the group-2 test covers the filter and the consent control with fixtures.
Amended at A18c (2026-09-24). The brief view renders the evidence packet next to the VOC packet (per lens: title, citation with the PMID link, evidence class, the four flags with null shown as "unavailable", the plain-language finding when present) with the same add and remove controls as the VOC entries, plus the view's evidence_summary; the staging walkthrough records the ids of one brief that shows both packets, with a reference to A18c.
Amended at V15's co-plan (2026-09-24). Seven facts settled with the brief: (1) the promotion pages are card V15b (below), which depends on A15 and on this card; this card shows the opportunity's decision from A14 and a Promote placeholder that V15b replaces, and the "research → product" leg of the walkthrough with the recorded staging promotion (A15's acceptance) lives in V15b's record; this card's walkthrough is research run → ranked opportunity → tracked ad → Expand / Create script; (2) the market-intel documents, threads, aliases, stats and the closed source list have no engine route: A23 delivers them as workspace-scoped API routes with a capabilities response and a contracts schema, and this card renders them and never calls the engine proxy; (3) Expand and Recreate exist per ad only, one form on A12b's single route, differing on success (in place, or the hand-off to the Production session); a family carries Create script and Add to brief and lists its ads; (4) the scraped-ads web pages, shell routes, sidebar entry, redirect and client functions are retired by this card, whose record and checklist carry the replacement table per legacy feature and per legacy control; A19 removes the API side against that table and never touches the web; (5) the briefs pages carry what A18c's amendment requires (list, detail, both packets, the summary, entry controls, the explicit add); tagging and sending are Studio's UI in S3; (6) the evidence view shows a visible, locked pubmed source filter; "Run again" on a breakdown is the family enqueue, because A12a exposes no retry route; (7) the card merges on its local proof and is done only when the walkthrough's slots (the recording; A12a's drained fan-out id; A12b's teardown, brief and session ids; A18c's brief id) are filled after A2h's staging data exists.
V15b · Promotion pages · V · S · S2-W7
Read first. A15's promotion, product-readiness and bridge-route contracts; A14's research opportunities; V15's intelligence module, client and test pattern.
Deliverables. Replace V15's Promote placeholder with native promotion pages: recommend from a ranked opportunity using competitors from that run, the niche and the sourceability note; the promotion detail with its action history; approve and decline gated by the distinct recommendation and approval permissions; the market-card state per market with retry, manual card link and number refresh; the linked product's readiness checklist with the permitted done and undone actions; and the three editable client-file fields. Use A15's typed routes and errors, URL state, permission-only gates and V15's client and test conventions. Render server-derived evidence and the incomplete readiness state; invent no score threshold and no client-side promotion state.
Acceptance. cd apps/web && npm test -- promotion-pages, typecheck, lint and build pass. One staging promotion recorded in docs/ops/v15b-acceptance.md: the opportunity and promotion ids, the created product id, the linked competitors, the per-market ClickUp card state and id, and the resulting tracked-ad hand-off. The card merges on its local proof and is done only when that walkthrough is recorded.
Out of scope. A15's API, migrations, the bridge and workos implementation, ClickUp configuration, product editing outside the readiness and client-file controls, script generation, and the tracked-ad actions V15 delivers.
Split from V15 at V15's co-plan (2026-09-24). The promotion surface has its own permissions, workflow state and acceptance, and A15 waits on D22's answers, so it is a card of its own rather than a conditional part of V15.
Amended at V15b's co-plan (2026-09-25). Four facts settled with the brief: (1) the pages read what A15's read model now carries — the allowed markets per promotion, the eligible competitors per opportunity, the created product's id and name, and the client-file content with its last change — and never derive them in the browser; (2) the product link uses the product name, because Production's detail route names its parameter productId but carries the name; the product id serves the readiness and client-file calls; (3) one shared readiness and client-file panel is mounted on the promotion page and at the end of Production's existing Brand Identity page, and nowhere else; the product routes go through the established product client, the promotion routes through the intelligence client; (4) the gates follow A15: recommend and the number refresh need the recommend permission, approve, decline, card retry and card link need the approve permission, and the promotion view needs both the intelligence and the production read permissions because it carries client-file content.
A19 · Scraper retirement + duplicate-scraper shutdown + archive · A · M · S2-W8
Read first. A2d (both stacks), /opt/meta-ads compose, static-ads scraped_ads.py, decodo_client.py, scrape_runner.py, ad_collections, bridge cards I1/I2/R7a.
Deliverables. Cutover day: final meta_ads delta migration (verify script), old crons stopped, meta-ads.rescale.media → redirect to app.rescale.media/intelligence, old Supabase project read-only then archived (dump to the Storage Box), old container removed from /opt/meta-ads; static-ads' scraper routes removed from apps/api, collections migrated to tags, and legacy session source lineage mapped to native ads; bridge export surfaces retired (/bridge/lfs/runs*, the package route, the Primal Queen continuous folder-drop path, any bridge-only schedule/worker, is_long_copy_native, the lfs_extract tier after A12a absorbs it); docs/runbooks/S2-intelligence-cutover.md.
Acceptance. In S2-W8 (amended 2026-09-19): the final-delta script and its verify, the redirect configuration and the archive procedure prepared and verified; rg "bridge/lfs|lfs_extract|BRIDGE_DROP_ROOT" services/scraper = 0, rg decodo apps/api = 0; X3 checks updated. At the go-live, in X7's acceptance: old cron off, redirect live, archive dump present, old container removed.
Bridge impact. Retires every scraper-side bridge path proved in I1/I2/R7a.
Out of scope. The console (A20).
Amended 2026-09-19 (the owner's decision: production at the terminal go-live). A19 splits in two. In S2-W8 it prepares and verifies the retirement artefacts: the final-delta script and its verify, the redirect configuration, the archive procedure, the removal of static-ads' scraper routes from Skynet's apps/api, and the retirement of the bridge export surfaces in the repository. X7 executes them at the go-live: the final delta, the scheduler stop, the redirect, the archive and the removal of the old container. The "Cutover day" deliverables above are therefore prepared here and run at X7. The shutdown of the old standalone scraper is a go-live acceptance item. Until the final switch at the go-live, the old scraper's data is authoritative.
Amended at A19's co-plan (2026-09-24). Facts settled with the brief: (1) the bridge export surfaces (/bridge/lfs/runs*, the package route, the folder-drop path, is_long_copy_native, lfs_extract) were never built in either repository — cards I1, I2 and R7a never ran — so this card delivers the recorded proof and a CI guard (infra/scripts/retired-surfaces.py, a closed list) instead of a removal; (2) the final delta is the migration tool's rehearsed full replacement from the freeze archive, wrapped by final-delta.sh with a required parent archive, a per-table delta report, the mapping check for columns the archive does not carry, and the verify extended with row counts and the sequence state; the freeze witness gains the mode go-live-final with the schema's four claim keys, each backed by its own receipt; (3) the source's end runs in this order: the scraper container stopped (both legacy containers recorded), the persisted active work reconciled by an explicit cutover policy per field, the source fenced by revoking the old credential (rotate, revoke the rotated-out signing key, disable the legacy API keys; a database read-only setting is not a fence for PostgREST) and proved by an old-key read and write both refused, then the owner's dump, then the archive to the box and the Storage Box, then the removal of exactly the two legacy containers, then the project pause last; (4) production has no enable path for the intelligence profile, so the switch is X7's manual fixed-target compose command written in the runbook, and switch-receipt.py only observes it: a live engine session named skynet-scraper-engine (the engine's application name, renamed from meta-ads-engine) and an engine boot marker in meta_ads.settings (engine.last_boot, outside the replacement scope), with a T-1 preflight that proves the application role's grants on that table without starting the engine; (5) every retirement script reads fixed targets from target.env (immutable image digests), accepts no host, path, service or DSN argument, writes a receipt, and requires the receipts of the steps before it; the removal script is the one stated exception to the never-remove rule; (6) collections and static-ads tags become intelligence tags by scraped_ads.ad_library_id = meta_ads.ads.ad_library_id inside the workspace through an api module run in the api container, unmapped rows reported and never invented, no migration; (7) the removal of the static-ads scraper API routes and services is bounded by V15's replacement table, the web pages are V15's and this card never modifies apps/web, and the legacy admin application services/scraper/admin goes with this card (open item #160); (8) the ported engine registers 14 timers at this base, the old image's count is read at its own commit, and the "ten crons" sentences are corrected. The runbook is docs/runbooks/S2-intelligence-cutover.md; X7 files the live receipts in the go-live record.
A18c · Evidence packet in briefs · A · S · S2-W8
Read first. A12b (VOC packet auto-attach), A18b (pubmed_evidence_corpus), studio.briefs stub.
Deliverables. When a teardown or research artefact becomes a brief, the evidence packet (top findings per lens for the product and group: plain-language finding, citation, evidence class, PMID link) is auto-attached next to the VOC packet, with add/remove controls preserved in the brief payload; the packet carries the claims-policy flags (form-mismatch, class) so script generation and the compliance pass read them from the row, never from the prose; pytest apps/api/tests/intelligence/test_evidence_packet.py.
Acceptance. Test green; one staging brief shows both packets (ids attached).
Amended at D23 (2026-09-24). A18c carries the evidence class and all four flags (form-mismatch, route-mismatch, combination-mismatch, disease-population) from each row unchanged; it never infers what may be claimed from packet prose, and a disease-population row cannot by itself substantiate a healthy-population efficacy statement.
Amended at A18c (2026-09-24). The packet attaches on every brief creation and every add-to-brief action, whatever the source kind (A12b's four kinds), for the group the action names; the test module is apps/api/app/backend/tests/core/test_evidence_packet.py; the staging proof (one brief showing both packets, ids attached) runs in V15's staging walkthrough after staging carries the intelligence data (A2h), its ids in V15's record with a reference to this card; the card's own proof is local (docker Postgres, fixtures, ids in docs/ops/a18c-acceptance.md). An explicit add of a finding names its PMID and lens; the routine snapshots the row from the corpus. The healthy-population rule of D23 is code, not prose: a finding supports a healthy-population efficacy statement only when its class is strong or moderate AND disease_population is false; null (unavailable) never supports.
Amended at A18c's co-plan (2026-09-24). Five facts settled with the brief: (1) the packet copies every value from A18b's export rows and derives nothing after selection, so the export gains two output columns, reasons and permalink, by DROP and CREATE (a changed result list cannot go through CREATE OR REPLACE) with the CSV routine re-created and both routines' grants re-applied in the same migration; (2) a brief has one group: both packets are keyed by one group_key, and an add-to-brief or explicit add that names another group is refused (EVIDENCE_GROUP_MISMATCH, 409, nothing written); (3) an explicit add on a brief created before this card carries the group key once, the routine derives and persists the packet metadata for it, and adds only the requested finding; (4) claims_context_present is true only when every live lens scope of the group carries a claims context, and the metadata names the lenses without one, so rows with null flags are explained; (5) the card launches on main only after both chains (A12a → A12b and A18 → A18b) are merged, with studio/002 as its migration, so A10's planned studio/002_baseline.sql becomes 003.
A23 · Chat source adapters: Telegram + Discord channel ingestion · A · M · S2-W7
Context. Selected Telegram/Discord channels become sources in the market-intel corpus (customer/community channels) and later feed the feedback lanes (team channels, S3). Consent per channel; authors salted-hashed like every other market-intel source.
Read first. lib/market-intel/{ingest-worker,reddit-client}.cjs, mi_scopes (scope_type), mi_documents, MARKET_INTEL_AUTHOR_SALT; RescaleOS console/src/server/cron/feedbackPoller.ts, console/src/server/feedbackPollers/{telegram,discord,secrets}.ts (bot polling, offsets, attachment capture); Discord bot install pattern in ecomprofits notification-platform/src/server/discord-install.service.ts.
Deliverables. mi_scopes.scope_type gains telegram_channel and discord_channel (bot token per workspace from core.integration_credentials, channel id, consent flag, keyword group); a poller worker (long-poll Telegram getUpdates with offsets; Discord gateway or channel REST with after) writing mi_documents (+ thread reconstruction like Reddit); retention per D51; searchable in the VOC UI (V15) by source telegram|discord; node --test services/scraper/test/chat-adapters.test.js.
Acceptance. Test green with recorded fixtures; one consented channel ingests on staging and appears in the VOC search with the author hashed. Amended 2026-09-19: the staging run polls with a staging bot credential, and a staging poll configured with a bot credential outside the staging allowlist is refused before any call to Telegram or Discord.
Out of scope. Team-feedback lanes and the agent (S3, A24); DMs; channels without consent.
Amended 2026-09-19 (the owner's decision: production at the terminal go-live). On staging the poller writes its documents only to the staging database. Its read offsets are shared state where they live outside Skynet: Telegram confirms a getUpdates offset on its own side for the bot that polls, so a staging poll with the console's or production's bot token would consume updates another poller needs. Under the staging write rule (architecture §6), staging polls only with its own staging bot credentials, on channels consented for staging. A Discord after cursor stays in the staging database. The owner supplies the staging bots, their credentials and the consented sandbox channels at this card's co-plan; until then the card builds and tests with fixtures and cannot claim its staging live acceptance.
Amended at A23's co-plan (2026-09-24). The VOC UI source filter for telegram | discord is delivered by V15 (its card carries the contract); A23 ships the API filter and its test. A23 builds on A18b's migration chain (its meta_ads migration follows A18b's), so the source and kind lists carry PubMed and the chat values together. Consent is an append-only history per scope, not a flag. The Telegram cursor is keyed by the bot identity the provider confirms, under a database lease.
Amended at V15's co-plan (2026-09-24). A23 owns the workspace-scoped VOC corpus contract that V15 consumes: the document feed and thread reads, the per-source stats, the capabilities schema and response (the closed lists of sources, scope types and document types, in packages/contracts), and the safe brand-alias CRUD with its documented attribution effect (an alias applies to documents ingested after it is written; it retags nothing retroactively); V15 owns only their UI. The engine carries no such routes, and the legacy admin's read-only document and alias routes retire with it.
A42 · Every finding of the mandated scan is a leak · A · M · S2-W2 · chore/a/a42-scan-reads-what-can-be-committed (surfaced while building V47, open item #119; carded 2026-09-20)
Context. This is a decision about which set of files the house's mandated secret scan reads, not a defect in the scanner or in the dev stack, and the card stops at a gate where the owner picks the contract. The mandated scan is written down three times in two shapes, and both read the working tree: AGENTS.md:39 and CLAUDE.md:5 say gitleaks detect --no-git --source . --config infra/secret-scan/gitleaks.toml --redact, and docs/CI.md:150 says gitleaks dir --config infra/secret-scan/gitleaks.toml --redact .. The dev stack writes into that tree by design: infra/scripts/env.sh:32-39 copies infra/env/dev.env from dev.env.example on first run and :68 fills every blank secret with a generated value, which docs/runbooks/LOCAL_DEV.md:30-31 documents as step 1 of make dev. Git ignores the result (git check-ignore -v infra/env/dev.env prints .gitignore:10, the *.env line), and the scan does not. The item's own measurement was re-run at cd8022a with gitleaks 8.30.1, the version ci.yml:25 pins, and the number moved: the documented tree scan reports 41 findings on this box, not 6. The 6 the item names reproduce exactly and all sit in infra/env/dev.env: one fernet-encryption-key, one private-key and four generic-api-key. The other 35 sit in files that git ignores for the same reason the first 6 are ignored: 9 in infra/env/staging.env, 9 in infra/env/staging.bak-20260917T091139Z.env, 11 in apps/api/app/backend/tests/__pycache__/*.pyc and 6 under services/agent-runtime/console/.next/. So the item's closing sentence, that the scan reads clean once the dev file is removed, holds only on a box that has never held a staging env file, never run the api suite and never built the console; on a working box the count a builder is asked to trust is the count of how much of the machine has been used. Nothing else in the house reads that set. The secrets job reads a clean checkout, which holds tracked files alone: ci.yml:1129-1152, with gitleaks git over the change's commits at :1150 and gitleaks dir over the checkout at :1152. The pre-commit hook (.pre-commit-config.yaml, gitleaks v8.30.1 with the same config) reads staged content. The builder's own command is therefore the only one of the three that can report a file which can never enter the history, and that is the whole defect: the false alarm is not in the scanner, it is in which set the house told the builder to scan. Two of the item's three routes are closed by measurement. The allowlist route is refused by the config's own written rule at infra/secret-scan/gitleaks.toml:12, which says there is no paths clause on purpose, because a global path allowlist skips the whole file before any rule runs and would hide a real token dropped into the same file; and an allowlist entry for the dev file would answer 6 of the 41 findings on this box and leave 35. Moving the file out of the tree answers the same 6 and leaves the same 35, and it costs the compose files, env.sh:32-33 and the clean-up sentence at LOCAL_DEV.md:130, all of which name infra/env/dev.env by that path. The scanner offers no third way of its own: gitleaks dir at 8.30.1 takes a path and has no tracked-file filter, and .gitleaksignore holds fingerprints, whose file, line and redacted value move every time env.sh regenerates the file. What does answer all 41 is the set git itself can name. git ls-files --cached --others --exclude-standard is 2,862 files at cd8022a on this box, and it is exactly what git add -A would stage, so it is exactly what can enter the history. A gitleaks run with the same config over a copy of that set gives 0 findings where the documented command gives 41, and a new untracked file that git does not ignore is still in it, which is the case the scan exists for.
Read first. AGENTS.md:39 (the mandated command) and :45 (rule 1, no secrets ever); CLAUDE.md:5 (the same command, written again); docs/CI.md:23 (the secrets row) and :150 (the third written form); .github/workflows/ci.yml:25 (the pinned version) and :1129-1152 (the job, the range scan and the tree scan); .pre-commit-config.yaml whole file; infra/secret-scan/gitleaks.toml:1-3 and :8-19 (the rule against a paths clause, with its reason); infra/scripts/env.sh:24-39, :44-56 and :60-78 (what creates the file and what fills it); docs/runbooks/LOCAL_DEV.md:28-38 (step 1) and :130-131 (deleting the file to regenerate); .gitignore:1-12; docs/ops/v47-acceptance.md:252 and :289-296 (the row that records 0 findings and the paragraph that records the 6). Open item #119.
Deliverables. (1) The decision, written as docs/decisions/ADR-022-what-the-secret-scan-reads.md, with the measured table this card carries: the 41 findings by file and rule, the 6 that are the item's, the 35 that are not, and the three routes with what each answers and what each leaves. The recommendation it records is the committable set, because it is the only one of the four that answers all 41 findings without weakening what the scan can see, and because it makes the builder's command and the CI job read the same thing for the first time. (2) infra/secret-scan/scan-tree.sh, one script, shell, which builds the committable set from git ls-files -z --cached --others --exclude-standard, copies it into a scratch directory outside the repository, runs gitleaks dir over that directory with infra/secret-scan/gitleaks.toml and --redact, removes the directory whether the scan passed or failed, and exits with the scanner's own exit code. It refuses to run outside a git work tree with a sentence that says so, and it never writes inside the repository, so a scan can never create the file a later scan reads. (3) AGENTS.md:39, CLAUDE.md:5 and docs/CI.md:150 all name that one script, so the house has one written form of the scan instead of three in two shapes. docs/CI.md:23 gains the sentence that the job's clean checkout and the script's committable set are the same set, and why an ignored file is outside both. (4) docs/runbooks/LOCAL_DEV.md gains a sentence at step 1 saying that the generated file is ignored, that the mandated scan does not read it, and that a builder who scans the whole working tree by hand will see it. (5) infra/secret-scan/test-scan-tree.sh, the script's behavioural test on a fixture repository the test builds in a temporary directory, with no network and with the scanner stubbed where the case is about the file set rather than about a rule: a tracked file carrying a canary is found; an untracked file that git does not ignore and carries the same canary is found; an ignored file carrying the same canary is not; a file that is both ignored and tracked, which git reports as tracked, is found; a path with a space and a path with a newline in its name reach the scan intact; the scratch directory is gone after a pass and after a failure; the script exits non-zero when the scan finds something and zero when it does not; and a run outside a git work tree is refused with its sentence. (6) The secrets job runs the script's test, so the file-set contract is proven in CI and not only by hand.
Interfaces. Exposes infra/secret-scan/scan-tree.sh as the one mandated scan, named by AGENTS.md, CLAUDE.md and docs/CI.md. Consumes git ls-files, the pinned scanner and infra/secret-scan/gitleaks.toml as they are. Unchanged: every rule and allowlist of the config, the secrets job's two existing steps, the pre-commit hook, .gitignore, infra/scripts/env.sh, the compose files and the path infra/env/dev.env.
Acceptance. The gate is answered first, and the answer is in the ADR before any other deliverable is built. bash infra/secret-scan/test-scan-tree.sh green, with each case failing on the base for its own reason: on the base there is no script, so every case names the missing script rather than passing, and the record carries that transcript. bash infra/secret-scan/scan-tree.sh gives 0 findings on a tree that holds infra/env/dev.env, infra/env/staging.env, apps/api/app/backend/tests/__pycache__/ and services/agent-runtime/console/.next/, and the record carries the same tree's 41 findings under the command AGENTS.md:39 names today, by file and rule, as the before half of the pair. A canary written into a tracked file and a canary written into a new untracked file are each found by the script, and the same canary in infra/env/dev.env is not, with all three transcripts in the record. The count of the committable set is recorded with the command that produced it (2,862 files at the cut) so the next reader can re-derive it. shellcheck clean over both new scripts. python3 infra/scripts/task-refs.py check . green, so no comment the card adds carries a key. The card edits ci.yml, so the two checks that read that file are run beside the scan change and both are green before the push, with the pinned reader on the path as the infra job supplies it: python3 infra/scripts/test-workflow-timeouts.py then python3 infra/scripts/workflow-timeouts.py check ., which requires the new step's job to still declare the limit the rule derives for it, and python3 infra/scripts/test-image-frontend-gate.py then python3 infra/scripts/image-frontend-gate.py check ., which requires the literal shapes of the workflow to be unmoved. actionlint -shellcheck=$(command -v shellcheck) .github/workflows/ci.yml clean. The whole ci aggregate green on the pull request, with the secrets job's new step in its log, and because the change edits ci.yml every job runs. Record docs/ops/a42-acceptance.md, holding the ADR's table, the three transcripts and the file-set contract in one sentence; open item #119 closed.
Out of scope. A paths allowlist entry for infra/env/dev.env, which loses on the config's own written rule at infra/secret-scan/gitleaks.toml:12 and on arithmetic: it answers 6 of 41 findings and leaves 35, and every later generated file needs a new entry that a reviewer has to believe. Moving the generated file outside the tree, which loses for the same arithmetic and costs the compose files, env.sh:32-33 and LOCAL_DEV.md:130, all of which name that path, for no gain over the committable set. A .gitleaksignore baseline, which loses because a fingerprint is a file, a line and a value, and env.sh regenerates all three. Changing which secrets env.sh generates or where the dev stack keeps them. Any rule, allowlist or fingerprint of infra/secret-scan/gitleaks.toml. The archive depth of any scan, which is open item #104's and A41's. Making the secrets job read anything other than its clean checkout, which already holds the same set. The 11 compiled-test findings and the 6 console build findings as findings: they are ignored build output, the card's measurement lists them only to show what the documented command reads, and nothing in the card inspects them.
Waits on / Unblocks. The owner's answer at the gate, which is the one input the card cannot derive: the measurement says the committable set answers everything the other routes leave, but which contract the house wants written into AGENTS.md is the owner's to say, and no file is edited before that answer is in the ADR. Nothing else: AGENTS.md, CLAUDE.md, docs/CI.md and the secrets job are all on main at cd8022a, and no open card edits the scan config. It shares AGENTS.md and docs/CI.md with any card that adds a job, so whichever merges second is rebased on the first / every builder who brings the dev stack up and then runs the scan the house asks for, and every later generated or ignored file, which is outside the mandated scan by the contract rather than by an entry somebody has to add.
Built as (PR #150 → main 4966cc2, 2026-09-22). The owner's answer at the card's gate written first: the mandated scan reads the committable set, git ls-files --cached --others --exclude-standard, and nothing git ignores, so every finding is about content that can enter the history. Code 2 review rounds → the first returned four must-fixes (a conflicted path is one name, with a real three-stage conflict fixture; a positive link-target canary; the precondition of "both halves always run", which is that a refused name stops both and default-deny wins; --directory needs a work tree), the second APPROVE. Four commits in the card's order: c95ed4c ADR-022 alone, before any other file was edited; e1f7915 the change; 7fb0176 the fix round; cf01beb the secrets floor from the pull request's own run. infra/secret-scan/scan-tree.sh is the one mandated command: it copies the committable set once, with --deduplicate, into a private directory outside the repository, runs both halves over that copy (gitleaks with the house config and --redact, then scan-archives.py --directory over the same copy for archive members), reads each half's own exit code, and removes the copy on every exit path; scan-archives.py gains that one directory mode and the bytes reader it needs, and its three git modes and the hook are untouched; test-scan-tree.sh drives 24 cases over throwaway repositories it builds itself (0 of 24 on the base), the last four on the directory mode alone; the secrets job of ci.yml replaces the checked-out-tree gitleaks step and the --tree archive step with the one script and adds the wrapper's test beside the runner's, keeping its two commit-range steps; AGENTS.md, CLAUDE.md, docs/CI.md, LOCAL_DEV.md and INTELLIGENCE_DATA_MIGRATION.md name that one command. Measured on the main checkout, read-only: the old command reads 41 findings at a7146c4, file for file and rule for rule as the card counted at cd8022a, every one in a path git ignores (six in infra/env/dev.env, item #119's; the staging env file and its backup; eleven compiled test files; four console build files); the script reads 0 findings over the 2,882 names of the committable set, all of them tracked; the fall-through audit lists every kind of name git can print and what the copy does with each. The secrets job ran 71 s on the pull request's run 35668752496, so the floor is 71 s and timeout-minutes 7 → 8 by the rule (workflow-timeouts.py check . equal, 54 cases). Found and not fixed here: scan-archives.py:326 asks git where the checkout is, so the directory mode still has to run inside a work tree, named beside the mode; the eleven bytecode findings are the shape #148 describes, a fixture whose parts spell a secret when joined, outside the mandated scan by the ADR's rule that every fixture builds its canary at run time. Record docs/ops/a42-acceptance.md; open items #119 and #148 closed.
Amended 2026-09-21 (the owner's answer at the gate; co-plan, one round). The amendment is the authority where it and the text above differ. The owner answered with the card's own recommendation: the mandated scan reads the committable set (tracked files plus untracked files that git does not ignore), through ONE script that AGENTS.md, CLAUDE.md and docs/CI.md all name. What changed under the card since it was written is A41, which made the mandated scan two commands: the working-tree scan, then python3 infra/secret-scan/scan-archives.py --tree for what is inside archives (AGENTS.md:39, CLAUDE.md:5, docs/CI.md:23 and :152). A script that replaced the first command alone would leave the house with two commands and the owner's one script unmet, so: (1) infra/secret-scan/scan-tree.sh is the one mandated command. It takes ONE private snapshot of git ls-files -z --cached --others --exclude-standard outside the repository and runs both halves over it, in order: the committable-file scan (gitleaks over the snapshot) and the committable-archive-member scan. (2) It always attempts BOTH halves, also when the first finds a leak or fails; it prints an unambiguous start line and final result for each half, keeps redaction, and exits 0 only when both pass, 1 when either reports a finding or a refusal, and 2 when either has a usage or operational failure (2 wins over 1). It removes the snapshot only after both halves have finished, on every exit path. (3) The untracked-archive gap is closed here and not filed: scan-archives.py has three modes today, --tree, --index and --range (near :529-535), and --tree reads the blobs of HEAD (near :161-185), so a new archive that is committable and not yet added is read by neither half. The tool gains ONE narrowly scoped mode that reads the regular files of a directory it is given (the wrapper's snapshot); its three git-object modes and the pre-commit hook's behaviour stay exactly as they are. (4) ADR-022 states, in these words or closer ones: the wrapper's two halves read one private snapshot of the committable working-tree set, so tracked files with unstaged edits and untracked files that are not ignored are scanned as they stand; the pre-commit archive hook remains an index scan (.pre-commit-config.yaml:7-14). (5) The deliverable that said the job's clean checkout and the script's set "are the same set" is replaced: the secrets job keeps its two commit-range scans (gitleaks over the change's commits, and A41's archive range step, because a current-tree command cannot say whether a now-deleted archive member leaked in a commit) and REPLACES its checked-out-tree gitleaks step and its scan-archives.py --tree step (near ci.yml:1182-1209) with one call of scan-tree.sh. In a clean checkout the snapshot is the tracked files; on a builder's box it also holds the untracked files that are not ignored. (6) The references that change, at today's lines: AGENTS.md:39, CLAUDE.md:5, docs/CI.md:23 and :152, the generated dev env explanation at docs/runbooks/LOCAL_DEV.md:30-35, and docs/CI.md:105 after the first green run of the changed job. Each names the one script and no second command. (7) The behavioural test also proves the combined execution: a finding in the file half does not stop the archive half from running; a finding in the archive half does not hide the file half's result; the exit-code precedence is as (2) says; the snapshot is gone after every result. The card's NUL-name, tracked and ignored cases stay, and two are added: a member canary inside an untracked, not ignored archive is found, and the same archive under an ignored path is not. (8) This closes open item #148's general question: ignored paths are outside the mandated scan, so a finding is always about content that can enter the next commit. What is left of #148 becomes a written rule in the ADR and the tests: a fixture never builds a scanner canary from adjacent literal constants that a compiler may fold; it builds it at run time, as A41's test does. (9) The job's shape changes (it copies and scans the snapshot), so by the floor rule: keep the 7-minute declaration for the first pull-request run of the new shape, then, in the same pull request and ONE commit, write the floor from that first green run into infra/scripts/workflow-timeouts.json, the re-derived timeout-minutes into ci.yml and the row into docs/CI.md:105; the checker derives the limit from the committed floor (infra/scripts/workflow-timeouts.py:22-33), and A41's 54 s floor is not kept merely because the old shape passed in it. Later runs are margin evidence and are not chased. (10) A46 changed what validates this: the timeout check imports the shared workflow reader (infra/scripts/workflows.py), so the checks the card runs before a push are python3 infra/scripts/test-workflow-timeouts.py, python3 infra/scripts/workflow-timeouts.py check ., python3 infra/scripts/test-workflows.py and the image gate's test, with the pinned yq on the path. Size is M as carded; the extension of A41's tool is bounded to the one mode.
A43 · The comment rule reads every comment, and what the first pass left is decided · A · L · S2-W3 · chore/a/a43-every-comment-is-read (surfaced while building A35, open item #123; carded 2026-09-20)
Context. Measured at main 78b90301. A35 put the comment rule in the repository and in CI: AGENTS.md rule 7 (:51) states it, infra/scripts/task-refs.py (266 lines) refuses the written shapes of a task reference (SHAPES, :51-59, with the key grammar at :44: a lane letter A, M, S, V or X, one or two digits and an optional letter, never inside a word), infra/scripts/task-refs-allow.json is the reviewed list (37 exclusions, 23 allowed findings), and the task-refs job (.github/workflows/ci.yml:1287-1299, timeout-minutes: 6 at :1290, no path filter and no needs, in the ci aggregate's needs at :1368 and its loop at :1376) runs python3 infra/scripts/test-task-refs.py (294 cases) and then python3 infra/scripts/task-refs.py check . on every pull request. On main the check prints read 1310 files, 1792 skipped by the reviewed list, 23 findings the list keeps, 0 to answer. Six facts decide this card's shape. First, services/scraper is no longer a mirror (AGENTS.md:24), but the reviewed list still excludes all of it (task-refs-allow.json:6, reason "a read-only mirror until its merge slice"). Narrowing that entry to services/scraper/migrations/** exposes five failures and no more: four findings on three lines (services/scraper/lib/market-intel/ingest-worker.cjs:6, "Phase 1" and "Phase 2", project phases of the Reddit ingest; lib/market-intel/normalize.cjs:44, "Phase 2"; normalize.cjs:137, "Phase 1"), and services/scraper/admin/public/.gitkeep, an empty file of a kind the check has no reader for. The migrations stay excluded because spikes/intelligence-data-path/migration-order.json pins all 48 files under services/scraper/migrations by sha256 (45 ordered, 3 excluded from the order), lib/order.cjs:383-392 refuses a changed byte, and npm run order:check runs at ci.yml:1248. Second, bare keys in running comment prose, which no written shape matches, are 280 lines in 157 files, measured with the check's own reader and key grammar: apps/api/app 189 lines in 102 files, infra 29 in 18, spikes/intelligence-data-path 20 in 16, apps/web/src 16 in 5, apps/api/tools 9 in 5, .github/workflows 6 in 2, e2e 4 in 3, the apps/api root files 2 in 2 (docker-compose.yml, requirements.txt), apps/api/scripts 2 in 1, packages/contracts 2 in 2 and the repository root 1 in 1. Lifting the scraper exclusion adds 6 more prose lines in 4 files, all of them non-reference uses: services/scraper/download-ads.js:110 (the S3 client), lib/analysis/classify.cjs:65 and lib/market-intel/normalize-amazon.cjs:70 (V8, the JavaScript engine) and scraper.js:183, :328 and :473 (M1, M2 and M3, Apple chips). Third, 7 of the 16 swept spike files are inputs of the parity run digest (tools/evidence.cjs:978, runDigestInputs): test/parity.test.cjs, test/parity/candidate.cjs, test/parity/cases/paths.cjs, test/parity/differences.cjs, test/parity/harness.cjs, test/parity/types.cjs and tools/classify.cjs; and 3 are inputs of the generator digest (tools/inventory.cjs:105): tools/classify.cjs, tools/paths.cjs and tools/scan.cjs. So a comment-only edit there moves both digests in the header of docs/spikes/a4-parity.csv, and the sweep ends with a regeneration and a closed-run restamp in A60's pattern (docs/ops/a60-acceptance.md). spikes/intelligence-data-path/sql/bootstrap/llm_reader.sql never carried a key comment, at cd8022a or now, so it is not the pin that matters. Fourth, the three output lines, the two strings, the eleven Python test names and the three spike titles are not findings: the check reads comment text alone, and each of them is an argument, a string or an identifier. A reviewed-list entry for any of them would match nothing and fail the check (task-refs.py:250-254), so they are decided in the record, not in the list. Their anchors now: infra/smoke/proxy-io.sh:430; infra/smoke/pressure_report.py:767 and :781; the slice name in a job's echo at ci.yml:245; e2e/production-states.spec.ts:113, written out again in e2e/README.md:119; apps/api/app/backend/tests/test_video_storyboard_delivery.py:537, :556, :575, :1224, :1245 and :1259 (each 65 lines below A35's measure) and test_video_storyboard_queue.py:1606, :1807, :1827, :1904 and :1942 (unmoved); spikes/intelligence-data-path/test/inventory.test.cjs:613, test/parity-manifest.test.cjs:159 and pressure/test/compose-intel.test.cjs:47. apps/api/ci-known-failures.txt names none of the eleven, so all eleven run. The applied migrations under db/migrations/unified hold 68 comment lines with a key in 20 files, each file an exclusion entry because apps/api/migrate.py pins its bytes by checksum (:120-137, :329-341). Fifth, the check reads a comment after code only where the marker follows a space and no quote, backtick or slash comes before it on the line (trailing, task-refs.py:120-130; the docstring states the limit at :14-18, AGENTS.md:51 repeats it). The size of that hole is a number that moves with every merge, so this card replaces the old fixed count with one command that measures it (deliverable 5). The one keyed line found behind a quote at A35's measure is still a string: spikes/intelligence-data-path/capture/lib/run-capture.sh:97, printf '# A4 capture — %s\n' "$CAPTURE_NAME", which writes a heading into a capture file. Sixth, the .csv and .npmrc readers (task-refs.py:87 and :76) run trailing like every other kind, so a # or ; behind a space inside a CSV field or an npmrc value can be read as a comment, although neither format has a comment after data.
Read first. docs/ops/a35-acceptance.md "What this first pass leaves" (:202) and "Notes for the next reader" (:245); infra/scripts/task-refs.py whole file, in particular :44 (the key), :51-59 (the shapes), :64-88 (the marker kinds and the reader tables, with .npmrc at :76 and .csv at :83-87), :91-101 (reader), :104-117 (python_comments), :120-130 (trailing), :133-160 (marked_comments), :163-179 (comments, findings), :182-203 (glob, reviewed) and :206-260 (check, with the unused-entry failure at :250-254); infra/scripts/test-task-refs.py whole file (the contexts at :42-57, the shaped and innocent cases at :60-107, the blind checker at :33); infra/scripts/task-refs-allow.json whole file; AGENTS.md:22-24 (which roots are mirrors), :38 (the comments command), :39 (the secret scan, bash infra/secret-scan/scan-tree.sh) and :51 (rule 7); docs/CI.md:23 (the job row), :61-69 (the section, whose :67 still says "the four read-only mirrors under services/") and :75-110 (the floor rule and the ci.yml floor table, task-refs 25 s on run 35578092768); spikes/intelligence-data-path/migration-order.json, lib/order.cjs:383-392 and ci.yml:1244-1249; spikes/intelligence-data-path/tools/evidence.cjs:978-1040 (runDigestInputs), tools/inventory.cjs:98-108 (generatorDigest), bin/closed-run.sh and docs/ops/a60-acceptance.md (commit A regenerates, commit B restamps from a closed run, and the comparison script); apps/api/migrate.py:120-137 and :329-341; every anchor named in the fourth fact of Context, with apps/api/ci-known-failures.txt. Open item #123.
Deliverables. (1) The prose decision, written into AGENTS.md rule 7 and into docs/CI.md:61-69: a bare key in running comment prose is not refused by the check and is not allowed either; it is a comment that has not yet been rewritten as the fact it stands for, and the rule names the record path a comment uses instead. The reason is the measurement: the same letters are as often a name as a reference (the S3 client, V8, M1 to M3, the angle labels, the second voice clone, an algorithm's Phase 1), so a prose rule in the checker would refuse code that is right. (2) The sweep of every prose line with a key, in the trees the check reads, one by one, by A35's rules: a parenthetical that only names the card that wrote the code goes; a key that stands for a fact becomes the fact; a pointer to an item or a record says what it points at and names the record by its path under docs/ops/; a label from the imported plan goes. A non-reference use stays and is listed in the record with its anchor and reason; it gets no reviewed-list entry, because it is not a finding. The count is measured again at the cut by deliverable (5), because it moves with every merge. (3) The scraper leaves the exclusion. task-refs-allow.json:6 becomes services/scraper/migrations/** with the reason that migration-order.json pins each file there by sha256, a new file included, and lib/order.cjs refuses a changed byte. The four findings at ingest-worker.cjs:6, normalize.cjs:44 and normalize.cjs:137 are rewritten in place as the facts they stand for (collection stores and does not interpret; labelling is a separate, later table), each without a change in the file's line count. The reader table gains .gitkeep as a kind with no comments, read only while the file is empty; a non-empty .gitkeep fails as a kind with no reader. docs/CI.md:67 names what is excluded now (the transcriber, agent-runtime and workos trees under services/, and services/scraper/migrations) and drops "four". (4) The lexical reader: task-refs.py gains, per kind it already reads, the scan that tells a comment marker from a marker inside a string, a template literal and an interpolation in one, a regular expression and a character class in one, a division, a heredoc and a printf format, so that a comment after code is read wherever its marker stands. It parses no grammar beyond that scan and still reads comment text alone. A kind it cannot scan safely stays on the conservative trailing rule, with its reason beside the code. The .csv and .npmrc readers read a comment only where the marker starts the line (after leading blanks), and never after data: a # or ; inside a CSV field or an npmrc value stays data. The module docstring (:9-18), docs/CI.md:63 and the last sentence of AGENTS.md:51 state the new rule and drop the conditional. (5) One canonical measure, python3 infra/scripts/task-refs.py measure <root> --kind hole|prose, reading the files the check reads with that tree's own reviewed list. --kind hole prints each line where the lexical reader opens a comment after code that trailing does not, as path:line<TAB>marker<TAB>comment text; --kind prose prints each comment line that carries a key of the grammar that no written shape matched, as path:line<TAB>key<TAB>comment text. Both sort by path and line and end with one line, <kind> <n> lines in <f> files, <k> carry a key. The builder records the full output of both kinds twice, run with the branch's script: on a worktree of the base at the cut (git worktree add <dir> <base>), and on the branch. On the branch every keyed hole line is a finding the check now reads and answers, and every prose line is a non-reference use listed in the record. No fixed count goes into AGENTS.md, docs/CI.md or a comment. (6) Regressions in infra/scripts/test-task-refs.py, each on fixture text written at run time in the throwaway repository: per kind the lexical reader covers, a marker inside a single-quoted, double-quoted and escaped string, a template literal and an interpolation in one, a regular expression and a character class, a division slash followed by a second one on the same line, a URL in a string and a URL in a comment, a heredoc and a printf format, and a Python f-string and raw string, each passing; a real comment after code with a shape in it, refused; a real comment after code with a non-reference in it, passing; the line at run-capture.sh:97, passing as a string; one CSV row whose field holds # card A12, passing, beside a header line # card A12, refused; one npmrc line whose value holds ; card A12, passing, beside a line ; card A12, refused; an empty .gitkeep, passing, and a .gitkeep holding text, failing as a kind with no reader; and measure --kind hole and --kind prose over a fixture, printing exactly the fixture's lines and count. Every refusal case is also run against the blind checker and must fail there. (7) The three output lines, the two strings, the eleven test names and the three spike titles stay unchanged. The record lists each with its present anchor and one reason: an operator reads the output lines in a proof, a job's log and a README read the strings, and a test name is an identifier a run selects and a failure report names. (8) The 20 applied migration files under db/migrations/unified stay excluded, and each entry's reason gains the sentence that a migration added after this cut is read like any other file. (9) The spike: the sweep of spikes/intelligence-data-path lands in A60's two-commit shape. Commit A holds the comment edits and docs/spikes/a4-parity.csv regenerated by npm run inventory; commit B holds the restamp written by bin/closed-run.sh on commit A's tree, with the comparison script's output in the record. A comment edit under services/scraper/lib keeps its file's line count; if npm run inventory:check still names a change, commit A regenerates the CSV with its own tool. (10) docs/ops/a43-acceptance.md.
Interfaces. Exposes the lexical reader, the line-start readers for .csv and .npmrc, the empty .gitkeep kind and the measure subcommand inside task-refs.py, and a narrower scraper exclusion in task-refs-allow.json. Exposes new generator_digest and run_digest values in the header of docs/spikes/a4-parity.csv. Consumes git ls-files, the written shapes, the key grammar and the task-refs job as A35 left them. Unchanged: the written shapes, the key grammar, the check command and its exit behaviour, the task-refs job's steps and its place in the ci aggregate, every byte under db/migrations/unified and services/scraper/migrations, the transcriber, agent-runtime and workos trees and the mirror overlays, every identifier and test name, every string, every docstring that a command prints, every row and verdict of a4-parity.csv, and everything under docs/ apart from docs/CI.md, the record and the regenerated CSV.
Acceptance. Locally the builder runs only targeted tests, red-on-base proofs, generators and fast static checks: python3 infra/scripts/test-task-refs.py, python3 infra/scripts/task-refs.py check ., the two measure runs of deliverable (5), in spikes/intelligence-data-path npm ci, npm run order:check, npm run inventory:check and bin/closed-run.sh, and bash infra/secret-scan/scan-tree.sh (AGENTS.md:39). Full suites run only in GitHub CI (owner rule). RED ON THE BASE: task-refs.py check . on the branch's reviewed list over the base tree exits 1 and names the four scraper findings, the .gitkeep and every finding the lexical reader adds; each new refusal case in (6) fails when run against the base's task-refs.py, for its own reason (the conservative rule does not read a marker behind a quote, the CSV and npmrc readers read a marker after data, the base has no .gitkeep kind and no measure); the record carries both transcripts. GUARD: task-refs.py check . reports 0 to answer and exits 0 on the branch; test-task-refs.py green, and every refusal case fails against the blind checker; git diff --name-only <base> -- db/migrations services/scraper/migrations infra/import/mirrors/overlays services/transcriber services/agent-runtime services/workos is empty; git diff --name-only <base> -- docs holds only docs/CI.md, docs/ops/a43-acceptance.md and docs/spikes/a4-parity.csv; npm run order:check passes with migration-order.json unchanged; the CSV's rows are byte-equal to the base's and only its two header digests changed; the closed run's comparison passes. Behaviour unchanged, in A35's shape and by kind: for every changed Python file the syntax tree of base and branch is equal once docstrings are removed, and no docstring that a command prints changed; for every changed TypeScript or JavaScript file the token stream without comments is equal and the line count is equal; every other changed line is a comment line of that file. The record carries both measure outputs from the base and the branch, and every keyed line from them with its decision. No job's shape changes, so no floor is re-measured; if the pull request's own task-refs run passes its 25 s maximum (docs/CI.md, run 35578092768), the record says so and the floor is re-measured by the rule at docs/CI.md:75-87, with max_seconds and run_id in infra/scripts/workflow-timeouts.json, the derived timeout-minutes at ci.yml:1290, the docs/CI.md row and python3 infra/scripts/workflow-timeouts.py check . green. In CI the whole ci aggregate is green on the pull request, with task-refs, api, scraper, spike-a4 and mirrors among the green jobs, and the eleven test names still collected and passing. Record docs/ops/a43-acceptance.md; open item #123 closed.
Out of scope. Extending the check to refuse a bare key in running prose, for the reason in deliverable (1). Renaming the eleven test names or the three spike titles, because a test name is an identifier a run selects. Rewriting the three output lines or the two strings, because an operator, a job's log and a README read them. Any byte of an applied migration under db/migrations/unified or services/scraper/migrations, whose bytes a ledger and a manifest pin. The transcriber, agent-runtime and workos trees, each of which meets the check at its own merge slice. Everything under docs/ and Markdown anywhere. A pre-commit hook for the comment rule. Making the lexical reader parse a language: it scans for string, comment and regular-expression boundaries and nothing more. Any new lane letter in the grammar, which A35's Out of scope already settles. A fixed count of the hole in any document, which the measure command replaces.
Waits on / Unblocks. Waits on nothing open: A45, A9 (pull request #118) and A42 are merged, and everything this card reads is on main at 78b90301. The sweep edits run-digest inputs of spikes/intelligence-data-path, so it shares A60's rule: if a card that changes a run-digest input (the adapter package, db/migrations/unified, services/scraper/migrations, the harness, tools/classify.cjs, the lock) merges first, this card rebases, regenerates and runs bin/closed-run.sh again on its new head; a card that only moves lines under services/scraper/lib needs a regeneration alone, on whichever of the two merges second. It shares services/scraper with A2d and docs/CI.md with A2d and A2f; whichever of two cards on a shared surface merges second is re-tested on the merge commit. Unblocks every later card, which meets a rule that reads every comment instead of a rule with a stated hole, every later review, which stops reading a trailing comment by eye, and each remaining mirror's merge slice, which lifts its exclusion into a check that no longer has one.
A44 · The legacy admin's framework version is a decision with every advisory measured · A · S · S2-W2 · chore/a/a44-admin-framework-version-decision (surfaced while building A9's L-B, open item #124; carded 2026-09-20)
Context. This is a version decision that needs an owner's answer, not a defect with one correct patch, and the card stops at a gate with the evidence assembled rather than choosing a release by itself. The dependency is Skynet's own for the first time: services/scraper left the read-only mirrors at its merge slice, which AGENTS.md states and infra/import/mirrors/tools/check-mirror-scope.py:35 enforces, and renovate.json ignorePaths now lists only services/agent-runtime/**, services/transcriber/** and services/workos/**, so nothing hides that package from the update bot any more. The pin is services/scraper/admin/package.json:17, "next": "14.1.0", and services/scraper/admin/package-lock.json resolves node_modules/next at the same 14.1.0. The item's number moved a long way. It says the admin "carries a published security advisory", in the singular. Measured against the GitHub Advisory Database, queried through its advisories endpoint for the npm ecosystem at that exact version, 34 published advisories affect next 14.1.0: 3 critical, 12 high, 15 medium and 4 low. Every advisory figure in this card is that query's answer as it stood on 2026-09-20, with the repository at cd8022a, and the query is named in the record so the next reader runs it again rather than trusting a count that only moves upward. The item's second question is whether a patch on the 14 line is enough, and the advisories' own patched-version fields answer it without any judgement being added. Of the 34, 13 name a release on the 14 line as the first that closes them, and the highest 14-line release any of them names is 14.2.35 (GHSA-5j59-xgg2-r9c4). The other 21 name no 14-line release at all: their first patched versions are on the 15 or 16 lines. So an upgrade to the newest 14 release closes 13 advisories and leaves 21 open, and no further 14 release exists to close them, which is what "the 14 line is closed for security" means read off the data rather than asserted. The 21 that no 14 release closes include 2 of the 3 criticals: GHSA-2xp9-vwfh-vxw4, unauthenticated remote code execution in the Image Optimization API, first patched at 15.5.24 and 16.3.3, and GHSA-p293-qw3h-jr36 (CVE-2026-75604), unauthenticated remote code execution on Windows-hosted servers, first patched at the same two. They also include 7 of the 12 highs, among them server-side request forgery in rewrites (GHSA-p9j2-gv94-2wf4) and a middleware bypass in Pages Router applications using i18n (GHSA-36qx-fr4f-26g5). The third critical, GHSA-f82v-jwr5-mffw (CVE-2025-29927), the authorization bypass in middleware, is one of the 13 and is closed on the 14 line at 14.2.25. The record's claim that remediation must mean a major is therefore supported by the advisories' own text, which is the opposite of what the item expected, and the card says so rather than repeating the item. Two of those surfaces are live in this application and not abstract. The admin's access control is a Next middleware: services/scraper/admin/middleware.ts:36 is the middleware export, and it is what decides the public prefixes at :12, the one exact public integration route at :25 and the admin-only prefixes at :31-34, which is precisely the surface the critical middleware bypass defeats. The Image Optimizer is enabled with two remote patterns, **.supabase.co and **.fbcdn.net (services/scraper/admin/next.config.js:4-15), which is the surface the two criticals that no 14 release closes reach. What limits the exposure is where the thing runs. The admin is built into the services/scraper image (services/scraper/Dockerfile:15-21 builds it, :74-91 copies the standalone output and verifies it is present) and that image is not in Skynet's own stack: none of infra/compose/compose.prod.yml, compose.staging.yml or docker-compose.dev.yml names it, and neither does .github/workflows/deploy.yml. It runs on the old standalone host, and A19 retires it at the go-live. So the decision weighs an application that Skynet builds but does not yet deploy, whose access control sits on a defeated surface, against a framework migration on code that is scheduled to be deleted.
Read first. services/scraper/admin/package.json whole file (the pin at :17, and react and react-dom at :18-19, which a major moves with it); services/scraper/admin/package-lock.json, the node_modules/next entry; services/scraper/admin/middleware.ts whole file, the access rules the advisory defeats; services/scraper/admin/next.config.js:1-18 (output: 'standalone' and the two remote patterns); services/scraper/Dockerfile:15-30 and :70-95 (how the admin is built, copied and verified); renovate.json whole file (the ignorePaths list this package left, and the minor-and-patch grouping rule); AGENTS.md (the paragraph that says services/scraper is no longer a mirror) with infra/import/mirrors/tools/check-mirror-scope.py:30-40; .github/workflows/ci.yml, the scraper job and its path filter, which is what would run a changed admin; infra/compose/compose.prod.yml, compose.staging.yml and .github/workflows/deploy.yml, none of which names the admin, which is the fact that bounds the exposure; A19 in the delivery plan and its place in the last wave of the slice; docs/ops/a9-acceptance.md on A9's branch, the section that lists the admin's files, for what else moves if the framework moves. Open item #124.
Deliverables. (1) The evidence table, as docs/ops/a44-acceptance.md: all 34 advisories, one row each, with identifier, severity, the vulnerable range that covers 14.1.0, every first patched version the advisory names, whether any of them is on the 14 line, and whether the application's own configuration reaches the surface (middleware, image optimizer, server actions, rewrites, dev server). The table is generated by a command the record names and repeats, never typed, so the next reader can re-derive it and see whether the count has moved again. (2) The three costed routes, each with what it closes and what it leaves, taken from the table and not from judgement: the newest 14 release closes 13 and leaves 21, of which 2 are critical; the newest 15 release closes all 34 and costs the framework major on the admin's app directory, its middleware and its React pair; carrying the version to A19 closes none and costs a recorded acceptance with a boundary. (3) The recommendation the card proposes, which is the newest 14 release now and the version held there until A19 deletes the application: it closes the one critical that defeats this application's own access control, it is a version bump inside a minor line with no migration, and it takes the two remaining criticals off the table by the boundary rather than by the version, because the surface they reach is an image optimizer on a host Skynet does not deploy and is scheduled to retire. The recommendation is a proposal and not a decision; the gate is the owner's, and this card changes no dependency whatever the answer is. (4) The boundary written down once, which holds whichever way the gate goes: a section of the record naming the host the admin runs on, that Skynet's own stack and deploy workflow do not carry it, the two advisories that no 14 release closes and the surface each reaches, and the card that removes the application. (5) The hand-over: the answer recorded as the input A49 builds from, so the implementation card names a version it did not choose and a reason it did not invent, and nothing about the dependency moves inside this card.
Interfaces. Exposes the recorded version decision and its boundary as the answer every later dependency alert about this package points at, and as A49's one input. Consumes services/scraper/admin/package.json and its lock as reading material only. Unchanged: every file of the repository. The card writes one record and changes no code, no dependency, no lock and no configuration.
Acceptance. The card is a measurement and a decision, and its acceptance is the record rather than a build. The evidence table reproduces from the query the record names, with the same 34 rows, the same 3 criticals, the same split of 13 with a 14-line fix and 21 without, and the same highest 14-line release of 14.2.35; if the count has moved since the carding, the record says by how much and which rows are new, because a table that cannot be re-derived is an opinion. Each of the three routes is stated with what it closes and what it leaves, both numbers read off the table and neither asserted. The two surfaces this application reaches are each shown by a file and a line, the middleware export and the image configuration, so severity is argued from the code and not from a label. The boundary section names the compose files and the deploy workflow that do not carry the admin, each by path, and the record shows the search that produced that claim. git diff --stat on the branch shows the record and nothing else, which is the proof that a decision card decided and did not build. python3 infra/scripts/task-refs.py check . green. The whole ci aggregate green on the pull request. Record docs/ops/a44-acceptance.md; open item #124 closed by the recorded answer, A49 raised against it with the chosen version named, and whatever the answer leaves carried is filed as its own open item with the two critical identifiers in it.
Out of scope. Moving the pin, regenerating the lock, rebuilding the image and changing any update-bot rule, all of which are A49's and none of which belongs in a card whose whole purpose is to put a measured choice in front of an owner. A card that both proposes and applies has already decided, and the gate would be decoration. The framework major, which is the alternative that loses on the recommendation and would win on a different card: it closes all 34 advisories, and it costs the app directory, the middleware and the React pair on an application A19 deletes in the last wave of the slice, so the work would be thrown away and the risk it removes is on a host Skynet does not deploy. If the owner's answer at the gate is the major, A49 carries that migration scope instead of the version move. Retiring the admin early, which is A19's work and depends on the native pages. Any other dependency of that package, and any dependency of any other package: the card reads one pin. Changing what the admin does, its routes, its access rules or its image optimizer configuration. Deploying the admin from this repository. The old standalone host's own patching, which is outside the repository.
Waits on / Unblocks. The owner's answer at the gate, which is the one input the card cannot derive: the measurement says what each route closes and what it leaves, and which risk the house will carry until A19 is the owner's to say. Nothing in the repository, because the card touches no file but its own record, so A9's open draft over the same package is not a dependency / A49, which cannot start until the answer names a version and which takes its whole scope from this record; A19, which retires the application; and every later dependency alert on that package, which reads the recorded decision instead of reopening it.
Built as (PR #144 → main a7146c4, 2026-09-21). One file, docs/ops/a44-acceptance.md, and no other change, which is the proof that a decision card decided and did not build. Measured on 2026-09-21 against the GitHub Advisory Database for next at exactly 14.1.0: 34 published advisories, none withdrawn, 3 critical, 12 high, 15 medium and 4 low; 13 name a first patched version on the 14 line, the highest 14.2.35, and 21 name none, 2 of the 3 criticals among them. The table is generated by a command the record gives in full, and the record runs from itself: a loop in it extracts its five jq programs from its own text and runs them, the 34-row table diffs empty against the first program's output, and the 21 identifiers still open at 14.2.35 diff empty against the 21 the table marks. Reach is argued from the code with file and line: the middleware is the application's whole access control on main, so the middleware-bypass critical defeats its own rules; the matcher excludes /_next/image, so the optimizer answers without a session, with two remote patterns configured; no rewrites, internationalisation, server actions, pages directory or dev server exist. The boundary is written once: no compose file and no deploy workflow of this repository names the admin, so Skynet builds it and deploys it nowhere; that the old standalone stack runs it is the owner's operational context and not repository proof. The record proposed the newest 14 release, held until A19. The owner's answer at the gate, 2026-09-21, was the third route and not the proposal: the pin is carried at 14.1.0 until A19 retires the application. The owner's reason: Skynet replaces this admin with pages of its own (V22, V15) and A19 deletes it, so of the routes the simplest is the right one; the boundary is what that rests on. The one fact the record asked for: the old production host runs Linux containers, by the owner's statement, so the Windows-host critical does not apply to it. Carried until A19: all 34, the three criticals named in the record and filed together as open item #160. The owner was asked in the same sitting about the old live host's own copy, which pins the same release, and accepted it until the go-live; that is an owner item and not this card's. Consequence: A49 is not cut, because it is gated to the patch on the 14 line and its own Out of scope says a decision to carry the version needs no card. Review: co-plan one round; the record 2 rounds to approve (one jq block was not self-contained, one count was typed wrong); the owner's answer 1 round, approved with no must-fix. PR runs 35624882494 on 16b8606 and 35657287991 on 96177e7, both green; merge run 35657455928 green, attempt 1; deploy 35657455883 green. Record docs/ops/a44-acceptance.md. Open item #124 closed.
A45 · The spike's one unguarded pin is read back by the job that owns it · A · S · S2-W2 · chore/a/a45-order-check-in-ci (surfaced while fixing A35's red run, open item #126; carded 2026-09-20)
Context. The spike carries three generator-backed pins and CI reads two of them back. spikes/intelligence-data-path/package.json:17 defines order:check as node bin/build-migration-order.cjs --check, which refuses a migration-order.json whose recorded digests no longer match what the generator would write now. The spike-a4 job runs two commands and no others: npm ci --no-audit --no-fund at .github/workflows/ci.yml:1112 and npm run test:ci at :1113, inside spikes/intelligence-data-path by the job's default at :1104. The item cites ci.yml:1025 for that line; at cd8022a it is :1113, because the task-refs and secrets jobs have moved below it since. The other two pins are read back and this one is not, which is the measured shape the item generalises from and which narrows it. inventory:check is exercised by the suite: test/inventory.test.cjs:143 runs the command-line tool with --check, and test/inventory-roundtrip.test.cjs:149 and :167 each prove that a drifted generated column and a drifted header fail it. corpus:check is exercised too: pressure/test/corpus.test.cjs:54 is the case that verify passes on the committed corpus and names the manifest digest. order:check is named inside the suite in exactly one place, test/scripts.test.cjs:96-97, which asserts that the script exists in package.json; asserting that a script is defined is not running it. The suite does read the extracts, and reading them only half way is why the drift was invisible: test/migration-order.test.cjs:460-473 takes each extract's claimed spans and asserts that the produced file contains each source excerpt, and it never compares extract.sha256 against the produced bytes. Every line of the generated file that is not inside a claimed span, the header among them, is therefore outside everything CI runs. That is what happened: A35's sweep rewrote a comment in sql/bootstrap/llm_reader.sql, the digest went stale, no run went red, and only a hand run of order:check found it. The base failure was reproduced at cd8022a rather than taken from the record. On a scratch copy of the tree holding the spike and services/scraper/migrations, which the generator reads, node bin/build-migration-order.cjs --check prints "every generated file is current" and exits 0. With a trailing phrase added to one comment line of sql/bootstrap/llm_reader.sql, the same command prints "sql/bootstrap/llm_reader.sql is not what the generator would write now" and "1 generated file(s) have drifted" and exits 1, while node --test test/migration-order.test.cjs over that same broken tree reports 14 tests, 14 passed, 0 failed. Red to the check that owns the pin, green to everything the job runs. The job also has a second hole of the same kind, which the item does not name and which the measurement found. The generator's source is outside the spike: bin/build-migration-order.cjs:28 resolves MIGRATIONS_DIR to ../../services/scraper/migrations, and :669-685 reads every file there. The spike_a4 path filter (ci.yml:174-177) names only the workflow anchor, spikes/intelligence-data-path/** and infra/smoke/**. So a change under services/scraper/migrations/** does not start the job at all, and neither order:check nor the on-disk digest case at test/migration-order.test.cjs:106-112 is run against it. That gap was harmless while services/scraper was a read-only mirror whose bytes only a refresh moved; it is live now that the package has graduated and is edited here like any other.
Read first. spikes/intelligence-data-path/package.json:10-29 (the script block, test:ci at :12 and order:check at :17); spikes/intelligence-data-path/bin/build-migration-order.cjs:1-35 (what the generator is and where its source sits), :660-700 (reading the source directory), :770-815 (how an extract and its digest are written) and :885-914 (the --check path and its two sentences); spikes/intelligence-data-path/migration-order.json, the extracts array, both entries; spikes/intelligence-data-path/sql/bootstrap/llm_reader.sql:1-15 (the header, which no claimed span covers); spikes/intelligence-data-path/test/migration-order.test.cjs:100-115 (the on-disk digest case) and :460-473 (the extracts case that reads spans and not the digest); test/scripts.test.cjs:90-100 (the script-name assertion, the only mention of order:check in the suite); test/inventory.test.cjs:140-150 and test/inventory-roundtrip.test.cjs:145-185 (the pin that is read back, as the shape this card copies); pressure/test/corpus.test.cjs:50-60 (the second pin that is read back); .github/workflows/ci.yml:170-178 (the spike_a4 filter) and :1096-1113 (the job); docs/CI.md:21 (the spike-a4 row) and :103 (its timing row); docs/ops/a35-acceptance.md, the paragraph that hands this over. Open item #126.
Deliverables. (1) The spike-a4 job runs npm run order:check as its own step, before npm run test:ci. Its own step rather than a line folded into test:ci, because a generator check has its own exit code and its own sentence and a reader of the job's log should see which one failed; and before the suite rather than after it, because the check is sub-second and a drifted pin should not wait behind the whole suite. The step's name says what it holds, and it names no card and no item. (2) The spike_a4 path filter gains services/scraper/migrations/**, so a change to the bytes the generator reads starts the job that checks them. A comment beside the entry says why it is there, which is that the generator resolves its source outside the spike, and names the line that does so. (3) test/migration-order.test.cjs gains one case: every extract's recorded sha256 equals the digest of the produced file on disk. The suite then covers the whole generated file and not only the lines inside a claimed span, so the pin is read back by the check that owns it and by the suite that runs beside it, and a future change to either one alone does not silently reopen the hole. (4) docs/CI.md:21 states both commands the job runs and what each one holds, and :21's path column gains the new filter entry. (5) docs/ops/a45-acceptance.md: the three pins of the spike with which command owns each and which run reads each back; the base transcripts; and the sentence that a generator whose source lies outside its own package needs that source in the filter of the job that checks it.
Interfaces. Exposes npm run order:check as a step of the spike-a4 job, so a later change to the generator, to migration-order.json or to the mirror's migration bytes fails that job until the generated files are rebuilt by their own tool. Consumes the job, its Node version, its install step and its working directory, all of which exist. Unchanged: the generator, migration-order.json, sql/bootstrap/llm_reader.sql, every script in package.json, test:ci's file list, the other two pins and their existing cases, and every other job and filter key.
Acceptance. npm run order:check exits 0 in spikes/intelligence-data-path on the branch, and the record carries its output. The job is proven red on a deliberately stale pin and green on the regenerated one, both transcripts in the record: a comment line of sql/bootstrap/llm_reader.sql is given a trailing phrase, the new step exits 1 naming that file and the drift count, and npm run order:check after npm run order:build exits 0 again with the file restored byte for byte, which git diff proves is empty. The same stale tree is run through npm run test:ci and is green, and that transcript is in the record too, because it is the proof that the new step is not duplicating something the suite already does. node --test test/migration-order.test.cjs green on the branch with the new digest case, and that case is proven to fail on the base for its own reason: on the base the produced file and its recorded digest agree, so the case is re-run against the stale copy, where it must fail naming the extract path, and against a checker that reads nothing, where it must also fail. The filter entry is proven live: a run whose only change is under services/scraper/migrations/** starts the spike-a4 job, and the record names that run; on the base the same change leaves the job skipped, and the record names that run too. actionlint -shellcheck=$(command -v shellcheck) .github/workflows/ci.yml clean. The card edits ci.yml, so both checks that read that file are run whole before the push, each with its own test first and with the pinned reader on the path as the infra job supplies it: python3 infra/scripts/test-workflow-timeouts.py then python3 infra/scripts/workflow-timeouts.py check ., and python3 infra/scripts/test-image-frontend-gate.py then python3 infra/scripts/image-frontend-gate.py check ., all four green. The gate's test is the one that proves the new filter entry did not move a literal shape it rewrites. python3 infra/scripts/task-refs.py check . green. The whole ci aggregate green on the pull request, and because the change edits ci.yml every job runs. Record docs/ops/a45-acceptance.md; open item #126 closed.
Out of scope. Folding order:check into test:ci, which is the alternative that loses: package.json:11 composes test from test:ci and test:local, so a generator check inside test:ci changes what npm test means for anyone working in the spike, and a failure would arrive inside a test runner's output instead of as its own named step. Running the spike's test:local half, its container-backed suites or the parity harness in CI, which need a database and are out of the job by design. The generator itself, the shape of migration-order.json and which spans an extract claims. The other two pins, which are read back already and whose cases this card does not touch. Any other job's path filter. Re-measuring the job's duration or its timeout, which the floor file and its own rule own. Whether the spike graduates into a package, which is A9's.
Waits on / Unblocks. Nothing on main: the job, the filter, the generator and the suite are all at cd8022a. A9's draft on pull request #118 edits the same job's checkout depth, so whichever merges second is rebased on the first and the job is re-run under the merge rule, and if A9 merges first this card adds its step beneath the checkout change rather than beside it / A43, which waits on this card as a hard predecessor: its sweep rewrites comments in spikes/intelligence-data-path/sql/bootstrap/llm_reader.sql, the exact file this pin covers, and without this step landed first that sweep goes stale with no run going red, which is how A35's own sweep missed it twice. Also every later change to the generator, to the extracts or to the mirror's migration bytes, which now fails the job that owns them instead of going unseen.
Built as (PR #139 → main d4e36fb, 2026-09-21). The spike-a4 job runs npm run order:check as its own step, named "The generated replay order is what its generator would write now", immediately before npm run test:ci, with a comment that says why it is a step and not a line of the suite: a generator check has its own exit code and its own sentence. The spike_a4 path filter gains services/scraper/migrations/** under a comment naming bin/build-migration-order.cjs:28, where the generator resolves its source outside the spike; it reads every file of that directory, and the comment does not claim less. test/migration-order.test.cjs gains one case: every extract's recorded sha256 equals the digest of the produced file on disk, behind an assertion that the manifest holds at least the two extracts the sibling spans case already treats as an invariant. Its proofs, in a throwaway clone: a stale produced file fails 1 of 15 naming sql/bootstrap/llm_reader.sql while the spans case stays green, which is the hole; an empty extracts list fails; the unmodified tree passes 15 of 15. The generator's transcripts: clean 0; stale 1 with both sentences; the same stale tree through the base suite green at 381 of 381, which proves the step duplicates nothing; order:build then order:check 0 with an empty git diff. Two comments that said the job runs test:ci alone, in the workflow and in the spike's README, were corrected because the change made them false. The filter was proven live by two draft pull requests closed unmerged with their branches deleted, each appending one comment line to services/scraper/migrations/001_rank_tracker.sql: #140 on this card's branch as the base, run 35581917791, where spike-a4 started and failed in 13 s at the new step naming sql/filters/001_rank_tracker.sql.filter.json and migration-order.json as drifted, so one run shows both that the entry is live and that the step catches the change; and #141 on main as the base, run 35581931330, where the same change left the job skipped. A pull request's filter compares its head with its base, which is why #140 saw only the migrations change. The timeout is untouched as the card says; the record states that the floor file holds 56 s for this job while its last runs took 74 to 81 s, and that at 81 s the rule derives 8 minutes and not the declared 7, which A48 owns. A9's open draft widens the same filter key to services/scraper/**; at its rebase that entry subsumes this one, keeps one line and folds this reason into its comment, and the record says so that nobody re-adds the narrow line. Review: co-plan one round; the implementation approved on the first pass, with the live proof, one overstated sentence and one tautological counter closed in a follow-up commit that was re-checked. Record docs/ops/a45-acceptance.md. Open item #126 closed; #150 filed (two tests of the job's suite read files outside its path filter: the transcriber mirror lock, and the scraper's lockfile until A9's draft lands).
A46 · One reader of the workflow shape, imported by both checkers · A · M · S2-W2 · chore/a/a46-one-workflow-reader (surfaced while building A37, open item #131; carded 2026-09-20)
Context. Two checkers read the same two workflows and each carries its own copy of the same four helpers. Measured at cd8022a by comparing the function bodies line for line, all four pairs are identical character for character: load, which shells out to the pinned reader and returns the YAML as data (infra/scripts/image-frontend-gate.py:36-42 and infra/scripts/workflow-timeouts.py:72-78, 7 lines each); flat, which flattens a scalar or a list into a list of strings (:45-46 and :96-97, 2 lines); matches, which decides whether a path filter pattern covers a path (:53-56 and :100-103, 4 lines); and path_filter, which finds a named job's filter block and its entries (:170-182 and :81-93, 13 lines). That is 26 lines of the same code in two files. The item counts four copies of the helpers, and the measurement puts it more precisely: four helpers, two copies of each, and the second copy is not a variant that drifted already but a verbatim duplicate, which is the state in which a drift costs the most, because a reader comparing the two files today sees agreement and will not think to check it after the next edit. The tests carry the same shape and the item does not name them. Comparing infra/scripts/test-image-frontend-gate.py and infra/scripts/test-workflow-timeouts.py finds six shared runs of four lines or more, 47 lines in all: the import block (:24-31 and :34-41), the tail of the subprocess runner that joins the two streams (:143-146 and :238-241), the helper that writes a file into a throwaway tree (:161-164 and :270-273), the failure report that prints the exit code and the output (:167-173 and :276-282), the case runner (:178-195 and :283-300) and main (:204-209 and :316-321). So the shared surface is 73 lines across four files, not 26 across two. The duplication is bounded and the card can say where it stops. infra/scripts/compose-limits.py is the third checker in that directory and it shares nothing with either: compared the same way it has no run of four or more lines in common, because it reads rendered compose output rather than a workflow. So the module this card adds is a workflow reader for two checkers and not a general library for the directory, and naming that boundary is part of the deliverable. Why the duplicate exists is recorded and is not an oversight. A37 held the image gate unchanged on purpose: that gate's own test rewrites ci.yml through exact literal shapes, and any change to the gate is its own card. This is that card. Two things the change has to keep. Both checkers read YAML through the same pinned reader, mikefarah yq v4, which the infra job installs at the version ci.yml:491 names and which each checker will take from a YQ environment variable when it is set (image-frontend-gate.py:37-42, workflow-timeouts.py:73-78); a shared module must keep that override, because the tests use it. And both checkers make the same self-reference: each declares the files it reads about itself (image-frontend-gate.py:31 and :255, workflow-timeouts.py:56 and :276) and requires the infra job's path filter to hold them, so a change to a checker runs the job that runs it. The filter names five of those files today at ci.yml:144-153; the shared module is a sixth input to both checkers and joins both SELF lists and the filter, or the self-reference each checker makes about itself becomes untrue the moment the module changes alone.
Read first. infra/scripts/image-frontend-gate.py whole file, and in particular :20-33 (the imports, the SELF list and the option table), :36-56 (the four helpers, in their first copy), :170-182 (path_filter) and :185-265 (check, and the self-reference at :254-262); infra/scripts/workflow-timeouts.py whole file, and in particular :46-70 (the imports, the floor path, the SELF list and the rule's constants), :72-105 (the same four helpers, in their second copy) and :247-295 (check and its self-reference at :276); infra/scripts/test-image-frontend-gate.py whole file, and in particular :15-20, :43-61 (the line rewrites written against a line's shape rather than its exact text) and :155-209 (the shared test machinery); infra/scripts/test-workflow-timeouts.py whole file, and in particular :230-320 (the same machinery in its second copy); .github/workflows/ci.yml:127-160 (the infra filter and the five entries that name these files, with the comments that say why each is there), :485-495 (the job's env and the reader pin) and :519-533 (the four steps that run both checkers); infra/scripts/compose-limits.py:28-35 and :308-330, as the file that shares nothing and marks the boundary of this card; docs/CI.md, the infra row and the sentence that lists what the image gate's claim covers. Open item #131.
Deliverables. (1) infra/scripts/workflows.py, standard library only, holding the four helpers once: the reader that returns a workflow as data through the pinned reader with the YQ override kept exactly as it is, the flattener, the pattern matcher and the path-filter lookup. Each carries the docstring its first copy had, and the module's own docstring says what it is for and what it is not: a reader of the two workflows this repository owns, used by the checkers that read them, and not a place for anything a single checker needs. (2) Both checkers import the four from that module and define none of them, and nothing else about either checker changes: the same findings, the same sentences, the same exit codes, the same option table, the same rule constants and the same floor file. (3) The module joins both SELF lists and the infra path filter as a sixth named entry, with a comment beside it saying that both checkers read it and that a change to it alone must start the job that runs them. (4) The two tests import their shared machinery from one place as well: a second module beside the first, or the same one, with the choice made on the module's own boundary rather than on convenience, and the card states the choice and its reason in the record. Whichever is chosen, the throwaway-tree helper, the subprocess runner, the failure report, the case runner and main exist once, and every existing case of both tests is unchanged in what it asserts. (5) A behavioural test for the shared module itself, infra/scripts/test-workflows.py, on fixture text in a temporary directory: a workflow read back as data; a scalar and a list flattened the same way; a pattern with **, a pattern with *, a pattern that matches a path exactly and one that does not; a named job's filter found with its entries, a job that has no filter, a filter name that does not exist, and a workflow whose filter block is not where the lookup expects it. Each case is re-run against a module that returns nothing and must fail there, so none can pass by matching nothing. (6) The infra job runs that test beside the four steps it already runs, and docs/CI.md's infra row names it.
Interfaces. Exposes infra/scripts/workflows.py as the one reader of the two workflows, which every later workflow checker imports instead of copying. Consumes the pinned reader and its YQ override, the infra job and its path filter, all of which exist. Unchanged: every finding, sentence and exit code of both checkers; the image gate's option table and its Dockerfile reading; the timeout rule's constants, its floor file and its derivation; every case of both existing tests, in what it asserts and in how it rewrites a line; compose-limits.py and its test; and every other job and filter key.
Acceptance. python3 infra/scripts/test-image-frontend-gate.py and python3 infra/scripts/test-workflow-timeouts.py both green with every existing case unchanged, run locally with the pinned reader before the push, and the case counts recorded beside the base's so the record shows that no case was lost in the move. python3 infra/scripts/image-frontend-gate.py check . and python3 infra/scripts/workflow-timeouts.py check . both green on the branch, and both exit 1 with the same findings and the same sentences on a deliberately broken copy as they do on the base: for each checker the record carries one base transcript and one branch transcript of the same mutation, and the two are compared line for line, which is what says the move changed no behaviour. The derivation table the timeout check prints is byte for byte what it prints on the base. python3 infra/scripts/test-workflows.py green, with each case proven to fail against a module that returns nothing, and each pattern case proven against a fixture rather than against the repository's own filter. The image gate's literal-shape rules still hold, checked by running its own test unchanged: the three build strings each occur exactly once, the aggregate's needs list and its result loop each stay on one line in their present shape, and a filter name stays at twelve spaces with its entries at fourteen. Removing the new module's entry from the infra filter fails both checkers by their self-reference, and that transcript is in the record. actionlint -shellcheck=$(command -v shellcheck) .github/workflows/ci.yml .github/workflows/deploy.yml clean, shellcheck clean over the infra scripts, and python3 -m py_compile on all five scripts. python3 infra/scripts/task-refs.py check . green, so no comment the move carries names a card or an item. The whole ci aggregate green on the pull request; the change edits ci.yml, so every job runs. Record docs/ops/a46-acceptance.md, holding the four helper pairs with their line ranges on the base, the six shared runs of the two tests, the case counts before and after, and the paired transcripts; open item #131 closed.
Out of scope. Changing what either checker holds. The image gate's option table, its Dockerfile reader and its build-string rules; the timeout rule's two terms, its ceiling, its floor file and any number in it. infra/scripts/compose-limits.py and its test, which share no line with either checker by measurement and would be pulled in only to make the module look general. A general library for infra/scripts: the module is a reader of two workflows, its docstring says so, and the next checker that reads something else gets its own. Moving either checker or its test to another directory or renaming it, which would break the filter entries and the self-references for no gain. Adding a job, a step budget or a path filter key beyond the module's own entry. Re-measuring any job's duration, which the floor file owns.
Waits on / Unblocks. A48, as a hard predecessor and for one reason: both cards touch infra/scripts/workflow-timeouts.py and its floor file, and A48 replaces every measured maximum and re-derives every declared limit, which changes what the check prints and what the workflow declares. A48 lands that number first; this card then proves an extraction that changes no behaviour, by comparing a base transcript with a branch transcript of the same mutation, and a moving floor underneath would make that comparison meaningless. Nothing else: both checkers, both tests, the filter and the job are on main at cd8022a, and A37's Built-as note records that the image gate was held unchanged for this card. It shares ci.yml with A45 and with A9's pull request #118, so whichever merges second is rebased on the first and the whole aggregate is re-run under the merge rule; the shared surface with A45 is the infra and spike_a4 filter blocks of the same file / every later workflow checker, which imports the reader instead of copying it, and every later change to a path filter's grammar or to the pinned reader, which now lands in one file rather than in two that must be kept equal by hand.
Amended 2026-09-21 (co-plan, two rounds). Three points the card's measurement at cd8022a could not see. (1) This card re-measures infra, and only infra. The card adds a step to the infra job, and the repository's rule says a card that changes a job's shape re-measures that job and carries the new maximum, its run id and the re-derived timeout-minutes together in one commit, where a reviewer sees both. A48's record says the same from the other side: it left infra's floor at 631 s, the maximum on main, which still describes the job's OLD shape (the job takes about 270 s since A48), and it names the first card that changes this job's shape again as the one that re-derives it. This is that card, so the sentence under Out of scope that excludes re-measuring any job's duration yields for this one job. The order keeps the extraction's proof meaningful: the extraction commit changes no number, so the paired base and branch transcripts and the byte-for-byte derivation table are made against an unmoved floor; then one follow-up commit writes infra's floor from this pull request's own first run, with the run id, and the re-derived limit in ci.yml and the row in docs/CI.md, all three in that one commit. No other entry moves. (2) The filter proof needs two lines removed, not one. infra/** already matches the new module, so removing its named entry alone cannot fail either checker; the mutation that proves the self-reference removes the umbrella entry and the named entry together, the way the timeout check's own test already does, and the record carries that transcript for both checkers. (3) The test machinery gets its own module. The reader's boundary is a reader of the two workflows used by the checkers, so the shared test machinery lives in a second, test-only module, infra/scripts/checker_testkit.py, and the image gate's own Dockerfile helper stays in its test. Only workflows.py joins the two SELF lists and the infra filter as a named entry, because the test kit is an input of the tests and not of either checker. python3 -m py_compile runs over all seven files: the two checkers, their two tests, the reader, the kit and the reader's behavioural test. The case counts to hold are 19 for the image gate's test and 54 for the timeout check's test at 644a25c.
Built as (PR #143 → main cb6f1d6, 2026-09-21). infra/scripts/workflows.py, standard library only, holds load (the pinned YAML reader with its YQ override, kept exactly), flat, matches and path_filter once. At the cut all four pairs were still byte-identical in the two checkers (26 lines; empty diffs and equal digests), and the module's copy diffs empty against both originals, so there was no drift to decide. image-frontend-gate.py and workflow-timeouts.py import the four and define none; both lose the two standard-library imports only load used. The module's docstring states its boundary: a reader of the two workflows this repository owns, used by the checkers that read them, and not a library for the directory; compose-limits.py shares no line with either checker by measurement and stays out. infra/scripts/checker_testkit.py, test-only, holds the tests' shared machinery once (Cases: the throwaway-tree writer, the subprocess runner, the report and its counters, the edit applier, the judge; the driver and the summary); what differs stayed in each test: the image gate's Dockerfile helper, each test's own wording and all-passed line, the timeout test's preamble and its ran-count guard. The shared runs of the two tests measured 46 lines in six runs at the cut (the card's 47 at cd8022a) and 15 lines of imports and entry point after the move. Every file that imports a sibling sets sys.dont_write_bytecode first, the pattern the archive scanner already uses, so a run leaves no bytecode directory in the checkout. The reader joins both checkers' SELF lists and the infra path filter as a named entry with its comment, at the indent the image gate's literal-shape rules require. infra/scripts/test-workflows.py tests the reader on fixture text: 10 cases, each also run against a stand-in that answers nothing and failing there, fixtures only, failing and never skipping without the reader; the infra job runs it and docs/CI.md names it. The proof that nothing changed: for each checker the same mutation script on the base and on the branch exits 1 on both with an EMPTY diff of the transcripts; the timeout check's derivation table is byte-identical (24 lines, 4,880 bytes); unmutated, each check differs from the base by exactly one line, the sentence that reports how many files it reads about itself, which this card grows by one; both existing tests print byte-identically, 19 and 54 cases. The filter proof took two lines, as the co-plan found: removing the named entry alone passes, because the umbrella infra/** still matches, and removing both fails both checkers by their self-reference (the gate with four findings, the timeout check with exactly one, naming the reader). The floor. The card adds a step to the infra job, so by its amendment it re-measured that job and only that job: the extraction commit moved no number, and a follow-up commit wrote the floor from the pull request's own first run of the new shape, 280 s on run 35618886010 (was 631 s, which described the shape before A48), with the limit 27 to 15 minutes by term (a) and the docs/CI.md row in that one commit; the pair is proven to move together in both directions. The pull request's second run took 327 s for the same job, which fits the 900 s limit; it is margin evidence and the floor was not chased, which is now a written stopping rule. Found and not fixed, in the record: the kit's judge has a branch with no caller, dead on the base too; one now-redundant bytecode line in the image gate's test; a YQ that names a missing path raises the interpreter's error and not the reader's sentence, as on the base. Review: co-plan two rounds (it amended the card); the extraction and the floor commit each approved on the first pass. Record docs/ops/a46-acceptance.md. Open item #131 closed.
A47 · A job's install boundary is asserted where the job runs · A · M · S2-W3 · chore/a/a47-install-boundary-holds (surfaced while building A9's L-B, open item #137; carded 2026-09-20)
Context. The instance is recorded in docs/ops/a9-acceptance.md:1121, "The spike's route to the package", and its numbers were measured on A9's branch: the parity harness required the adapter by a path across the tree, ../../../../services/scraper/packages/db/db.cjs; the pool under it requires the Postgres driver, which Node looks for from where that module really sits, under services/scraper; the spike-a4 job installs inside spikes/intelligence-data-path and nowhere else, so under that job services/scraper has no installed dependencies at all; the child process that spikes/intelligence-data-path/test/suite-turn.test.cjs:1060 starts, for the looped case at :1087, died at load. On clean archives with the spike-only install the branch failed 12 runs of 12 and main failed 0 of 12, while the same test passed 10 times out of 10 in a working tree, which happens to hold services/scraper/node_modules. The fix was to depend on the package rather than to point at it, with install-links=true in a committed .npmrc so that npm copies instead of linking, because Node resolves a symlink to its real path before it looks for that package's own dependencies. A9 (#118) has merged, with it ca05fa9 and b296bd7, and A46 (#143, cb6f1d65) has merged the shared workflow reader. Measured at main 78b90301, the shape now has three subjects. git grep '"file:' -- '*package.json' returns three path dependencies, all on the same package, services/scraper/packages/db (meta-ads-db, which declares pg 8.23.0): services/scraper/package.json:21 file:packages/db, services/scraper/admin/package.json:18 file:../packages/db, and spikes/intelligence-data-path/package.json:33 file:../../services/scraper/packages/db. git ls-files '*.npmrc' returns one, spikes/intelligence-data-path/.npmrc, which sets install-links=true at :10, so the spike copies. The two scraper ones have no .npmrc and link: their lockfiles mark the entry "link": true (services/scraper/package-lock.json:3152-3155, services/scraper/admin/package-lock.json:3852-3855). Both work on main, for two different reasons. The scraper's link target lies inside the directory that the same install step installs, so the target's search for pg walks up into services/scraper/node_modules. The admin's link target lies outside services/scraper/admin, and it finds pg only because the same job installed services/scraper one step earlier (.github/workflows/ci.yml:454 before :458-459): the search from services/scraper/packages/db walks up into services/scraper/node_modules/pg, not into the admin's own install. That is the trap of #137, held up by step order and written down nowhere in the workflow. services/scraper/Dockerfile:36-37 works around the same fact for the image by linking /app/node_modules to the admin's install and resolving pg from /app/packages/db. The decision this card builds is to accept a link without adding any .npmrc, on two conditions: the link's real path stays inside the checkout, and the same job has already installed the target's dependencies, which is the same step when the target sits inside the directory being installed and an earlier step otherwise; and to make the step order the admin rests on a checked fact rather than an accident. Two modules leave their own package: e2e/production-states.spec.ts:7 imports ../apps/web/src/shell/production-routes, and every job that installs e2e installs apps/web first; and spikes/intelligence-data-path/pressure/test/db-driver.test.cjs:855 resolves the adapter by a path across the tree (require.resolve('../../../../services/scraper/packages/db/db.cjs')), a key that never enters the cache under the copy install, so its two assertions cannot fail. Beyond these two, searching apps/web, e2e, services/scraper, services/agent-runtime/console, spikes/intelligence-data-path and services/workos/service for a require or an import of a relative path that climbs three levels or more finds two hits, apps/web/src/shell/pages/admin/bindingRules.test.ts:3 and guardView.test.ts:3, and both resolve to apps/web/src/lib/adminApi, inside their own package; the ../../ requires under services/scraper/packages/db/test/ stay inside packages/db. The population the check guards is wider than the item's phrase. Four workflows exist: ci.yml, deploy.yml, meta-ads-staging-rehearsal.yml and staging-app-role-preflight.yml, and only ci.yml carries an install command. Twelve install commands run in nine of its jobs: the repository root with a workspace install at :259 (contracts); apps/api twice, once per job, into a virtual environment at :284-286 (api) and :314-316 (contracts-consumers); apps/web at :341 and e2e at :414 (web); services/scraper at :454 and services/scraper/admin at :458-459 (scraper); services/agent-runtime/console at :484 (runtime); services/workos/service at :509 (workos); apps/web at :1006 and e2e at :1079 (e2e); and spikes/intelligence-data-path at :1221 (spike-a4). Three more jobs build a Python environment under $RUNNER_TEMP, outside any directory: mirrors with hash-pinned requirements at :724-726, spike-a4 at :1241-1242 and meta-ads-migration at :1281-1282. Three jobs install in two directories each, web, e2e and scraper, and in scraper one directory is nested inside the other, so a boundary belongs to an install step and not to a job, and a check that assumed one boundary per job would be wrong about three of the nine. One distinction has to be in the check or it will refuse working code. A job's checkout holds the whole tree, so reading a file in another directory is fine and several tests do it: the api contracts tests read fixtures under packages/contracts/ by path (apps/api/app/backend/tests/contracts/test_launch_contract.py:6, test_launch_package.py:6, test_auth_samples.py:3, test_bindings_contract.py:6) and nothing about that is unsafe. What is unsafe is resolving code, because a module's own dependencies are looked for from where that module really sits, and only an install puts them there. The boundary this card asserts is about resolution and not about files.
Read first. docs/ops/a9-acceptance.md:1121 onward, the section "The spike's route to the package" whole, including the paragraph on why the link setting lives with the project rather than in the job and the paragraph on the stale copy an install leaves behind; spikes/intelligence-data-path/test/suite-turn.test.cjs:1050-1090, the child process the failure surfaced in and what its case reads from the child's exit code; spikes/intelligence-data-path/package.json:33 and .npmrc; services/scraper/package.json:21, services/scraper/admin/package.json:18, services/scraper/packages/db/package.json and the two lockfile entries at services/scraper/package-lock.json:3152-3155 and :4224-4234 and services/scraper/admin/package-lock.json:35-44 and :3852-3855; services/scraper/Dockerfile:25-37, the image's own answer to the same resolution; .github/workflows/ci.yml:233-263 (the workspace install at the root), :264-324 (the two Python jobs and their virtual environments), :325-437 (the web job, which installs at :341 and :414), :438-466 (the scraper job, which installs at :454 and then :458-459, and runs the admin's tests at :464-466), :468-514 (the runtime and workos jobs), :698-732 (the mirrors job's hash-pinned environment), :934-1147 (the e2e job, which installs at :1006 and :1079), :1198-1255 (the spike-a4 job, its install at :1221, its Python environment at :1241-1242 and its tests at :1248-1249) and :1257-1286 (the meta-ads-migration job); .github/workflows/ci.yml:142-168, the infra path filter, where each workflow checker names its own inputs (:162-164 and :168), and :592-612, where the infra job runs test-workflows.py first and then each checker's test before its check; infra/scripts/workflows.py and infra/scripts/test-workflows.py, the shared reader this checker imports; infra/scripts/image-frontend-gate.py whole file as the house pattern for a standard-library checker that reads the workflows through that reader (:32) and names its own inputs (SELF at :36, the filter self-check at :225-233), with infra/scripts/test-image-frontend-gate.py as the pattern its test copies; infra/scripts/workflow-timeouts.py:1-95 and docs/CI.md:87, the rule that a card which changes a job's steps re-measures that job, and docs/ops/v57-acceptance.md, sections "The timeout check" and "The floor", as the pattern for doing it; apps/api/app/backend/tests/contracts/test_launch_package.py:1-20, as the cross-directory read that is safe and must not be refused; AGENTS.md:26 "How to run checks (per package)". Open item #137.
Deliverables. (1) The rule, written once in docs/CI.md and in the checker's docstring: code a job's tests resolve must have its real path under a directory that job has installed into by that step. Reading a file across the tree is not covered and is not affected, because the checkout holds the whole tree; resolving a module is covered, because a module's own dependencies are looked for from where that module really sits. A dependency declared by path is accepted in two shapes and no others. A copy, where the package's project commits an .npmrc beside it setting install-links=true, so the target's dependencies are installed into the project's own install. And a link, with no such setting, only when the target's real path stays inside the checkout and the install directory whose node_modules the target's own dependency search reaches is installed by the same job at or before this install: in the same step when the target sits inside the directory being installed, as services/scraper does, and in an earlier step otherwise, as the admin does. (2) infra/scripts/install-boundary.py check [<root>], standard library only, importing load, flat, matches and path_filter from infra/scripts/workflows.py as the two other checkers do, reading all four workflows, one line per finding and exit 1 on any. It derives each install directory from the workflow itself, per step and in step order, so the three jobs that install in two directories each get two boundaries and the nested services/scraper/admin is its own. A job that runs a test command in a directory it never installed into is a finding naming the job, the directory and the command. A dependency declared by path that is neither a copy nor an accepted link is a finding naming the package file and line, the dependency, its real path, and the install it needs and where that install sits in the job, or that the job has none. A relative require or import that leaves its own package root, which is the shape the instance started from, is a finding naming the file, the line and the package it left, with one exception: when every job that installs the importing package has installed a directory at or above the target earlier in the same job, the check passes it and prints that order, and swapping or removing that earlier install in any such job fails the check naming the import. The relative-import scan covers exactly six source roots, the npm and pnpm install directories below the repository root as measured at 78b90301: apps/web, e2e, services/scraper (which holds services/scraper/admin and services/scraper/packages/db), services/agent-runtime/console, spikes/intelligence-data-path and services/workos/service. They are one constant in the checker, SCANNED, beside SELF, and the checker's docstring says that a new install directory joins that constant and the filter in the same commit. Every accepted link is printed as a passing line that names the step order it rests on, so the admin's line reads that meta-ads-db resolves pg through services/scraper, installed at ci.yml:454 before the admin's install at :458, and the dependency is explicit in the job's log. Every path it refuses is named with the reason, and a package it has no reader for is a finding rather than a silent skip, in the house pattern. (3) The runtime half, in the same jobs, for the three install steps that carry a dependency declared by path and for no others: one step directly after ci.yml:454 (scraper), one directly after :458-459 (scraper, admin) and one directly after :1221 (spike-a4). Each resolves the path dependency and every dependency it declares, from that install directory, prints each resolution with its real path, and fails when a resolved real path is not under a directory the job has installed into by that step, so the job's log is the proof and nobody re-types it. (4) The infra job's path filter at ci.yml:142-168 covers every file the checker reads, the self-reference the two existing workflow checkers make. It gains exactly these entries: the checker and its test, infra/scripts/install-boundary.py and infra/scripts/test-install-boundary.py; the three package files that declare a path dependency, services/scraper/package.json, services/scraper/admin/package.json and spikes/intelligence-data-path/package.json; the target's package file, services/scraper/packages/db/package.json; the spike's setting, spikes/intelligence-data-path/.npmrc; and one entry per scanned source root, 'apps/web/**', 'e2e/**', 'services/scraper/**', 'services/agent-runtime/console/**', 'spikes/intelligence-data-path/**' and 'services/workos/service/**', because the relative-import rule reads every source file under them and no job that covers one of those roots, the scraper and spike-a4 jobs among them, runs this checker. The package files above stay listed by name even where a root entry also matches them, so the claim the check makes about its own inputs is readable beside the image gate's. The shared reader infra/scripts/workflows.py is already listed at :168, and the four workflows are already covered (ci.yml through the *workflow anchor at :143, the other three at :145-147). No lock file is listed, because the checker reads none. Its SELF list holds the checker, its test and workflows.py, as image-frontend-gate.py:36 does. The filter self-check, in the shape of :225-233, checks every file in SELF, every package file and .npmrc it reads, and every root in SCANNED against the infra filter: a scanned root is covered only when a filter entry matches a path under it, and each file or root that is not covered is a finding naming it and saying that a change there does not run the check. (5) infra/scripts/test-install-boundary.py, in the shape of the existing checker tests: throwaway copies of the workflows and of small package trees in a temporary directory, one broken copy per sentence, each exiting 1 with every line matching that case's pattern. Its cases are a link whose target sits inside the directory the same step installs, which must pass; a link whose target sits outside, with the covering install one step earlier in the same job, which must pass; the GUARD, that same copy with the two install steps reordered so the admin installs first, which must fail naming the job, the admin's package file and line, the dependency, and services/scraper as the install it needs, found only after it; the same link with the covering install in another job, which must fail; a link whose real path leaves the checkout, which must fail; a copy with install-links=true beside it and no install of the target anywhere, which must pass; the same with an .npmrc that sets something else, which is then a link and must fail without a covering install; a relative import that leaves its package root; the e2e spec's import with the apps/web install moved after, or removed from before, the e2e install in each job that installs e2e, which must fail naming the import; a relative import that climbs and stays inside, which must pass; a file read by path across the tree, which must pass, because refusing it would refuse the api contracts tests; a job that runs a test command in a directory it never installed into; a job that installs in two directories, where both boundaries must be recognised and a command in either must pass; an install step added to one of the three workflows that carry none today, which must be found; a workflow whose install step carries no working directory, so the root is the boundary; the filter with this checker's own entry removed; and the filter self-test, where the infra filter of a throwaway copy of ci.yml has each of the six scanned root entries removed in turn, and each copy must fail naming that root and only that root, then has the spike's .npmrc entry removed and must fail naming it, while the unedited filter must pass. Every refusal case is re-run against a checker that reads nothing and must fail there. (6) The infra job runs the test then the check, after test-workflows.py and in the order its other checker pairs take, and docs/CI.md:17, the infra row, names both. (7) The card changes the steps of three jobs, scraper, spike-a4 and infra, so each is re-measured from the pull request's first green run of its new shape, in the pattern of docs/ops/v57-acceptance.md: the entry in infra/scripts/workflow-timeouts.json (scraper 267 s on 35936167480, spike-a4 90 s on 35800922300, infra 280 s on 35618886010), the derived timeout-minutes (ci.yml:443, :1203, :558) and the row in docs/CI.md (:98, :106, :102) change together in one floor commit. A higher observed maximum replaces its entry. A lower one follows the standard explicit rule for each of the three entries (infra/scripts/workflow-timeouts.py:42-56, docs/CI.md:87): the old number is either moved into a high_water mark, with its run, the job's current shape and one sentence saying what made that run slow, or deleted, and either way the diff shows it and the record says which was done and why. Amended at review. The RED-on-base shape, the spike proof's observed result and the e2e install-order exception above were settled at review (2026-09-24); the fixed replay test db-driver.test.cjs:855 now resolves meta-ads-db by name.
Interfaces. Exposes infra/scripts/install-boundary.py and its rule as the place a cross-directory dependency is declared safe, so a later card that points one package at another meets the check in CI instead of in a failed run, and a later card that reorders the scraper job's installs meets the admin's passing line turned into a finding. Consumes the infra job, the shared reader infra/scripts/workflows.py and its test, and the infra path filter. Unchanged: every install step and its order, every test command, every package's dependencies and lock files, the one committed .npmrc, services/scraper/Dockerfile, every path filter key but the infra one, and every cross-tree file read, which the rule does not cover.
Acceptance. Builders run only targeted tests, red-on-base proofs and fast static checks locally; full suites run only in GitHub CI. python3 infra/scripts/test-install-boundary.py green, with each broken copy exiting 1 and every line matching its case's sentence, and each case proven to fail on the base sources for its own reason: on the base there is no checker, so every case whose fixture applies to the base sources names the missing checker and fails loudly rather than passing; the cases whose fixture edits a line only the branch adds (the resolution steps and the infra filter entries) cannot apply there, and the record lists them apart as branch-only GUARD cases and proves each RED on the real branch tree; the base checker_testkit.py does not create a parent directory for an added file and crashes after four cases, so the base run uses the branch's helper, and the record says so; both transcripts are in the record. The filter self-test is proven RED: each of the six copies with one scanned root removed from the infra filter exits 1 naming that root, and the transcript of all six is in the record. python3 infra/scripts/install-boundary.py check . green on the branch, and its passing lines for the three path dependencies quoted in the record, the admin's with the step order it rests on. The GUARD is proven on the real workflow and not only on a fixture: a scratch copy of ci.yml with the scraper job's two npm ci steps swapped must fail the check naming services/scraper/admin/package.json:18 and services/scraper, and the unswapped file must pass; both transcripts in the record. The check is proven to catch the instance that caused the item, reconstructed rather than asserted: a copy of the spike in a temporary directory, with the adapter required by a path across the tree as A9's branch first had it, must fail the check naming that file and its package, and with the dependency declared and the setting committed must pass; both transcripts in the record. The runtime half is proven the same way on clean archives with nothing installed anywhere else in the tree, because a working box is what hid this the first time: in the spike, with the setting removed from the committed file and the install repeated, the resolution step must fail, naming the adapter's real path outside the boundary and naming the driver as not resolvable from that path (the driver cannot be found at all, which is the stronger boundary result), and with the setting present it must pass and print the path inside; in the scraper, with only services/scraper/admin installed, the admin's resolution step must fail naming pg, and with services/scraper installed first it must pass and print services/scraper/node_modules/pg; all four transcripts in the record. The record states the base counts, each with the line it was read from: three dependencies declared by path, one committed .npmrc, two relative module paths that leave their package (one accepted by install order and one fixed), four workflows, and the twelve install commands in nine jobs with their directories, plus the three $RUNNER_TEMP Python environments. The card edits ci.yml, so every check that reads that file is run whole before the push, each test before its check and with the pinned reader on the path as the infra job supplies it: python3 infra/scripts/test-workflows.py, then python3 infra/scripts/test-image-frontend-gate.py and python3 infra/scripts/image-frontend-gate.py check ., then python3 infra/scripts/test-workflow-timeouts.py and python3 infra/scripts/workflow-timeouts.py check ., all five green, on the step commit and again on the floor commit. Running every test and not only every check is the proof that the new steps and filter entries did not disarm a mutation any of them rewrites, and that each changed job still declares the limit the rule derives for it. actionlint -shellcheck=$(command -v shellcheck) clean on all four workflows, shellcheck clean over the infra scripts, python3 infra/scripts/task-refs.py check . green. The whole ci aggregate green on the pull request; the change edits ci.yml, so every job runs. Record docs/ops/a47-acceptance.md; open item #137 closed.
Out of scope. Running every job's suite a second time on a clean archive, which is the alternative that loses: it would catch this class and more, and it costs a second full run of every suite on every pull request against a pipeline whose longest job already runs over eight minutes (e2e, 514 s, infra/scripts/workflow-timeouts.json:14), and it still only reports after the fact instead of naming the rule that was broken. Making developer boxes match the runner, by removing installed dependencies before a local run or by refusing a dirty tree, which loses because nothing can enforce it on a box the repository does not own and the failure would still surface at the same cost. Requiring a copy everywhere by adding an .npmrc to services/scraper or services/scraper/admin, which would change two packages' install shape and the image's resolution at services/scraper/Dockerfile:36-37; this card accepts both links as they are and checks the order they rest on. Cross-tree file reads, which the rule does not cover and which several api tests depend on. Python: the two apps/api virtual environments and the three $RUNNER_TEMP environments in mirrors, spike-a4 and meta-ads-migration, which resolve by interpreter path rather than by a per-directory search and have no equivalent of the shape this guards; a card that wants them covered brings the failure that motivates it. The workspace install at the repository root and how pnpm links its members, which is its own mechanism. The stale-copy hazard a copying install leaves for anyone who edits a package and does not reinstall, which A9's record states and which is a runbook sentence rather than a check. Any dependency version, and any lock file.
Waits on / Unblocks. Merges before V54, because both cards edit ci.yml, docs/CI.md and infra/scripts/workflow-timeouts.json; V54 rebases on this card's merge and measures its web floor after it. Waits on nothing: A9 (#118, with ca05fa9 and b296bd7) and A46 (#143, cb6f1d65) are on main, so the check has three real subjects and imports the shared reader instead of adding a copy of it. Unblocks V54, and every later card that points one package at another, which declares it and meets the check in the same commit; and every future resolution failure, which now names the boundary and the step order on the box that broke it instead of appearing only in CI a cycle later.
A48 · The pipeline's longest step is understood, and the floor under it is current · A · M · S2-W2 · chore/a/a48-longest-step-understood (surfaced while building A36, open item #138; carded 2026-09-20)
Context. A36 moved the pole off the browser job and recorded that the infra job became it, with pressure.sh the longest step inside it, a median of 257 s and a range of 76 s to 331 s, and it filed the spread as unexplained. Both halves were re-measured at cd8022a over the last 100 completed runs of .github/workflows/ci.yml, in which the path-filtered infra job executed 60 times, and both numbers moved. The job is now the pole by a wider margin: over the most recent 30 completed runs its median is 580 s against the browser job's 462 s, and over the eight of those that ran on main its median is 560 s and its maximum 631 s (run 35505380176). The step is now a median of 418 s, which is 72 per cent of the job it sits in. The spread the item asks about does not survive the measurement. The item reads it as run-to-run variance, "the same step, on the same runner class, over the same tree, takes four times as long on one run as another". Over the 60 runs the step's duration is not scattered between 76 s and 421 s; it sits in six tight clusters, and every move between clusters is a merge. It is 76 s to 77 s on main and on every branch without the intelligence spike's pressure drivers, over seven main runs from 35201165745 to 35467308764. It is 175 s to 185 s, then 212 s to 213 s, then 256 s to 258 s, then 329 s to 331 s on the spike's own branch as its stacked fixes landed on it. It is 331 s on main at run 35473356283, the first main run after that spike merged. It is 371 s after A40 merged (35486284919), 390 s after A38 (35501986996) and 417 s to 421 s from 35504121747 onward after A39. Inside a cluster the step varies by at most about 5 s across several runs and several branches. So the step is not erratic, it is growing: 5.5 times in the window, one card's cases at a time, and the 76 s run and the 331 s run the item compares are different trees. The mechanism is measurable in the harness rather than guessed at. infra/smoke/test-pressure.sh is 1,027 lines and runs 130 cases. It stubs sleep to a no-op on the path (:290), and two calls bypass the stub deliberately, /bin/sleep 0.05 inside a mock at :86 and /bin/sleep 5 in one slow case at :275, so the stub is not where the time goes. The driver does not take its waits from sleep at all: infra/smoke/pressure.sh:763-770 measures both of its waits against the clock, with now() at :168, and the comment there says why, which is that a sleep that returns early, a signal or a stub, would turn a wait into no wait and race past the guard the leg exists to honour. The cases that actually run a load therefore pay real wall clock, and the harness keeps that clock small: a two-second baseline, a three-second window, a two-second drain and no poll interval (test-pressure.sh:333, and the same shape at :860), while the cases that carry the real 600 s and 1200 s windows (:819, :836, :915) are refused before anything starts and cost nothing. At 418 s over 130 cases the step averages 3.2 s a case, which is the size of that per-case clock. The growth and the mechanism agree, and the thing to remove is not variance, because there is none to remove. There is a second finding the item does not carry, and it is the reason the card belongs in this wave rather than later. infra/scripts/workflow-timeouts.json records the infra job's measured maximum as 530 s, read from run 35498445351, and five later main runs are 542 s, 579 s, 582 s, 588 s and 631 s. A37's rule takes twice the maximum plus five minutes, so 530 s gives the 23 minutes ci.yml:485 declares, and the current maximum of 631 s gives 27. The check compares the declared value with the number derived from the floor file and not with any run history, which A37's own scope states, so it is green today on a floor nobody has re-measured. Nothing is red and nothing will be soon, because 23 minutes is still 2.19 times the observed maximum, but the pipeline's longest job is running on a limit derived from a number that is already a hundred seconds out of date, and every card that changes a job's shape is told to re-derive from that file.
Read first. docs/ops/a36-acceptance.md "What this cannot move, and what is left open" whole section, which files this item, and "Every job, all three runs" and "The phase table" for the method its numbers were taken by; infra/smoke/test-pressure.sh:1-10 (what the harness mocks and why), :60-95 (the bounded mock and its one real wait), :265-300 (the mock responder, the slow case and the sleep stub), :320-345 (the small clock the running cases set) and :810-930 (the cases that set the real window and are refused before anything starts); infra/smoke/pressure.sh:60-70 and :100-110 (the window variables and their defaults), :160-175 (now), :440-460 (the refusal that needs at least the full window), :610-630 and :670-760 (the legs and their poll loops) and :760-775 (the two clock waits and the comment that says why they are not counted sleeps); .github/workflows/ci.yml:480-495 (the infra job, its declared limit at :485 and its pinned tools at :490-491) and :584-585 (the step); infra/scripts/workflow-timeouts.json, the infra entry and the ci.yml block around it; infra/scripts/workflow-timeouts.py:55-70 (the constants) and :127-132 (derive), with A37's rule in docs/CI.md and its sentence that a card which changes a job's shape re-derives that job's number; A38, A39 and A40's Built-as notes, which are the three merges the last three clusters line up with. Open item #138.
Deliverables. (1) The measurement the item asks for, taken per case rather than per run, because the per-run measurement is already done and says the answer is not in the runner: the harness reports each case's wall clock, the report is written to the job's log and to the record, and the cases are ranked. The report is produced by the harness itself and not by a one-off script, so the next card that adds a case sees what its case costs at the moment it adds it. (2) The explanation, recorded with its evidence rather than asserted: the six clusters with the merge that moved each one, the within-cluster spread that shows the runner is not the variable, and the per-case table that shows where the 418 s sits. The record states plainly that the item's premise of run-to-run variance did not reproduce and what replaced it. (3) The lever, taken where the measurement puts it. The driver's two waits are measured against the clock on purpose, so the harness cannot make them cheaper by stubbing sleep harder without destroying the property pressure.sh:763-770 exists to hold. The driver therefore takes its clock the way it already takes its windows, from something the harness can supply, and the harness advances it instead of waiting on it; the one case that proves the clock rule itself, that a returning-early sleep does not shorten a wait, keeps a real clock and is named as the case that does. Every other case's assertions are unchanged, and the record shows the step's new cost beside its old one, case by case. (4) The floor re-measured and the limit re-derived: infra/scripts/workflow-timeouts.json's infra entry is replaced with the maximum measured over the last 30 runs of the workflow on main at the cut, with the run id it was read from, and ci.yml's declared timeout-minutes for that job is whatever A37's rule then gives, which the check requires it to equal exactly. Every other entry of the floor file is re-measured in the same pass, because a file half re-measured is worse than one nobody touched, and the record carries the whole table with its run ids. (5) docs/CI.md: the infra row states which step dominates the job and why it costs what it costs, and the timing table's infra and spike-a4 rows are refreshed from the same measurement. (6) docs/ops/a48-acceptance.md: the cluster table, the per-case table before and after, the floor table with run ids, the derived limits before and after, and one sentence naming the rule the next person should reach for, which is that this step's cost is the number of cases times the clock a case is made to wait.
Interfaces. Exposes the per-case cost report from the pressure harness, which every later card that adds a case reads, and the re-measured floor entries, which every later card re-derives from. Consumes the infra job, the harness, the driver and A37's rule and check, all of which exist. Unchanged: what any case asserts, every refusal, verdict and cleanup sentence the harness proves, the driver's behaviour on a real run, the window defaults at pressure.sh:106, the acceptance bar's minimum window at :454-455, and every other job.
Acceptance. bash infra/smoke/test-pressure.sh green with all 130 cases passing and the same assertions, and the case count recorded beside the base's so the record shows none was lost. The property that motivated the clock rule is proven, not assumed: one case keeps a real clock and fails on a driver whose wait can be shortened by a returning-early sleep, and that case is proven to fail against such a driver, with the transcript in the record. Every case whose clock the card changes is proven to fail on the base for its own reason where it asserts anything new, and where it asserts nothing new the record says so and pairs its base and branch transcripts instead. The step's cost is recorded three times: on the base, on the branch, and per case on both, taken from the harness's own report rather than re-typed. python3 infra/scripts/workflow-timeouts.py check . green on the branch with the re-measured floor and the re-derived limits, and exiting 1 with the named job when the infra entry is put back to 530 s while the declared limit stays at the new number, which is the proof that the floor and the workflow move together; both transcripts in the record. python3 infra/scripts/test-workflow-timeouts.py green. The card edits ci.yml, and the image gate reads the same file, so python3 infra/scripts/test-image-frontend-gate.py then python3 infra/scripts/image-frontend-gate.py check . are run beside the timeout pair and are green, with the pinned reader on the path as the infra job supplies it; the gate's own test is what proves the re-derived limit line did not move a literal shape the gate rewrites. actionlint -shellcheck=$(command -v shellcheck) .github/workflows/ci.yml .github/workflows/deploy.yml clean and shellcheck clean over the smoke scripts. python3 infra/scripts/task-refs.py check . green. The whole ci aggregate green on the pull request, with the infra job's own duration and its step durations recorded beside the limits so the record shows the margin the job actually had; the change edits ci.yml, so every job runs. Record docs/ops/a48-acceptance.md; open item #138 closed, and any job whose re-measured maximum moves its derived limit is named in the record with its new number.
Out of scope. Hunting run-to-run variance in the runner, which is the reading the item carries and which the measurement refused: within a cluster the step varies by about 5 s across several runs and several branches, so there is nothing there to remove. Running the 130 cases in parallel, which is the alternative that loses: the harness asserts on the ordering and the contents of one shared mock log, so parallel legs would race each other's assertions, and the work to separate them is larger than the work to stop waiting. Moving the step into a job of its own, which loses because the ci aggregate needs every job, so the pipeline's wall would not move and only the name of the pole would. Removing any case, weakening any assertion, or changing what the harness mocks. The driver's real behaviour, its window defaults or the acceptance bar's minimum window, none of which this card touches. The browser job and its suites, which A36 measured and left. The type-check passes of the web job, which are their own open item. Making any other job faster: this card re-measures every floor entry because a half-measured file is worse than an untouched one, and it changes no job's work but this step's clock.
Waits on / Unblocks. Nothing: the job, the harness, the driver, the floor file and A37's check are all on main at cd8022a. It shares ci.yml and the floor file with any card that adds or changes a job, so whichever merges second re-measures that job's entry and re-derives its limit in the same commit, which is what A37's rule already requires; it shares infra/smoke/** with the spike_a4 path filter, so a change there starts that job too / A46, which follows this card and not the other way round: it extracts the workflow reader that infra/scripts/workflow-timeouts.py uses and proves the extraction changed no behaviour by comparing base and branch transcripts of the same mutation, and this card moves every floor entry and every derived limit underneath, so the comparison is only meaningful once the new numbers are in. Also every later card that adds a case to this harness, which now sees what its case costs when it adds it rather than in a pipeline measurement a wave later; and every later card that changes a job's shape, which re-derives from a floor that is current instead of from one that is a hundred seconds out of date.
Built as (PR #142 → main 644a25c, 2026-09-21). The item's premise did not reproduce, as the card found: the step was not erratic, it was growing, one card's cases at a time, and the measurement over gh confirmed the six clusters with the merge that moved each. The lever went where the measurement put it. infra/smoke/pressure.sh reads its clock through one seam: PRESSURE_CLOCK names an EXECUTABLE the driver runs to read the time, never a command line it evaluates, and an answer that is not whole seconds is refused. The driver had no test mode, so the seam got a boundary the driver checks for itself and is not merely told: PRESSURE_TEST_HARNESS=1 is the claim and PRESSURE_HARNESS_BIN is the proof. On every run docker and curl, the only programs through which the driver leaves the box, are resolved ONCE with type -P (files on PATH only, so a function or an alias cannot be selected), must be regular executable files after link resolution, and are called by their pinned paths at all 25 call sites through run_docker and run_curl; PATH is not changed afterwards. Only under a supplied clock the two must also be plain non-symlink files whose physical directory is the harness directory, and an exported shell function named docker or curl is refused outright. A real run is unchanged: a symlinked tool on PATH (an alternatives link, a Snap install, a version shim) is accepted as before, which review round 3 restored after the plain-file rule had reached real runs. The text says what the check is: a guard against an ACCIDENTAL inherited use of the PATH tools that trusts the named directory's contents, not a sandbox. An acceptance run refuses a supplied clock in p1_bar_preflight, before docker compose up and before any spend, and every report carries clock: real or clock: supplied. In the harness the sleep stand-in advances a clock file under flock, published by rename, monotonic and atomic (8 processes times 25 advances move it exactly 200), so the driver's two clock-measured waits finish at once and are still measured against the clock. One named case keeps the REAL clock with a no-op sleep and proves that a sleep that returns early does not shorten a wait; against a scratch driver whose waits count sleeps the suite fails with exactly that case. A wait measured against a supplied clock advances it every turn (CLOCK_POLL_S is the poll floored at 1 only when the clock is supplied, so no real run changes): the drain with work that never finishes now ends at its bound, names what is still running and cleans up in 4 s, where on the first commit it hung until timeout killed it. The harness reports each case's wall clock from EPOCHREALTIME, ranked, on every run, and asserts its own count: 142 cases at the cut (the card's 130 plus what merged since it was measured), 159 on the branch, none removed and no assertion weakened. The first pull-request run was red in infra alone, and it taught a rule. Run 35603437339: 130 failures, 60 of them the driver's own sentence that a report exists and is never overwritten. The driver names its report by the real clock at one-second resolution and rightly refuses to overwrite one; the harness gave every case the same reports directory; and this card had made the cases faster than a second on the runner (about 0.16 s each against about 2 s on the build box, which is why the box could never see it). It was reproduced with no dependence on speed by a stand-in date that answers one fixed second, and fixed in the harness alone: every run writes into $WORK/reports/run-<n>, the never-overwrite case seeds its collision in a directory it names itself, and a new case proves that two runs inside one second each write their own report. A test that writes a file named by the clock gets its own directory. The floors. All 24 entries of infra/scripts/workflow-timeouts.json, the deploy workflow's included, were re-measured over the last 30 completed runs on main, each bounded below by its job's last change of steps (found by a first-parent diff), with the run id of each maximum, and every derived limit followed in the same commit: infra 530 to 631 s and 23 to 27 minutes, as the card's text requires (the maximum on main, so it still describes the old shape); api 15 to 16, web 18 to 17, mirrors 11 to 12, e2e 28 to 23, spike-a4 7 to 8, ci 7 to 6, build-web 17 to 9, deploy-staging 39 to 36, deploy-prod 41 to 40 with its stand-in moved from build-web to build-api. Two floors had been read from pull-request runs and are read from main now. The maxima that left the window (build-web 344 s, e2e 679 s, deploy-staging 207 s) still fit the new limits. The stale-floor mutation exits 1 naming infra. The result on the runner, from the pull request's own green run 35609962809: the pressure step 111 s against 418 to 422 s over the eight main runs before it, the harness's own total 110.5 s over 159 cases, and the infra job 271 s against a median of 579 s, so the job is 41 percent its longest step and no longer 72, and its limit is 6.0 times the job. What remains in the step is two real-clock cases (10.0 s and 5.3 s), one case at 6.0 s on real forks, and 156 cases at about 0.57 s each, which is what forking the stand-in programs costs. The intelligence spike's pressure drivers share the script's result directory, lifecycle and report and not its clock; their suites pass (119 and 382). Review: co-plan one round; 4 rounds to approve, plus the fix for the first run and the record. Record docs/ops/a48-acceptance.md. Open item #138 closed; #151 (the 30-run window drops a rare cold-cache maximum from a floor: decide whether the rule keeps a longest-seen-since-the-shape-cut term) and #152 (the spike's transcriber driver resolves curl with command -v and calls it bare) filed.
A49 · The admin's framework takes the last release its own line offers · A · S · S2-W2 · chore/a/a49-admin-framework-version-move (surfaced while carding open item #124; carded 2026-09-20)
Context. This card runs only if A44's recorded decision is the patch on the 14 line, and it is built for that one outcome: the highest release the advisories name on that line, 14.2.35 by A44's measurement, applied to the pin, the lock and the image. Any other answer at that gate does not reach this card, and its Out of scope says what each of the others needs instead. What the one outcome has to move is small and measured. services/scraper/admin/package.json:17 pins next at 14.1.0 and services/scraper/admin/package-lock.json resolves node_modules/next at the same version. react and react-dom at ^18.2.0 and their type packages at ^18.2.48 and ^18.2.18 sit beside it and stay where they are: a release inside the 14 line keeps the same React pair, which is one of the reasons the patch route is a small card at all. The application is built into the scraper image and not into Skynet's stack: services/scraper/Dockerfile:17-21 installs the admin's dependencies and builds it in a stage of its own, :74-77 copies the standalone output, its static files and its public directory, and :89-91 verifies that the server entry point and the package file arrived. None of infra/compose/compose.prod.yml, compose.staging.yml or docker-compose.dev.yml names it, and .github/workflows/deploy.yml does not either. What CI does with a change there is the part that decides how this card proves itself. The scraper path filter is services/scraper/**, so editing the admin's package file does start the scraper job, but that job never touches the admin: it installs from services/scraper/package-lock.json and runs npm run test:unit in services/scraper (.github/workflows/ci.yml:376-393), and no workflow in the repository builds services/scraper/Dockerfile, which a search over both workflow files confirms. So a version move would run a job that is green whether or not the application still builds, and nothing in the pipeline would notice a broken build. AGENTS.md has the same hole in its per-package list: it gives a command for services/scraper and one for the agent-runtime console, and none for this application. The proof of this card therefore has to be the image build, run deliberately and recorded, and the card says so rather than leaning on a job that would pass regardless.
Read first. docs/ops/a44-acceptance.md whole, and in particular the evidence table, the three costed routes and the recorded answer, which is this card's only input; services/scraper/admin/package.json whole file; services/scraper/admin/package-lock.json, the next entry; services/scraper/admin/next.config.js:1-18, whose standalone output the Dockerfile depends on; services/scraper/admin/middleware.ts whole file, the surface one of the closed advisories concerns; services/scraper/Dockerfile:15-30 and :70-95 (the admin build stage, the copies and the three verification lines); .github/workflows/ci.yml:376-393 (the scraper job) and its path filter entry, which is what starts it; renovate.json whole file, the ignorePaths list this package left and the grouping rules a new rule sits beside; AGENTS.md "How to run checks", the per-package list this card adds an entry to; A19 in the delivery plan, which deletes the application and the rule with it. Open item #124.
Deliverables. (1) The 14-line release A44's record names, and only that release, in services/scraper/admin/package.json, which is 14.2.35 unless a later one has appeared on that line by the cut, in which case the record says which and why. The card chooses nothing and it moves nothing off the 14 line: a diff that names a 15 or 16 release is the wrong card. (2) services/scraper/admin/package-lock.json regenerated by an install in that directory and never edited by hand, with the diff showing the resolved version, its integrity and whatever transitive versions the resolver moved, and nothing else. (3) An update-bot rule in renovate.json that matches the recorded decision rather than fighting it, so the bot stops opening the same change against an application scheduled for deletion, with its reason beside it and the sentence that the rule goes when A19 goes. The rule names the package and the path and nothing wider, so no other dependency of any other package changes behaviour. (4) AGENTS.md "How to run checks" gains the entry this application never had: the directory, the install and the build, so the next person can prove it without reading a Dockerfile to work out how. (5) The record docs/ops/a49-acceptance.md: the version before and after, the advisory query re-run against the new version with its count by severity, the two criticals that no 14 release closes named again with their identifiers, the image build transcript with the Dockerfile's own three verification lines in it, and the boundary sentence A44 recorded, repeated once so a reader of this record alone knows where the application runs.
Interfaces. Exposes the moved pin and the update-bot rule. Consumes A44's recorded answer, the admin's package files, the Dockerfile's existing stages and the scraper job as they are. Unchanged: react, react-dom and their type packages, which a release inside the 14 line does not move and which this card is not permitted to move; middleware.ts, next.config.js and every admin route; the Dockerfile's stages, copies and verification lines; the scraper job, its filter and its commands; Skynet's own stack and deploy workflow, neither of which carries the application; and every other package's dependencies.
Acceptance. The version in the diff equals the version in docs/ops/a44-acceptance.md, quoted in the record from both places so a reviewer compares them without leaving the page. cd services/scraper/admin && npm ci succeeds from the regenerated lock on a clean checkout with nothing installed anywhere else in the tree, and the record carries that transcript, because a lock proven only on a box that already held the old dependencies is not proven. npm run build in that directory succeeds and writes the standalone output the Dockerfile copies. docker build -f services/scraper/Dockerfile services/scraper succeeds and its own verification lines print, which is the only proof in this repository that the application still builds, and the record carries the tail of that build; the record also states plainly that no CI job builds it, so the next person knows the green pipeline is not that proof. The advisory query A44 names is run again against the new version and recorded with its count by severity, so the record says what is closed and what remains rather than implying the matter is finished, and the two criticals that no 14 release closes are named again with their identifiers, because the point of this card is to close what the line can close and to leave the rest visible. git diff shows react, react-dom and their type packages unmoved, which is the proof that the card stayed inside the outcome it is gated to. The update-bot rule is shown to match the one package and path it is meant to, by the configuration's own validation and by a listing of what it would now skip, and the record shows that no other package's updates changed. python3 infra/scripts/task-refs.py check . green. The scraper job green on the pull request, with the record's sentence that this job does not exercise the application and is not the build proof. The whole ci aggregate green. Record docs/ops/a49-acceptance.md; open item #124 closed once A44's record and this card's record both exist.
Out of scope. The other two answers at A44's gate, neither of which this card carries. A decision to carry the version until A19 needs no card at all: it changes no file, its whole output is A44's own record and the open item that record raises, and a card built to apply nothing would be a card that has to invent work. A decision to move to the 15 or 16 line is a framework major on the app directory, the middleware and the React pair, and it gets its own card scoped at that gate, from that gate's own reading of what the migration touches, rather than being smuggled into a card sized for a version bump inside a minor line. Choosing the version, which is A44's and the owner's; this card applies a recorded answer and refuses its own opinion. Adding the admin's install and build to the scraper job, which is the alternative that loses: it would make the pipeline prove what this card proves by hand, and it costs a framework install and build on every change under services/scraper for an application A19 deletes in the last wave of the slice, on a pipeline whose longest job already runs over nine minutes. A card that keeps the application past A19 brings that step with it. Changing what the admin does, its routes, its access rules, its middleware or its image optimizer configuration. The React pair and its type packages, which a release inside the 14 line does not move and which belong to the major's card. Any other dependency of the admin, and any dependency of any other package. Deploying the admin from this repository, and the old standalone host's own patching. Retiring the application, which is A19's.
Waits on / Unblocks. A44's recorded answer being the patch on the 14 line. That is a gate and not a preference: on either other answer this card is not cut at all, one because it would have nothing to apply and the other because it would be the wrong size for the work. A9's pull request #118, if it is open at the cut, because it changes files in the same package; the rebase is mechanical and the lock is regenerated once, after it, by the tool that owns it / A19, which deletes the application and the update-bot rule this card adds; and every later dependency alert on that package, which meets a rule that matches the recorded decision instead of reopening it.
Not cut (2026-09-21). A44's gate was answered with the third route: the pin is carried at 14.1.0 until A19 retires the application (docs/ops/a44-acceptance.md, "The owner's answer"). This card is gated to the patch on the 14 line and is built for that one outcome, so by its own Waits on and Out of scope it is not cut: there is no version to apply. The card text stays as the specification of that outcome, in case a later decision reopens the gate before A19; what is carried is open item #160.
S43 · An operator sentence names the cast member the panel shows and not the slug the code holds · S · S · S2-W2 · fix/s/s43-name-the-member-not-the-slug (surfaced while building S38, pre-existing on main, open item #120; carded 2026-09-20)
Context. Measured at main cd8022a. Three sentences in the frame runner build their subject out of the raw member slug: _instruct_fields refuses an instruction with f"{slug or 'this character'} without enough to render from. " (apps/api/app/backend/services/storyboard_image_runner.py:2068), refine_character refuses with f"There is no sheet for {slug or 'this character'} to refine. " (:2109), and redraw_character refuses with f"There is no character for {slug or 'this session'} to draw. " (:2235). The item measured the same three at 233f6e1 as :2059, :2100 and :2226; the four cards merged since moved each of them nine lines down and changed nothing else about them, so the count is still three and the numbers moved. One detail of the item needs correcting against the tree: the third site's fallback is this session, not the this character the item gives for all three, which is what the S38 record already recorded row by row (docs/ops/s38-acceptance.md:56-58). rg -n "slug or " apps/api/app/backend/services/storyboard_image_runner.py returns seven lines, and these are the three that reach an operator: :758 and :2380 resolve a slug rather than print one, :1595 writes an audit field, and :1127 prints to the container log. The answer S38 built for this question is character_cast.sentence_name(cast, slug, record) (apps/api/app/pipeline/character_cast.py:165-197): the character's own name once one has been built, else the part the roster gives the slug, else UNSHOWN_CHARACTER, the literal the character (:162). None of the three sites asks it. rg -n "sentence_name" apps/api/app/backend/services/storyboard_image_runner.py returns :1520, :2711 and :3030, which are S38's own three call sites, plus two docstring mentions, so S38's work does not cover these, exactly as the item says. The primary never prints a slug at any of the three, which is why this is about members: svc.member_slug collapses the primary to None (apps/api/app/backend/services/storyboard_service.py:4399-4401) and all three sentences are built after that collapse, so a solo session already reads the fallback phrase. What prints raw is a member slug on a session that has a roster, and the cast panel labels that same member through display_name on the roster route (apps/api/app/backend/routes/video_storyboard.py:1040-1059), so the screen and the refusal name one person two ways. Two of the three sites are one caller: _instruct_fields is called only from refine_character (:2141), so a single roster read covers both. refine_character reads without a cast today (:2105-2106); redraw_character already holds one, cast = svc.get_cast(session_id) at :2230, and threads it into both service reads under a comment that says why one read is threaded the whole way down (:2226-2229). Both service reads accept a cast (storyboard_service.py:4399-4400, :4521-4522), so the fix costs one call on one of the two paths and none on the other. Nothing pins the three sentences: rg -n "There is no sheet for|There is no character for" -g '!node_modules' . returns only the two runner lines, and no test file holds the instruction refusal's text either, so the base failure of every new test is the raw slug itself.
Read first. apps/api/app/pipeline/character_cast.py:111-119 (_own_name), :121-142 (display_name and the sentence in its docstring that points a caller here), :162-197 (UNSHOWN_CHARACTER and sentence_name, with the three steps and the paragraph on when it rather than display_name is the answer) and :286-320 (roster_of, primary_slug, entry_of). apps/api/app/backend/services/storyboard_image_runner.py:2005-2071 (_instruct_fields and the refusal at :2066-2069), :2084-2112 (refine_character, the collapse and read at :2105-2106, the refusal at :2107-2110), :2135-2145 (the one call of _instruct_fields) and :2205-2237 (redraw_character, the threaded read at :2226-2231 and the refusal at :2233-2236), with :2629-2645 (_as_subject, and why it is not needed here) and :1515-1522, :2705-2715, :3025-3032 (S38's three call sites, as the shape to copy). apps/api/app/backend/services/storyboard_service.py:4399-4410 (member_slug, the primary collapsing to None) and :4521-4530 (read_character and its cast parameter). apps/api/app/backend/routes/video_storyboard.py:1040-1059 (the chip label, the one place display_name faces the panel). docs/ops/s38-acceptance.md:50-60 (the table row for each of the three) and :180-190 (what S38 filed). Open item #120.
Deliverables. (1) refine_character reads the roster once and threads it, in the shape redraw_character already uses: one svc.get_cast(session_id) before the slug is collapsed, passed to svc.member_slug and svc.read_character, and on to _instruct_fields, which takes it as a keyword beside slug. Neither path gains a second read. (2) Each of the three subjects comes from character_cast.sentence_name when a slug survives the collapse, with the character record handed in where the site already holds one, which is character at all three. A member with a built name reads as that name, a member the roster holds without one reads as the part, and a slug the roster does not hold reads as the phrase, which is the case a stale member slug lands in. (3) The None case keeps each site's existing word: this character at :2068 and :2109, this session at :2235, all three unchanged, because each is already a phrase for a character nobody has been shown and the third one is the only reading of its sentence that works. (4) No capitalisation helper is used, because all three subjects sit mid sentence and _as_subject exists for a subject that starts one. (5) The comment each site gains says that a sentence an operator reads names what the panel shows, and it names no card, item or phase. (6) docs/ops/s43-acceptance.md: every sentence in these three functions that names a character, whether it now goes through the one answer and why, and the printed output of each of the three in four states, a member with a name, a member without one, a slug the roster does not hold, and the primary.
Interfaces. Exposes nothing new. _instruct_fields gains a cast keyword, and its one caller already holds one after deliverable 1. refine_character and redraw_character keep their signatures, their returns and their refusal types. Consumes character_cast.sentence_name, roster_of and entry_of unchanged, and the two service reads through the cast parameter each already declares. display_name and part_name are untouched, so the chip route and the tests that pin them do not move. No route, no client and no web change: the three sentences live in the server, and searching the tree for their text returns only the runner.
Acceptance. The api tier green as AGENTS.md sets it out, from apps/api, with DESELECT=$(grep -vE '^\s*(#|$)' ci-known-failures.txt | sed 's/^/--deselect /' | tr '\n' ' ') and .venv/bin/python -m pytest app/backend/tests -q -p no:cacheprovider $DESELECT, the targeted files run locally with the venv's own interpreter and pytest's own exit code read from the command. Each new test fails on the base sources for its own reason, three per site: on a roster of two, the instruction refusal names Rosie where the base says rosie; the refine refusal names Rosie where the base says rosie; the redraw refusal names Rosie where the base says rosie; a member the roster holds with no name built yet is named by its part at all three, where the base says the slug; and a slug the roster does not hold is named the character at all three, where the base says the slug. Four pins pass on both sides: the primary at each of the three sites still reads this character, this character and this session, and the cast route's chips still label through display_name. One pin is new on the branch alone: refine_character calls svc.get_cast once for a refine that reaches _instruct_fields, which is what holds the threaded read to one call. No comment the card adds names a card, an item or a phase. gitleaks detect --no-git --source . --config infra/secret-scan/gitleaks.toml --redact clean, and python3 infra/scripts/test-task-refs.py then python3 infra/scripts/task-refs.py check . green. Record docs/ops/s43-acceptance.md; open item #120 closed.
Out of scope. Sending the None case through sentence_name as well, which is the alternative and lost on its own reading: sentence_name answers the character for a slug the roster does not hold, so the redraw refusal would read There is no character for the character to draw, and the three existing fallback words already name nothing the operator has not seen, so the primary gains nothing and one sentence gets worse. The log line at :1127 and the audit field at :1595, neither of which an operator reads. Rewording any of the three sentences beyond its subject, which is text no item asks about. The panel's own labels, which go through display_name where every slug is on the screen and are right as they are. Any other runner sentence that names a character, all of which S38 tabulated and routed.
Waits on / Unblocks. Waits on nothing: sentence_name is on main at cd8022a in the shape this card reads, and all three sites are there as S38 left them. It shares storyboard_image_runner.py with S45 (open item #127), in a region neither card touches, so whichever merges second re-runs the api tier on the previous merge commit. Unblocks every later refusal in the refine and redraw paths that has to name a member, which from here names what the panel shows.
Built as (PR #145 → main dc8c055, 2026-09-21). The three refusals in apps/api/app/backend/services/storyboard_image_runner.py that built their subject from the raw member slug (the instruction refusal, the refine refusal, the redraw refusal) now read who = character_cast.sentence_name(cast, slug, character) if slug else <the site's own word>: the character's own name once one is built, else the part the roster gives the slug, else the character. The primary keeps each site's existing word (this character, this character, this session) and does not go through sentence_name, as the card's Out of scope argues; sentence_name is never called with no slug. refine_character reads the roster once with svc.get_cast, before the slug is collapsed, and threads it into svc.member_slug, svc.read_character and _instruct_fields, which takes it as a required keyword and has one caller; it is the shape redraw_character already had. One comment per site says that a sentence an operator reads names what the panel shows; no capitalisation helper is used. Fourteen tests in the runner's operator-flow harness, test_character_sheets_flow.py: nine fail on the base for the raw slug (three sites times three states: rosie to Rosie, sons_friend to Sons Friend, a slug the roster does not hold to the character); one pins that a refinement that reaches _instruct_fields reads the roster exactly once and fails on the base with 0; four pass on both sides (the primary's three words and the chip route through display_name). The harness's member_slug stub takes the threaded cast and get_cast is stubbed, which the co-plan found would otherwise break every _Refine test; no earlier assertion was weakened. The harness cast holds two members on a roster that also carries the lead, because a roster of two with no lead is not a shape the product makes. The record says plainly which readings are live and which are guarantees: the only writer of a skipped character record writes it for the primary, so a member reaches the refine and redraw refusals with an empty record today; the part and the phrase are the live readings there, and the name is a guarantee for the day a named member record arrives. The twelve state outputs were printed on both trees by a command the record names. Base 10 failed and 155 passed; branch 165 passed; the api tier green on the pull request. Found and not fixed, filed before the push at the review's request: #153 (a refinement's two render calls pass no cast, so the render reads the roster again; this card took a refinement from three reads to two) and #154 (the redraw's second refusal keeps its phrase with the member in hand). Review: co-plan one round; the code approved on the first pass. Record docs/ops/s43-acceptance.md. Open item #120 closed.
S44 · Every press of generate all keeps the record it wrote · S · M · S2-W2 · feat/s/s44-every-press-keeps-its-record (surfaced while building S40, pre-existing on main, open item #125; carded 2026-09-20)
Context. Measured at main cd8022a. generate_all_candidates builds one summary and writes it with svc.record_audit(session_id, {"last_batch": summary}) (apps/api/app/backend/services/storyboard_image_runner.py:3216). The item measured the same call at 5314a54 as :3207; it has moved nine lines and is otherwise as it was. record_audit is a shallow JSONB merge on the top-level key (apps/api/app/backend/services/storyboard_service.py:5001-5010), so each write replaces the whole last_batch object and a second press keeps nothing of the first. The summary carries scenes, ready, refused, blocked, failed, warnings and dropped (:3195-3196). dropped is claim_all's own list, each entry a scene, its position and the sentence its refusal gave (claim_all at :3116-3156, the refusal captured at :3150-3155), and the docstring above the write says why it is there at all: the scenes were refused before the thread existed, and the batch note is the one durable place they are written down, because the answer the route gave is gone by the time the run finishes (:3188-3193). S40's record states the consequence and files it as this item: the note is the only durable record of a drop and holds the last batch alone, so a board pressed twice keeps the second press's drops and loses the first's (docs/ops/s40-acceptance.md:259-263). The reader chain is one line deep at every hop. The poll route returns audit.get("last_batch") beside the scenes (apps/api/app/backend/routes/video_storyboard.py:747, with the paragraph at :730-734 saying it rides along because a generate-all runs on a thread nothing reads); the client types it as StoryboardBatchSummary | null (apps/web/src/lib/videoStoryboardApi.ts:1873-1880, the interface at :370-382, where dropped is optional because a batch recorded before drops existed has no field); the screen holds it in one state slot (apps/web/src/pages/video-storyboard/VideoStoryboardScreen.tsx:150, set at :613) and the banner draws it when the batch has a failure, a drop or a warning (:861-880). One api test reads the note as a nested object through the audit bundle (apps/api/app/backend/tests/test_storyboard_audit.py:39, :279), and two read dropped through the world fixture (test_video_storyboard_e2e.py:2931-2933). Two facts decide the shape of the fix. A history cannot be a read and a write back of one top-level key: record_audit_key exists precisely because that pattern is lossy, and its docstring gives the case, concurrent writers each reading a key and writing back a version missing the others, with jsonb_set touching only the leaf (storyboard_service.py:4881-4898). And two generate-all threads on one session are reachable rather than theoretical: the claim is per scene and taken synchronously by the route so the poll sees the work at once, and claim_all's own docstring describes what happens without that, a second press sailing past the guard into a second background run (:3125-3128). The item leaves a choice between a documented one-batch contract the page states and a bounded list the page reads newest first. The list is what the item's own want asks for, because the sentence a drop carries is the only account the operator ever gets of why a scene did not generate, and a contract that says it is deleted on the next press documents a loss rather than removing it.
Read first. apps/api/app/backend/services/storyboard_image_runner.py:3116-3160 (claim_all, the drop entry at :3150-3155 and the paragraph on a second press at :3125-3128), :3180-3220 (generate_all_candidates, the docstring's account of why the summary is written to the audit, the summary at :3195-3196, the per-scene failure at :3202-3207 and the write at :3214-3218). apps/api/app/backend/services/storyboard_service.py:4881-4898 (record_audit_key and why one leaf at a time), :5001-5010 (record_audit and its shallow merge) and get_audit. apps/api/app/backend/routes/video_storyboard.py:726-750 (get_scenes, the poll target, and the three run notes that ride along). apps/web/src/lib/videoStoryboardApi.ts:359-382 (StoryboardDroppedScene and StoryboardBatchSummary) and :1871-1886 (getScenes). apps/web/src/pages/video-storyboard/VideoStoryboardScreen.tsx:145-155, :605-620 and :855-885 (the state slot, the poll and the batch banner). apps/api/app/backend/tests/test_storyboard_audit.py:30-45 and :270-285, and apps/api/app/backend/tests/test_video_storyboard_e2e.py:2920-2940. docs/ops/s40-acceptance.md:60-80 (what S40 added to the note) and :255-265 (what it filed here). Open item #125.
Deliverables. (1) A press writes its own summary to its own leaf. generate_all_candidates writes through svc.record_audit_key(session_id, ["batches", batch_key], summary), where batch_key is generated once when the thread starts, so two threads never write the same leaf and neither reads the other's. The summary gains started_at and finished_at, so the order a reader needs is data in the record and not a convention in the key. (2) last_batch keeps being written exactly as it is, at the same moment and with the same object. Nothing in the route, the client type, the banner or the three tests that read it moves, and a session whose note was written before this card is still read. (3) The held presses are bounded, and the bound is applied in the service in one statement that keeps the newest entries of the batches object rather than by reading it into the runner and writing it back, which is the pattern record_audit_key exists to avoid. The bound is 10 and docs/ops/s44-acceptance.md carries its reason: the audit column is one JSONB value on the session row, a 40-scene press writes 40 scene entries into it, and the note is an account of the presses an operator is still looking at rather than a history of the board. (4) The poll answer carries the held presses beside the newest one, as batches, newest first, each reduced to what the banner draws: the two instants, the failure count, the drops with their sentences and the warnings, and not the per-scene rows. The poll target must not grow with the board, which is what this reduction is for, and the route's docstring says so. (5) The client declares the reduced shape as its own type beside StoryboardBatchSummary rather than reusing it, because the two are different objects and one type covering both would make every field optional. (6) The banner reports every held press that has a failure, a drop or a warning, newest first, each labelled by when it ran, with the newest reading exactly as it reads today. A press with nothing to report draws nothing, as now. (7) docs/ops/s44-acceptance.md: the note's contract in one paragraph, what a press writes and where, the bound with its reason, the two concurrent-write cases and why neither loses a leaf, and the printed answer of the poll route after one press and after three.
Interfaces. Exposes the session audit's batches object as the record of what recent presses did, and the poll route's batches field as the client's reader of it. last_batch keeps its meaning, its shape and its place in the answer, so it is the compatibility surface every existing reader keeps using. Consumes record_audit_key and get_audit unchanged. generate_all_candidates keeps its signature and its return, and claim_all is untouched, so the drops it produces reach the note by the path S40 built. One behaviour changes on purpose: an earlier press's drops and warnings survive a later press, up to the bound.
Acceptance. The api tier green as AGENTS.md sets it out, from apps/api, with DESELECT=$(grep -vE '^\s*(#|$)' ci-known-failures.txt | sed 's/^/--deselect /' | tr '\n' ' ') and .venv/bin/python -m pytest app/backend/tests -q -p no:cacheprovider $DESELECT, the targeted files run locally with the venv's own interpreter. cd apps/web && npm ci && npm run lint && npm run typecheck && npm run build clean, and the web suite green with cd apps/web && npm test, which the package expands to vitest run; the targeted run locally is cd apps/web && npx vitest run src/pages/video-storyboard/VideoStoryboardScreen.test.tsx, which is the one web test file this card changes. Each new api test fails on the base sources for its own reason: two presses on one session leave two entries under batches, where the base has no such key; the first press's dropped sentences are still readable after the second press, where the base returns only the second's; eleven presses leave ten entries and the oldest is the one gone, where the base has none to bound; two summaries written from two threads for the same session both survive, where the base keeps one; and the poll route's answer carries batches newest first with no per-scene rows in it, where the base has no field. Three pins pass on both sides: last_batch is still the newest press's whole summary including its per-scene rows; test_storyboard_audit.py:279 still reads the note through the audit bundle; and the two e2e assertions on dropped still read it off last_batch. On the web side, two tests fail on the base: the banner draws an earlier press's drop beside the newest press's, and a press with nothing to report draws nothing. No comment the card adds names a card, an item or a phase. gitleaks detect --no-git --source . --config infra/secret-scan/gitleaks.toml --redact clean, and python3 infra/scripts/test-task-refs.py then python3 infra/scripts/task-refs.py check . green. Record docs/ops/s44-acceptance.md; open item #125 closed.
Out of scope. The documented one-batch contract, which is the alternative and lost because it writes down a loss instead of removing it: the drop sentence is the only account an operator ever gets of a scene that did not generate, and a rule saying the next press deletes it leaves the same operator with the same missing answer. A record that survives the session, an audit trail or a page that lists every press a board ever had, none of which the item asks for and all of which are a different feature on a different store. Changing what claim_all drops or what reason it records, which is S40's and stays as it is. Changing the shape of last_batch itself, which is what keeps every existing reader working. Moving the per-scene rows out of the summary, or the three other run notes the poll answer carries, prompts_run and transcribe_run, which have the same one-record shape for their own reasons and are not what this item measured.
Waits on / Unblocks. Waits on nothing: the write, the route, the client type and the banner are all on main at cd8022a as S40 left them, and record_audit_key has been there since before it. It shares storyboard_image_runner.py with S43 and S45 (open items #120 and #127), in a region neither of them touches, so whichever merges second re-runs the api tier on the previous merge commit; it is the only one of the three that touches apps/web, so no web card shares a surface with it. Unblocks every later reading of what a press did, which from here is not erased by the next press.
Amended 2026-09-21 (co-plan, one round). One premise of deliverable (1) does not hold, and the co-plan replaced the mechanism while keeping every outcome the card asks for. record_audit_key runs one UPDATE … jsonb_set(COALESCE(storyboard_audit, '{}'), path, value, true), and PostgreSQL's jsonb_set creates only the LAST element of a path: every earlier step must exist. A write to ["batches", batch_key] on a session whose audit holds no batches object would therefore change nothing, silently, and the function's docstring says otherwise. So the press is written by ONE dedicated service function in ONE statement: it builds COALESCE(audit->'batches', '{}') || {batch_key: summary}, keeps the newest 10 entries of that object by started_at (descending, with the key as the tie-breaker when two instants are equal), sets the result under batches, and sets last_batch to the same summary in the same statement. The row lock serialises two presses, each statement reads the committed row after it waits, so neither press loses a leaf, no reader ever sees eleven entries, and the compatibility write can never drift from the history. batch_key is a random uuid4 hex made once when the thread starts; order is data in the record. The reduction for the poll answer is one pure service function over the audit object (newest first; started_at, finished_at, failed, dropped, warnings; an absent or malformed batches gives an empty list), called by the route, which already holds the audit. The client keeps its last_batch state, so a session written before this card still draws its banner, and gains a batches state beside it. The tests that need a real database (the atomic write, the bound from eleven to ten, two threads for one session) live under apps/api/app/backend/tests/core/, where the core_pg fixture starts a pgvector/pgvector:pg17 container and applies the whole migration plan; core/test_job_limits_db.py is the model for separate threads and connections. What record_audit_key does on a missing parent is measured by one test and filed as its own open item with its callers listed; this card does not change that function.
Built as (PR #146 → main 7ace835, 2026-09-21). The card's first mechanism could not work, which the co-plan found and the card's amendment records: record_audit_key runs jsonb_set, which creates only the last element of a path, so a write to ["batches", key] on a session with no batches object changes nothing and raises nothing. One database test measures exactly that. So a press is written by one dedicated service function, storyboard_service.record_batch, in ONE statement: it adds the press to COALESCE(audit->'batches', '{}') (an object, or empty when the stored value is not one), keeps the newest BATCH_HISTORY_LIMIT = 10 entries ordered by started_at descending with the key as the tie-breaker, sets that under batches, and sets last_batch to the same summary. It is correct by construction and not only by test: the subquery is correlated on the column of the row being updated, so after it waits on the row lock PostgreSQL re-evaluates the SET expression against the current tuple, the second press adds to the first's committed record, no reader can see eleven entries, and last_batch cannot drift from the history. _batch_instant writes %Y-%m-%dT%H:%M:%S.%fZ, fixed width and UTC, so the text order is the time order; a leaf with no started_at sorts last. The runner makes batch_key = uuid4().hex once when the thread starts, stamps started_at and finished_at, and writes through record_batch at the moment it wrote last_batch before, with the same object. recent_batches is the pure reduction the route calls with the audit it already holds: newest first; started_at, finished_at, failed, dropped with its sentences, warnings; an absent or malformed batches gives an empty list; the route's docstring says why the per-scene rows are left out. On the web a new type StoryboardBatchNotice sits beside StoryboardBatchSummary; the screen keeps its last_batch state, so a session written before this change still draws its banner, and gains a batches state; the banner reports every held press that has a failure, a drop or a warning, newest first, each labelled by when it ran, the newest reading exactly as before, and a press with nothing to report draws nothing. The review ruled one reported finding part of this card: the first board load discarded both fields, so a reopened idle board showed no banner until the rail refreshed; the second commit applies them with the initial board result (the first load already used the poll's client function and route), and the list is keyed by instant and index. Tests: 19 api tests in two new files (8 in-memory; 11 database-backed under tests/core/ on one small Postgres the suite's own fixture starts) and 5 web tests; on the base all in-memory tests, 9 of the 10 first database tests and the web tests fail for their own reason, the tenth being the missing-parent measurement, which passes on both sides by design; the thread test runs 25 rounds, a fresh session each, barrier-started, on two connections; three pins pass on both sides (last_batch still the newest press's whole summary, the audit bundle, the e2e assertions on dropped); npm test 1,122. One deviation is in the record: the database file was first run at a 1-minute load of 7.34, above the box rule, passed there and was re-run at 2.97. What the measurement found beyond the card: the callers of record_audit_key were measured; four pass a one-element path and are safe, ["cast", "members", slug] is safe because save_cast always stores members, and storyboard_runner.py:106 writes ["rigs", image_r2_key] where nothing ever creates rigs, so the rig cache has never recorded an entry and a rig is made again for every render of one image: open item #155, to be carded first with its spend measured. Review: co-plan one round, which amended the card; the api approved on the first pass; one must-fix on the first load, fixed and re-checked. Record docs/ops/s44-acceptance.md. Open items #125 and #156 closed.
S45 · Only the stage's own error degrades the sheet match and the shot variation · S · S · S2-W2 · fix/s/s45-only-the-stage-error-degrades-these-two (surfaced while building S41, pre-existing on main, open item #127; carded 2026-09-20)
Context. Measured at main cd8022a. Two clauses in the frame runner still have the shape S41 removed from ensure_cast. match_uploaded_sheets wraps its call in a try (apps/api/app/backend/services/storyboard_image_runner.py:386-388), re-raises the gateway's refusals by type (:391-392) and catches everything else (:393), printing that the sheets could not be matched, recording getattr(exc, "attempts", None) (:399-400) and continuing with pairs = [] (:401). _variant_prompts has the same three clauses (:2846, :2849-2850, :2851) with the same getattr (:2854-2855) and a warning that the frames were generated from one description and will look alike (:2857-2861). Both name ensure_cast as the stance they copy, the sheet match in its comment (:394-397) and the variation in its docstring (:2834-2841), and ensure_cast no longer holds it: S41 narrowed that clause to except storyboard_llm.StoryboardLLMError as exc and reads exc.attempts directly (:191-203). S41's record names both sites and says it changes neither (docs/ops/s41-acceptance.md:184-192). The item measured the sheet match at 3b05a5d as :393-401, which is where it still is; the variation clause moved from the :2769-2779 S41's card named to :2846-2861, with its content unchanged. The same failure reaches both, by the same road S37 opened. match_sheets ends in run_with_fallback (apps/api/app/pipeline/storyboard_llm.py:1237) and generate_shot_variants ends in it too (:1056); run_with_fallback loads the instruction first (:293-294) through a loader that catches only its own refusal, by type (:204-229), over a read that opens a cursor with no catch of its own (apps/api/app/pipeline/utils/file_io.py:77-82). So a database failure during the prompt read arrives at both clauses exactly as it arrived at the cast before S41, and the operator is told the reference sheets could not be matched, or that the frames will look alike, for a call that was never made. One of the two writes the degraded answer down, which the item does not say and which is what makes them differ in weight. After the sheet-match clause, svc.merge_launch_config(session_id, {"character_sheets": pool}) saves the pool (:420), so an empty pairs stores every sheet unassigned, the same shape as the failed roster S41 removed: an answer nobody gave, kept where later stages read it. The variation site writes nothing durable; its harm is a warning that names the wrong cause and now rides in the batch note (:3208-3210). Nothing is billed at either site on a failure that is not the stage's own: getattr(exc, "attempts", None) is None on a driver error and record_spend_attempts writes nothing for None (apps/api/app/backend/services/storyboard_service.py:4119-4120), so narrowing costs the ledger nothing here as it cost nothing there. Where a propagating error lands. match_uploaded_sheets has exactly one production caller, ensure_cast's tail (:235), outside its own try, so it escapes by the four paths S41 tabulated, of which the one that matters is the build route through _image_guard, which converts only the typed image errors and lets anything else become a 500 (apps/api/app/backend/routes/video_storyboard.py:910-938); the sheet-preparation caller has its own broad catch and logs (:970-979). Every other mention of the function in the tree is a docstring or a test. _variant_prompts has one caller, generate_candidates (:2951), whose own callers are the single-scene route, through the same guard, and generate_all_candidates, whose per-scene clause records the scene as failed with its error in the batch note (:3200-3207), which is the honest answer for a database failure and is what S37 chose everywhere else. One existing test encodes the old assumption at the variation site: test_a_failed_variation_call_still_produces_frames raises a bare RuntimeError from its stub and asserts the warning (apps/api/app/backend/tests/test_video_storyboard_e2e.py:617-631), a type the boundary never produces, the same shape S41 corrected for the cast. The sheet match needs no such correction: its degrade half already stubs a StoryboardLLMError (apps/api/app/backend/tests/test_storyboard_image_runner_gateway_refusals.py:177-181) and it must stay green. One measurement moves the item's own count. rg -n 'getattr\(exc, "attempts"' apps/api/ returns four lines, not two: the two above, plus storyboard_reference_runner.py:107 and storyboard_prompt_runner.py:227. Those two are a different thing and stay out: each is a background thread's outermost clause, which writes a failed status record carrying type(exc).__name__ for the page to read and then returns, with no caller left to propagate to, where these two sit in the middle of a stage that has one. The rg the item's pointer suggests, over apps/api/app/pipeline, returns nothing at all, because the runner lives under apps/api/app/backend/services.
Read first. apps/api/app/backend/services/storyboard_image_runner.py:176-210 (ensure_cast's narrowed clause, as the shape both sites take), :331-345 and :380-421 (match_uploaded_sheets, its blocking-never docstring, the try at :386-388, the gateway clause at :391-392, the broad clause at :393-401 and the durable write at :420), :225-236 (its one caller), :2829-2866 (_variant_prompts, its docstring's account of the degradation and the same three clauses) and :2930-2960 and :3196-3212 (its callers, and the per-scene clause that records a failure in the batch note). apps/api/app/pipeline/storyboard_llm.py:143-190 (StoryboardLLMError and the list it always carries), :204-229 (load_instruction and its catch by type), :248-300 (run_with_fallback and the load at :293-294), :997-1056 (generate_shot_variants) and :1173-1237 (match_sheets). apps/api/app/pipeline/utils/file_io.py:13-25 and :64-93 (the read with no catch of its own). apps/api/app/backend/services/model_gateway.py:260-265 (GATEWAY_ERRORS). apps/api/app/backend/services/storyboard_service.py:4100-4125 (record_spend_attempts and its tolerance of None). apps/api/app/backend/routes/video_storyboard.py:910-938 (_image_guard). apps/api/app/backend/tests/test_storyboard_image_runner_gateway_refusals.py:140-185 (the type pin for the cast and the refusal and degradation pair for the sheet match) and apps/api/app/backend/tests/test_video_storyboard_e2e.py:617-631. docs/ops/s41-acceptance.md:70-100 (the failure table and the ledger measurement) and :180-198 (both sites filed here). Open item #127.
Deliverables. (1) The sheet-match clause narrows to except storyboard_llm.StoryboardLLMError as exc. Its sentence, its spend record and its pairs = [] are unchanged, the gateway clause above it is unchanged, and every other exception propagates with nothing written, which includes the pool save at :420 not running. (2) The variation clause narrows the same way, keeping its sentence, its spend record, its repeated prompt and its warning exactly as they are. (3) Both getattr(exc, "attempts", None) calls become exc.attempts, because the one type the clause now catches always carries the list and its own docstring says so. (4) The comment at each site says which one type takes the branch and why the rest do not, in place of the sentence that points at a stance ensure_cast no longer has, and neither names a card, an item or a phase. match_uploaded_sheets's blocking-never paragraph and _variant_prompts's degradation paragraph each narrow their claim to the same rule: the model's own failure degrades, and a failure that is not the model's is raised to the caller, because a pool saved with every sheet unassigned, or a warning that says the shot could not be varied, is an answer nobody gave. (5) test_a_failed_variation_call_still_produces_frames is corrected rather than deleted: its stub raises the error the boundary produces and every assertion it makes is kept, because that behaviour is the one this card preserves. (6) docs/ops/s45-acceptance.md: the failure classes that reach each of the two calls and which branch each takes, where a propagating error lands for each caller of each site, the measurement that a driver error carries no attempts at either, and the two sibling copies of the same expression in the reference runner and the prompt runner with the reason they are a different thing and stay.
Interfaces. Exposes nothing new. match_uploaded_sheets and _variant_prompts keep their signatures and their returns, and each contract narrows the same way: it still never raises for a model failure, and it now raises for everything that is not one. Consumes StoryboardLLMError and model_gateway.GATEWAY_ERRORS as they stand. No route, no client and no web change, and no caller is touched. Two behaviours change on purpose: a database failure during the sheet match reaches the build route as a 500 and saves no pool, and one during the shot variation makes that scene a failure in the batch note instead of a set of look-alike frames with a warning that names the wrong cause.
Acceptance. The api tier green as AGENTS.md sets it out, from apps/api, with DESELECT=$(grep -vE '^\s*(#|$)' ci-known-failures.txt | sed 's/^/--deselect /' | tr '\n' ' ') and .venv/bin/python -m pytest app/backend/tests -q -p no:cacheprovider $DESELECT, the targeted files run locally with the venv's own interpreter and pytest's own exit code read from the command. Each new test fails on the base sources for its own reason: a stubbed match_sheets raising a psycopg2.OperationalError propagates that exact object, merges no launch config and records no spend, where the base swallows it and saves a pool with every sheet unassigned; the same failure raised from inside the real instruction read, with the cursor made to fail, propagates for the same reason, which is the path S37 opened; a stubbed generate_shot_variants raising the same error propagates and writes no version prompt, where the base returns the repeated prompt and the look-alike warning; and through the whole-board run a database failure at the variation makes that scene a failure in the batch note with the driver's sentence, where the base records it as ready with a warning. Two pins pass on both sides: a StoryboardLLMError at each site still degrades exactly as it does today, the sheet match saving a pool of unassigned entries and the variation returning its warning, each with its attempts recorded under its own stage. One pin is new on the branch alone, at each site: a TypeError carrying an attempts list of its own propagates and writes nothing, which is what holds the clause to a type rather than a shape. The parametrised gateway tests for the cast and the sheet match stay green unchanged (test_storyboard_image_runner_gateway_refusals.py:67-87, :165-181). test_a_failed_variation_call_still_produces_frames passes with its stub corrected and every assertion kept. rg -n 'getattr\(exc, "attempts"' apps/api/ returns two lines on the branch, both in the two thread runners, and the record says why each stays. No comment the card adds names a card, an item or a phase. gitleaks detect --no-git --source . --config infra/secret-scan/gitleaks.toml --redact clean, and python3 infra/scripts/test-task-refs.py then python3 infra/scripts/task-refs.py check . green. Record docs/ops/s45-acceptance.md; open item #127 closed.
Out of scope. The two sibling copies of the same expression, storyboard_reference_runner.py:107 and storyboard_prompt_runner.py:227: each is a background thread's outermost clause, which writes a failed status record carrying the error's class for the page to read and then returns with nothing above it to propagate to, so narrowing it would delete a record the page needs rather than stop an answer nobody gave. _record_wardrobe_leak_scan and the sheet-read catch at :977-979, which S41 put out of scope for the same reason and which write no durable answer a later stage reads. Restoring the loader's old fallback on a database blip, which S37 removed deliberately and recorded its reason for; putting it back for these two stages would undo it for them. Writing a marker on the pool that says the match failed, which is the alternative for the sheet match and lost because it makes the degraded state readable while still storing it, where the item's want is that a failure which is not the stage's own should not be swallowed at all, and because it adds a field every reader of the pool has to learn. Changing what an unassigned pool or a look-alike warning means downstream, or the sentence an operator reads when the model genuinely could not be used.
Waits on / Unblocks. Waits on nothing: S41 is merged, ensure_cast is on main at cd8022a in its narrowed shape, and both sites are there unchanged. It shares storyboard_image_runner.py with S43 and S44 (open items #120 and #125), in regions none of the three overlaps, so whichever merges last re-runs the api tier on the previous merge commit. Unblocks every later reading of a sheet pool or a frame warning, which from here says what actually failed.
Built as (PR #147 → main 80ab152, 2026-09-21). Both clauses in apps/api/app/backend/services/storyboard_image_runner.py now catch storyboard_llm.StoryboardLLMError alone: the sheet match in match_uploaded_sheets and the shot variation in _variant_prompts. Each keeps its sentence, its spend record under its own stage and its degraded result for that type (an empty pairing and a pool of unassigned sheets; the repeated prompt and the look-alike warning); the typed gateway re-raise above each is unchanged; every other exception propagates with nothing written, so no pool is saved, no launch config is merged, no spend is recorded and no version prompt is written. Both read exc.attempts directly, because the one type they catch always carries the list (it defaults to an empty list where the class is defined). Each site's comment names the one type that takes the branch and why the rest do not, and the two docstring paragraphs narrow their claim to the same rule: the model's own failure degrades, and a failure that is not the model's is raised to the caller, because a pool saved with every sheet unassigned, or a warning that says the shot could not be varied, is an answer nobody gave. Where a propagating error lands, measured: the sheet match has one production caller, ensure_cast, from which a non-model error reaches the image route's guard (500, no pool saved), the cast preparation's own broad clause (logged) or the prompt runner's failed step record; the variation has one caller, generate_candidates, which releases its claims and re-raises, and on a whole-board press the per-scene clause records the scene failed with the driver's sentence in the batch note and continues. No caller changed and no batch is lost. The card was wrong on one point and the record corrects it: a single-scene generate does not reach the route's guard; the route answers {"started": true} and hands the work to the job gate, which logs an escaping error and drops it on a Future nothing reads, so the operator sees a scene that does not finish and the reason is only in the container log. The review ruled that this card merges anyway, because the old state was a false answer and the new one is not (the claims become failed and releasable), and that the missing operator sentence, open item #157, is taken next. Tests, none needing a database: on the base 6 new tests fail for their own reason (the driver error swallowed three times, including once from inside the real instruction read with the cursor made to fail; a TypeError carrying its own attempts list swallowed twice, which is what holds the clause to a type and not a shape; one spend assertion) and 329 pass; on the branch 335 pass and the seven files that use these functions pass too, 586 together. Two pins pass on both sides: the model's own error still degrades exactly as before with its attempts recorded. Three tests were corrected and none deleted, every assertion kept: the variation test the card names; the sheet-match test whose stub raised a bare RuntimeError; and the end-to-end fixture, which left match_sheets unstubbed, reached a real database and had its failure swallowed by the very clause this card narrows. getattr(exc, "attempts" occurs twice under apps/api/ now, in the two thread runners whose outermost clause writes a status record with nothing above it to propagate to. Review: co-plan one round; approved on the first pass, with the three findings filed first. Record docs/ops/s45-acceptance.md. Open item #127 closed; #157, #158 (prepare_cast's three broad clauses swallow the driver error) and #159 (the whole-board clause also absorbs a gateway refusal) filed.
S46 · A single-scene press that fails is written where the board reads it · S · S · S2-W2 · fix/s/s46-a-single-scene-failure-is-written-down (surfaced while building S45, pre-existing on main, open item #157; carded 2026-09-21)
Context. Measured at main a7146c4. The functionality is one button: "Generate frames", or "Generate more frames" once a scene has some, on the scene open in the storyboard board (apps/web/src/pages/video-storyboard/VideoStoryboardScreen.tsx:1104, handler startStills at :628-644). It posts to POST /sessions/{id}/scenes/{scene_id}/stills (apps/api/app/backend/routes/video_storyboard.py:2786-2823). The route checks the gateway synchronously (:2812), claims the three candidate rows synchronously through _image_guard (:2813-2814), hands the work to the image job gate (:2816-2822) and answers {"started": true, "scene_id", "version_ids"} (:2823). The Future that submit returns (apps/api/app/core/jobs/limits.py:300, module entry :487-490) is discarded at the call site, and nothing else holds it. The gate runs the job body at limits.py:421; on any exception from it the gate logs logger.exception("job %s/%s raised", ...) and sets the exception on that Future (:422-424), which is the whole of what happens next. The route's on_error hook (:2820-2821) is not reached: the gate calls a hook only when the SLOT could not be taken (_fail at limits.py:393-400, reached from :412-416). So the container log is the only place the failure exists. What the rows do: generate_candidates (apps/api/app/backend/services/storyboard_image_runner.py:2987-3035) reads the scene at :2997, which is OUTSIDE its try at :3002; anything that escapes inside the try runs _fail_claims(claimed, "Generation could not start. Try the scene again.") (:3020) and re-raises (:3021). So in the common case the three rows end failed carrying that one generic sentence, the same sentence for a dead database, a missing reference and a gateway refusal alike. In the one case where the failure is that first read at :2997, nothing releases the rows and they stay pending; _frame_counts counts a pending row only while it is younger than PENDING_CLAIM_SECONDS = 300 (apps/api/app/backend/services/storyboard_service.py:1558-1568 and :2736), so the scene reads as generating for five minutes and then reads as a scene with no frames. It does not stay generating for ever, and that is the only state that looks like progress. What the board shows: get_scenes carries ready_frame_count and pending_frame_count alone (storyboard_service.py:1570 and :1574-1588), so a failed row is counted nowhere and the scene reads exactly as it read before the press. The board banner draws the batch note and nothing else (VideoStoryboardScreen.tsx:675-686 and :920-946), and a single-scene press writes no note. The one place a sentence surfaces is the scene's own pane, "Attempts that did not produce a frame" (VideoStoryboardScreen.tsx:765 and :1293-1310), which prints the row's status and provider_message, and the version strip's tooltip (apps/web/src/components/video-storyboard/FrameSlots.tsx:436-440); both print the generic sentence, and only for the operator who has that scene open. The press says "started", the scene goes quiet, the board says nothing, and the reason is in the container log. There is already a record for exactly this, and one press writes it. generate_all_candidates (storyboard_image_runner.py:3232-3298) wraps each scene in its own clause (:3277-3285), counts failed and appends {"scene_id", "position", "error": str(exc)} (:3282-3284), copies a look-alike warning in as f"Scene {position}: ..." (:3288-3289), stamps two instants from _batch_instant (:3219-3230, used at :3274 and :3293), takes a key of its own (:3271) and writes the press with svc.record_batch(session_id, batch_key, summary) inside its own clause (:3294-3297). record_batch files the summary under its own key in batches and on last_batch in one statement, keeping the newest BATCH_HISTORY_LIMIT = 10 (storyboard_service.py:4906 and :4909-4949). The poll route returns last_batch and recent_batches(audit) (routes/video_storyboard.py:730-757), recent_batches reduces a press to its two instants, its failure count, its drops with their sentences and its warnings (storyboard_service.py:4952-4981), and the banner reports every held press that has a failure, a drop or a warning. A single-scene press is the only press that puts nothing into any of it, and its warning return (:3034) is handed to a thread nothing reads. THE DECISION: the single-scene press is given the record the whole-board press already writes. One new runner function wraps the job the route submits, files the press into the session's batch note through svc.record_batch with the scene's own entry and the driver's bare sentence, releases the claim under a guard, and re-raises the original exception so the gate's log keeps the stack. Nothing is invented: the note, the poll field, the client type, the banner and provider_message are all on main and all already carry this account for a whole-board press, so there is no column and no migration to add, and the one new service call is the scene twin of a guarded release that already exists for the character table (storyboard_service.py:3335-3353). Two hazards were measured and both shape the card. A release run on the way out of a LATE failure must not rewrite a settled row: svc.set_version_status flips any row by id with no guard at all (storyboard_service.py:2790-2799), and generate_candidates finalises its rows at :3023-3026, OUTSIDE its try, so the second row's _finalise can raise after the first row is already ready with its picture stored (_finalise at :2626-2649). And the gate catches BaseException (limits.py:412 and :422) where generate_candidates catches Exception alone (:3017), so an interrupt or a shutdown escapes with the rows untouched and is not a driver failure to report. The other fire-and-forget submitters were measured and each already records its own escape, which is why this is one card and not a change to the gate. apps/api/app/backend/routes/video_storyboard.py:2876-2883 is the whole-board press, whose target records the press itself. apps/api/app/backend/services/pipeline_runner.py:1858-1862, through submit_image_job (:1865) and submit_localize_job (:1872), has thirteen call sites whose target is in every case a _run_*_safe wrapper that writes the failure onto the session row (:246, :458, :538, :624, :865, :989, :1267, :1397, :1540, :1625, :2345-2347, and intelligence_v2/labs_shared/runner.py:96 and intelligence_v2/generation/iv2_runner.py:135). apps/api/app/backend/services/storyboard_stage.py:38-46 carries the five auxiliary stages, and each target ends in an outermost clause that writes a record the page reads: the shot writer (storyboard_prompt_runner.py:73-76 and :217-229), the reference analysis (storyboard_reference_runner.py:16 and :98-109), the song transcription (storyboard_song_runner.py:196), the voice clone (storyboard_voice_clone.py:196-208) and the cast chain (storyboard_image_runner.py:1165-1183). So the single-scene stills press is the one submit in the tree whose target has no record of its own escape, and the rest stay out. A gateway refusal and a driver error stay different things here, the way S45 left them. model_gateway.check() at routes/video_storyboard.py:2812 is where a paused spend guard or a missing key answers the button press, before a row is claimed and before anything is spent, and S45 kept the typed re-raise above each narrowed clause for that reason (storyboard_image_runner.py:399-400 and :2900-2901, merged in 80ab152). A refusal that appears only AFTER the claim has no caller left to answer, so this card records it like any other failure and str(exc) carries the gateway's own sentence. Whether a refusal should end a whole-board press at once is open item #159's question and is not asked here.
Read first. apps/api/app/backend/routes/video_storyboard.py:2786-2823 (generate_stills, the synchronous gateway check and claim, the submit and the answer) and :2850-2885 (generate_all_stills, the same submit with a target that records itself), :726-760 (the poll route and the two note fields). apps/api/app/core/jobs/limits.py:279-300 (submit and the Future it returns), :393-400 (_fail, the only path that calls a hook) and :409-427 (_run_one, the two BaseException clauses at :412 and :422-424). apps/api/app/backend/services/storyboard_image_runner.py:2626-2649 (_finalise, the storage clause at :2641-2647 and the row that ends ready at :2648), :2925-2986 (claim_frames, _fail_claims and fail_claims), :2987-3035 (generate_candidates, the read outside the try at :2997, the Exception clause and the release at :3017-3021, the finalise loop outside the try at :3023-3026 and the warning in the return at :3034), :3219-3230 (_batch_instant) and :3232-3298 (generate_all_candidates, the per-scene clause at :3277-3285, the warning copy at :3288-3289 and the guarded write at :3294-3297). apps/api/app/backend/services/storyboard_service.py:1555-1588 (_frame_counts and get_scenes), :2736 (PENDING_CLAIM_SECONDS), :2790-2799 (set_version_status and its missing guard), :3335-3353 (release_character_claim, the shape this card copies and the docstring that states the hazard), :4906-4949 (BATCH_HISTORY_LIMIT and record_batch, the newest-ten history at :4923-4927) and :4952-4981 (recent_batches). apps/web/src/pages/video-storyboard/VideoStoryboardScreen.tsx:628-653 (startStills and refreshRail), :663 (generating), :675-686 (the held presses and the filter that decides what is drawn) and :920-946 (the banner, already singular at :929-930), :765 and :1293-1310 (the failed-attempts block). apps/api/app/backend/tests/test_video_storyboard_e2e.py:2880-2920 (the _Started gate and the started fixture, which is how every test of this card presses the button) and :962-1006 (the two whole-board tests this card must leave green). apps/api/app/backend/tests/test_storyboard_batch_record.py:1-50 (what a press files, with no database). docs/ops/s44-acceptance.md (the note's contract and its bound) and docs/ops/s45-acceptance.md (where a propagating error lands, and the correction that filed this item). Open items #157, #158 and #159.
Deliverables. (1) The route reads the scene's position before it claims, with the same guard it uses for its other synchronous service calls, and hands it to the job. One row read on a press that is about to make three image calls, and it earns its place twice: the note's entry names the scene the way the whole-board note names it (storyboard_image_runner.py:3283-3284), and a database already unreachable refuses the press instead of claiming rows nothing will render. (2) One new function in storyboard_image_runner.py, beside generate_candidates, is what the route submits. It takes the session, the scene, the position, the user and the claimed rows, calls generate_candidates with them unchanged, and files the press into the batch note with the same writer, the same key shape and the same summary shape the whole-board press uses: batch_key from uuid.uuid4().hex, started_at and finished_at from _batch_instant, dropped empty, and scenes holding the one scene. (3) A press that fails with an ordinary Exception files failed: 1 and one scene entry carrying scene_id, position and error as a bare str(exc) with no prefix, exactly as :3282-3284 writes it, and then re-raises. Two rules hold that re-raise honest. The note write sits in its own clause, the way the whole-board write does at :3294-3297, so a record_batch that fails is printed and dropped rather than taking the press with it. And what leaves the function is the ORIGINAL exception and never the note's, because the gate's logger.exception at limits.py:423 is the only place the stack exists, and a card about telling the operator more must not tell the engineer less. (4) A press that succeeds with a look-alike warning files that warning as f"Scene {position}: ...", the sentence shape the whole-board press files at :3289, so the warning that used to be returned to a thread nothing reads now reaches the banner. (5) A press with nothing to report files nothing, because no reader draws it: the banner reports only a press that has a failure, a drop or a warning (VideoStoryboardScreen.tsx:685-686), so a clean press would file an entry nobody ever sees. (6) The release of the claim is guarded IN SQL, and the guard is the point of the deliverable. svc.set_version_status flips any row by id with no guard at all (storyboard_service.py:2790-2799), and a release run on the way out of a LATE failure would therefore rewrite a row that is already ready as failed: generate_candidates finalises its rows outside its own try (:3023-3026), so the second row's _finalise can raise after the first row is ready with its picture in R2. The narrowest correct shape is already in the tree for the other table: release_character_claim (storyboard_service.py:3335-3353) moves a claim that is STILL pending to failed in one statement and returns whether it was the call that did it, conditional in SQL rather than read-then-write, and its docstring states this exact hazard. This card adds the scene twin of it, whose guard is status IN ('pending', 'failed') rather than status = 'pending' alone, because the wrapper must cover both settled states it owns: a row still pending (the failure was the read at :2997) is released at once instead of showing the scene as generating for the whole claim window, and a row already failed at :3020 has its generic sentence replaced by str(exc), which the scene's own pane already prints (VideoStoryboardScreen.tsx:1299-1308). A ready row is never touched, and neither is a refused or a blocked one: the first has a picture, and the other two carry the provider's own sentence about that candidate. The runner loops the new call best-effort the way _fail_claims does at :2977-2980. One thing the rule costs, named here so it is not found later: a row that _finalise already failed with its storage sentence (:2641-2647) has that sentence replaced by the driver's. fail_claims, _fail_claims, set_version_status and generate_candidates:3020 are not changed, so the whole-board path's rows stay byte for byte what they are today. (7) A BaseException that is not an Exception is not a driver failure and is not shown to anybody as one. The gate catches BaseException at limits.py:412 and :422 where generate_candidates catches Exception alone at :3017, so an interrupt or a shutdown escapes it with the rows untouched. The wrapper gives that case a best-effort guarded release of the rows still pending and re-raises, and files NO note entry: an operator reading "this scene produced no frames" with a driver sentence under it would be reading an account of a process that was told to stop. (8) generate_stills's docstring paragraph at :2802-2807 gains the other half of what it already promises: it says what happens when the work itself fails after the answer was given, and where the operator reads it. generate_candidates keeps its docstring's contract and gains one sentence saying which function the route submits and why its own raise is still its answer to its caller. No comment names a card, an item or a phase. (9) docs/ops/s46-acceptance.md: what a single-scene press files and when, the three states a claimed row can be in when the press fails and what each is left carrying, why the release is conditional in SQL, where a gateway refusal is answered and where it is recorded, what an interrupt does, and the printed note and poll answer after a clean press, a warned press and a failed press.
Interfaces. Exposes one new runner function, which the single-scene route is the only caller of, and one new service function, the scene twin of release_character_claim, which that runner function is the only caller of. generate_candidates keeps its signature, its return and its raise, so the whole-board loop at :3277-3285 is untouched and still records the scene itself. claim_frames, fail_claims, _fail_claims and set_version_status keep their signatures and their behaviour. Consumes svc.record_batch, _batch_instant and _fail_claims's best-effort loop shape as they stand, and writes no new field: the poll route, recent_batches, the client types in apps/web/src/lib/videoStoryboardApi.ts and the banner all stay as S44 left them. No web file changes at all: the banner's failure sentence is already singular on main (VideoStoryboardScreen.tsx:929-930) and already pinned (VideoStoryboardScreen.test.tsx:303-316), so the press this card starts filing is drawn correctly the day it merges. The gate is not changed, so every other submitter keeps its hook and its sentence. Three behaviours change on purpose: a single-scene press that fails or warns takes one of the ten held note entries, and may push out the oldest held press, whichever kind that is; the version rows of a failed single-scene press carry the driver's sentence instead of the generic one; and a claimed row that was left pending by a failure before :3002 is released at once instead of after the claim window. One limit is kept on purpose, because this card files the press with the whole-board writer and the whole-board shape: a press that fails after one row is already ready is filed as a failed scene, exactly as the whole-board press files it today (storyboard_image_runner.py:3280-3284), so the banner's "produced no frames" is inexact for that case on both presses. The row stays the authority for the kept frame, and the scene's pane shows it. The entry's contract for a scene that failed part-way is open item #161 and is not this card's.
Acceptance. The api tier green as AGENTS.md sets it out, from apps/api, with DESELECT=$(grep -vE '^\s*(#|$)' ci-known-failures.txt | sed 's/^/--deselect /' | tr '\n' ' ') and .venv/bin/python -m pytest app/backend/tests -q -p no:cacheprovider $DESELECT, the targeted files run locally with the venv's own interpreter and pytest's own exit code read from the command. No web check, because no web file changes. Every test presses the button through the route with the _Started gate (test_video_storyboard_e2e.py:2880-2920), so none of them names the new function and each has a reason of its own on the base. FIVE ARE RED ON THE BASE, in apps/api/app/backend/tests/test_video_storyboard_e2e.py, each for its own reason: a press whose shot variation raises psycopg2.OperationalError leaves world.audit["last_batch"] with failed == 1 and the driver's bare sentence in the scene entry, where the base writes no note at all and the read of the note is what fails; the same press leaves that sentence in each row's provider_message, where the base leaves "Generation could not start. Try the scene again."; a press whose first scene read raises the same error leaves no row pending, where the base leaves three for the whole claim window; a press interrupted by a KeyboardInterrupt leaves no row pending, where the base leaves three because generate_candidates catches Exception alone; and the submission the route holds carries the scene's position beside the claimed rows, where the base submits the scene id alone. FOUR ARE GUARDS, green on both sides and labelled as guards, because the behaviour they hold cannot be wrong on a base that does none of it: a failure that comes after one row is already ready leaves that row ready with its picture and its key, which can only go red on a branch that releases without the SQL guard; record_batch raising leaves the original exception as the one the caller sees, which can only go red on a branch that lets the note write mask it; an interrupted press files no note entry; and a clean press files no note entry. One more guard lives in apps/api/app/backend/tests/test_storyboard_batch_record.py: recent_batches reduces a single-scene entry to the same five fields it reduces a whole-board entry to, which passes on the base because the reduction is shape-agnostic (storyboard_service.py:4952-4981) and which pins that the entry this card writes is one that reader can read. Three existing pins pass on both sides: test_a_database_failure_at_the_variation_fails_that_scene_in_the_batch_note (:988-1006) and test_one_failing_scene_does_not_stop_the_others (:962-985) stay green unchanged, so generate_candidates still raises to its caller and the whole-board rows keep the generic release sentence; and test_a_job_whose_slot_cannot_be_taken_is_failed_not_hung (test_job_limits.py:248-260) stays green, so the route's on_error hook still fires only for a slot failure and still says so. No comment the card adds names a card, an item or a phase. gitleaks detect --no-git --source . --config infra/secret-scan/gitleaks.toml --redact clean, and python3 infra/scripts/test-task-refs.py then python3 infra/scripts/task-refs.py check . clean. Record docs/ops/s46-acceptance.md; open item #157 closed.
Out of scope. Making the job gate call on_error for a body failure as well as for a slot failure (limits.py:422-424 reaching _fail's hook path at :396-399), which is the alternative that would fix every submitter at once and lost on measurement: every hook in the tree says the slot could not be taken (routes/video_storyboard.py:2821 and :2882, pipeline_runner.py:1853-1854, and the Fail type's own comment at storyboard_stage.py:35), so a body failure would be written down with a sentence that is false, in four places, to fix one; and every other target already records its own escape, so there is nothing there to fix. A new column, a status or last_error field on the scene row, or a migration, which lost because the measurement found everything needed already on main: record_batch, recent_batches, the poll route's batches field, the client type and the banner carry exactly this account for a whole-board press, the version rows already carry provider_message which the scene's pane already prints, and the guarded release already exists in the shape this card copies. Filing every single-scene press and not only the ones with something to report, which lost because no reader draws a clean press. A retention policy per class of press, keeping whole-board entries apart from single-scene ones or bounding them separately, which lost because record_batch is a plain newest-ten history (storyboard_service.py:4923-4927): which press pushed out which is the history's own business, and a policy over it is a different card with a different reason. Treating a BaseException that is not an Exception as a driver failure, which lost because an interrupt is not one and must not be shown to an operator as one; it gets the release and the re-raise and nothing else. Swallowing the exception in the wrapper instead of re-raising it, which lost because the gate's logger.exception is the only place the stack exists. Routing the single-scene press through generate_all_candidates with a one-claim list, which would buy the whole account for nothing and lost on two counts: that loop swallows the exception at :3277, so the stack goes, and it would bind this path to the clause open item #159 is about to change. Drawing the per-scene error sentence in the banner, or carrying the per-scene rows in recent_batches, which lost because S44 kept them out on purpose so the poll answer cannot grow with the board or with the presses of it (storyboard_service.py:4958-4964), and the sentence already reaches the operator on the version row. Changing what generate_candidates catches, or what _fail_claims writes for the whole-board path. Open item #158, prepare_cast's three broad clauses at storyboard_image_runner.py:985, :992 and :998, each of which turns a driver failure into a quiet continue; and open item #159, the whole-board per-scene clause at :3277 absorbing a gateway refusal that applies to every scene. Both are about which exceptions a clause should catch, this card narrows no clause, and each is carded on its own.
Waits on / Unblocks. Waits on nothing: S44 is merged (7ace835) and S45 is merged (80ab152), so record_batch, recent_batches, the poll field, the banner and both narrowed clauses are on main at a7146c4 in the shape this card reads. It shares three files with V52, which is in build. In apps/api/app/backend/services/storyboard_image_runner.py, V52 edits the character refine outcome at :1973-2063 and :2206, and this card adds a function beside generate_candidates at :2987-3035 and changes nothing above :2786. In apps/api/app/backend/services/storyboard_service.py, V52 edits record_spend at :4074, and this card adds the guarded release beside release_character_claim at :3335-3353. In apps/api/app/backend/routes/video_storyboard.py, V52 gives the refine route a structured error detail through _image_guard (:920-948 and :1291-1301), and this card's route calls _image_guard at :2813-2814 and edits :2786-2823. No pair of ranges meets, but three files are shared and _image_guard is an interface both use, so this card's worktree is cut from the main that holds V52's merge, and its build reads _image_guard as V52 left it. Its siblings #158 and #159 sit in the same runner file in other regions and are not carded yet. Unblocks every later card that makes more errors propagate on this path, which is what #157's row asks to happen first: from here a propagating error on a single-scene press has somewhere to land.
Built as (PR #152 → main 55c38b7, 2026-09-22). Code 3 review rounds → the first returned one must-fix (the part-completed press was proven kept and not proven filed: the late-failure test asserted the ready row alone, and decision 8's note-shape assertion had been moved into the early-failure test because any note assertion is red on the base), the second returned two (assert the whole late-failure entry by equality, {"scene_id", "position", "error"} with the exact error text, not its key set; make the record's hook sentence literal), the third APPROVE. Three commits: 9026288 the change; ea1178d the first must-fix; 0757031 the second pair. release_scene_claim(version_id, message, *, include_failed=True) -> bool sits beside release_character_claim in storyboard_service.py: one conditional UPDATE, SET status = 'failed', provider_message = %s WHERE id = %s AND (status = 'pending' OR (%s AND status = 'failed')), answering rowcount > 0, never read-then-write, so a ready row is never touched and a second writer waits on the row. generate_candidates_with_record in storyboard_image_runner.py wraps generate_candidates (signature, return and raise unchanged; one docstring sentence added) with three paths: an ordinary Exception releases the rows this run still owns through the guarded statement, best-effort per row with each failure printed the way _fail_claims prints it, then writes the whole-board failure note (failed: 1, one entry {scene_id, position, error}) in its own clause with its own failure printed and dropped, then re-raises the ORIGINAL object; a BaseException releases pending rows alone with include_failed=False and the sentence "Generation was interrupted before frames were completed. Try the scene again.", writes no note, and re-raises; a returned result that carries warning is filed in the whole-board summary shape (Scene N: … with the ready, refused and blocked counts) and returned; a clean press files nothing. generate_stills reads the scene's position through _guard(get_scene) before claim_frames, so an unreachable database refuses the press before any row is claimed; it submits the new function and passes no with_note_cost (the only True is the refine route). The e2e fake cursor gains a handler for the conditional UPDATE with true rowcount behaviour and one direct assertion (an eligible row flips and answers True; a ready row is untouched and answers False; a failed row with include_failed=False is untouched and answers False). Measured: eight tests red on the base for their own reasons (the submitted arguments lacked the position; KeyError 'last_batch' on a failed press, on a warned press and on the part-completed press; rows carried "Generation could not start"; rows stayed pending on a first read and on a real KeyboardInterrupt through _Started.run_the_batch(); AttributeError for the new function), 8 failed and 4 passed on f1b50ca; five guards green on both sides with three existing pins; the whole api tier 4,746 passed, 11 skipped, 3 deselected. The first commit was made with --no-verify and both scan halves run by hand, recorded as a deviation; no pre-commit hook is installed in the shared checkout, so commits two and three were preceded by an explicit pre-commit run over the staged change, both hooks passing. Parity limit, in Interfaces and open item #161: both presses file a scene that failed after one frame was ready as a scene that produced no frames. Found and not fixed here, filed as open items: a slot refusal files no note in either route (routes/video_storyboard.py:2858-2859, :2918-2919); the scene row is read twice before a claim (:2848 and storyboard_image_runner.py:3064); set_version_status flips any row by id with no guard (storyboard_service.py:2790-2799). Record docs/ops/s46-acceptance.md; open item #157 closed.
S47 · A nested audit write builds its parents, and the rig cache holds its entries · S · M · S2-W2 · fix/s/s47-a-nested-audit-write-lands (surfaced while building S44, pre-existing on main, open item #155; carded 2026-09-22)
Context. Measured at main f1b50ca. record_audit_key is the session audit's one-leaf writer (apps/api/app/backend/services/storyboard_service.py:4894-4911). Its statement is jsonb_set(COALESCE(storyboard_audit, '{}'::jsonb), %s, %s::jsonb, true) (:4907-4908), and create_missing creates only the LAST element of the path. So a path whose parent is absent changes the row not at all and raises nothing. Its own docstring says the opposite at :4903: "create_missing is on so the parent object is created on first write", which is true of a one-element path and false of every longer one. The function has six callers, and the item's list is right about all six and stale about two line numbers: storyboard_prompt_runner.py:229 writes ["prompts_run"] (:32), storyboard_image_runner.py:1183 writes ["cast_run"] (:1150) and :1608 writes ["character_wardrobe_scan"] (the item has these two at :1169 and :1594, fourteen lines up, and nothing else about them has moved), storyboard_song_runner.py:242 writes ["transcribe_run"] (:154), storyboard_service.py:4400 writes ["cast", "members", slug], and storyboard_runner.py:106 writes ["rigs", image_r2_key]. A re-resolve by content found no seventh caller anywhere under apps/. The four one-element paths have no ancestor and land. The cast member lands because save_cast always stores members (:4361), an invariant its own docstring states at :4356-4359 and save_cast_member leans on by refusing a slug the roster does not hold (:4382-4388). The rig cache is the one that has never worked. rigs exists in exactly three places, the constant (storyboard_runner.py:75), the read (:95) and the write (:106), so nothing creates the parent, so the write is a no-op on every session, so _rig_cache (:93-96) reads {} and the cache hit at :119-121 can never fire. THE COUNT, INFERRED FROM THE CODE AND NOT OBSERVED IN A DATABASE, because no database was reachable in this pass: rig_for (:110-131) has exactly one caller, _run_heygen at :1018, and reaches it once per render attempt, under if not request_id (:1008), so one attempt that gets past narration builds one rig and a session builds one rig per attempt per still, however many attempts fall on the same still. Two shapes make that large rather than tidy. A scene that uses a character rigs the CHARACTER SHEET's key, not the scene's still (:1009-1017), so one press of render all on an N-scene session with one character builds N rigs from ONE image where the cache was designed to build one. And a requeued attempt that had not yet persisted a provider request id (:597-598, the reaper's rule at storyboard_queue.py:33 and :486-507) builds another. THE ITEM IS WRONG ABOUT THE PRICE, and the card says so rather than repeating it: the item calls this "repeated work against a paid service", and the tree says a rig is free three times over, heygen_client.py:12 ("the rig. Free."), :400 ("Rigs are FREE and complete in ~12s") and storyboard_runner.py:117 ("Free and ~12s, so the cache is about latency and tidiness, not cost"). The gateway agrees: model_gateway.resolve is called by synthesize_speech (heygen_client.py:274) and by submit_render (:496) and by neither upload_asset (:355-389) nor create_photo_avatar (:394-429), so rig creation is metered nowhere and refused by no spend guard. What the defect costs is therefore roughly twelve seconds of an operator's render plus one asset upload per attempt (AVATAR_POLL_TIMEOUT_S at :73 records the measurement), and one avatar group per attempt accumulating in the HeyGen account with no delete call anywhere in the tree. That is time and clutter, and it is not a bill. The fix is small and its proof is a database test, which is the shape S44 used for the same column (apps/api/app/backend/tests/core/test_storyboard_batches_db.py, the measurement that found this at :273-292). THE FIRST DECISION: build the missing parents rather than refuse a path whose parent is absent, because a refusal is the wrong answer for the one caller that is broken. The rig cache's parent is absent on EVERY session exactly once, on the first write, and a function that refuses its own first write is a cache that can never start. Refusing is right for the other half of the case, an ancestor that exists and is NOT an object: replacing it would delete an object this run did not create, so that one fails closed and says so. Both halves stay in ONE statement, which is the property the docstring exists to explain (:4897-4901): three concurrent renders each read the cache, add an entry and write back a dict missing the other two, so a read then a write loses leaves. The statement takes the session row's lock, a second writer waits and re-evaluates its expression against the row the first committed, which is the argument record_batch already rests on (:4928-4930), and the ancestor guard is the one record_batch already writes, jsonb_typeof(...) = 'object' at :4952-4953. THE SECOND DECISION: a cached rig is confirmed before it is handed back. Today a stale id cannot hurt anybody because nothing is ever cached, and the tree cannot say whether a rig expires or is deleted at the provider, because the facts document the client cites is not in this repository (heygen_client.py:3-5 names a path under docs-local/, which the tree does not carry) and the client holds a create (:405) and a per-id read (:414) and no list and no delete. The day the cache works, a rig the provider no longer has is returned for every later render of that still, submit_render refuses it (:499-511, and a 404 is not ambiguous at :528), and the scene fails the same way for ever because nothing rewrites the entry. So the fix removes a self-heal, and it puts one back: a cache hit is confirmed with the read the client already makes inside its own poll, and any other answer builds a new rig and overwrites the leaf. That is safe to do on doubt precisely because the call is free.
Read first. apps/api/app/backend/services/storyboard_service.py:4883-4891 (get_audit, and the mode predicate the read carries and the write does not), :4894-4911 (record_audit_key, the false docstring sentence at :4903 and the statement at :4905-4911), :4914-4962 (BATCH_HISTORY_LIMIT and record_batch, the lock argument at :4928-4930 and the ancestor guard at :4952-4953 this card copies), :4331 (CAST_KEY), :4349-4361 (save_cast and the members invariant), :4364-4403 (save_cast_member, the roster refusal at :4382-4388 and the depth-three write at :4400) and :5097-5106 (record_audit, the shallow merge this is not). apps/api/app/backend/db.py:72-97 (get_conn commits on success and rolls back on every other way out, which is what makes a refusal leave the row byte-identical). apps/api/app/backend/services/storyboard_runner.py:68-75 (why the cache is keyed by the image key), :93-107 (_rig_cache and _remember_rig, and the docstring that states the concurrency rule), :110-131 (rig_for, the cache read at :119-121, the free calls at :123-129 and the write at :130), :383 (execute_render_job), :596-598 (the persisted request id a requeued attempt reclaims), :638-643 (the HeyGen branch) and :1002-1020 (_run_heygen, the if not request_id guard at :1008, the sheet-or-still choice at :1009-1017, the one rig_for call at :1018 and the job-row record at :1020). apps/api/app/pipeline/utils/heygen_client.py:1-31 (the four calls and which of them costs a credit), :72-73 (the measured rig time), :355-389 (upload_asset), :394-429 (create_photo_avatar, free, and the per-id read at :414 this card reuses), :473-516 (submit_render, the one gated call) and :519-530 (_looks_ambiguous, and why a 404 is unambiguous). apps/api/app/backend/services/storyboard_queue.py:446-460 (record_avatar_id, the per-job rig ledger) and :33 with :486-507 (the reaper's requeue rule). db/migrations/unified/production/001_baseline.sql:2046-2073 (storyboard_jobs, and heygen_avatar_id at :2059-2061). apps/api/app/backend/services/storyboard_image_runner.py:1150 and :1167-1184 (the cast stage record), and :1587-1613 (the wardrobe scan, and the comment at :1604-1607 that states this defect as a design constraint). apps/api/app/backend/services/storyboard_prompt_runner.py:32 and :217-230; apps/api/app/backend/services/storyboard_song_runner.py:154 and :231-242. apps/api/app/backend/tests/core/test_storyboard_batches_db.py:1-23 (the module's account of the defect), :44-66 (the fixture and the session builder every test of this card reuses) and :273-292 (the test that asserts the bug and must be turned round). apps/api/app/backend/tests/core/conftest.py:34-37 and :100-139 (the container the core tests start, and the migration runner they apply). apps/api/app/backend/tests/test_video_storyboard_delivery.py:1530-1542 (the source scan that pins the leaf write) and apps/api/app/backend/tests/test_video_storyboard_render.py:1-12 and :60-136 (the HeyGen fake and the fixture the two rig tests use). docs/ops/s44-acceptance.md:160-183 (the measurement, the caller table and the item as it was filed). Open item #155.
Deliverables. (1) record_audit_key builds every missing intermediate object in the SAME statement. The expression is composed in Python from the path's own length, one fixed wrapper per ancestor, and every wrapper reads the ORIGINAL column rather than the accumulating expression, so the statement grows by a constant per level instead of by a factor. Each wrapper sets its ancestor to itself when the column already holds something at that path, and to '{}'::jsonb when the column holds nothing there, applied shallowest first so the next wrapper always finds its parent; the leaf is then the jsonb_set that is there today, unchanged. The statement for depth one, depth two and depth three, with what each parameter is, is in docs/ops/s47-acceptance.md. One statement and no read then write, so the property the docstring explains survives intact: the UPDATE takes the session row's lock, a second writer waits and re-evaluates its expression against the row the first committed, which is the argument record_batch already rests on (storyboard_service.py:4928-4930). (2) An ancestor that EXISTS and is not an object is refused by name, and nothing is written. This is the fail-closed half of the decision: an object this run did not create is never replaced, because replacing it would delete an operator's record to make room for a leaf. Detection costs no extra round trip and no second read. The statement ends RETURNING storyboard_audit #> %s::text[] IS NOT DISTINCT FROM %s::jsonb AS landed, jsonb_typeof(storyboard_audit #> %s::text[]) AS a1, with ONE jsonb_typeof column per ancestor, shallowest first, all read off the NEW row; depth one returns landed alone and depth three returns a1 and a2. landed true is the write having landed and the function returns. landed false can only mean an occupied ancestor once (1) has built every absent one, and the FIRST ancestor column whose value is not object names the element in the way and says what it holds, which is what the refusal repeats back. The whole audit is deliberately NOT returned in its place: it carries a press history a 40-scene board makes large, this is the hot path of all six callers, and one boolean plus one short word per ancestor answers the same question. The raise is a StoryboardError, the service's own type, and its sentence is exactly f"Session {session_id} already holds a {kind} at {'.'.join(path[:i + 1])}, so {'.'.join(path)} cannot be written under it. Nothing was changed. Remove or rename that key if the record it holds is no longer wanted.", which reads "Session 41 already holds a string at cast, so cast.members.astronaut cannot be written under it. Nothing was changed. Remove or rename that key if the record it holds is no longer wanted." The raise happens inside with get_cursor(), so get_conn's rollback (db.py:72-84) leaves the row byte-identical with updated_at included. A statement that matches no row returns nothing at all and stays silent, which is what every other writer of this column does on a session id that is gone, and the docstring says so rather than leaving it to be discovered. (3) The docstring says what is true. The sentence at :4903 goes, and what replaces it states the three facts a caller needs: every missing ancestor is built in the same statement, an ancestor that is not an object is refused rather than overwritten, and the write is still one leaf and still safe against a concurrent writer of a sibling leaf. No comment names a card, an item or a phase. (4) A cached rig is confirmed before it is used. One new function in heygen_client over the read the poll already makes at :414, answering whether that avatar id is there and completed, and rig_for calls it on a cache hit. A confirmed rig is returned as it is today. Anything else, a rig the provider does not have, a rig in any other state, or a call that fails at all, builds a new rig and overwrites the leaf through _remember_rig. Rebuilding on doubt is the right default here and only here: the call is free on the tree's own evidence (heygen_client.py:12 and :400), so doubt costs about twelve seconds and never a dead scene, where trusting a stale id costs every later render of that still in that session. The confirm is not gated and records no usage, like the two calls it sits beside. (5) The two comments that state the defect as a constraint are corrected, because both are false the day this merges. storyboard_image_runner.py:1604-1607 says the wardrobe scan was not keyed per member because jsonb_set cannot create the parent object; the scan keeps its session-level slot and this card changes no behaviour there, and its reason becomes the one that is true, that one slot holds one scan and naming the subject is the honest fix. storyboard_service.py:4356-4359 says save_cast establishes members so that save_cast_member can set a single leaf; members is still written there for its own reason, and the sentence stops resting on a limitation that is gone. (6) apps/api/app/backend/tests/core/test_storyboard_batches_db.py is corrected where it records the defect as a property of the tree: the module docstring at :15-19 and test_record_audit_key_writes_nothing_when_the_parent_of_its_path_is_absent at :273-292, which asserts the bug and would go red on this branch. It becomes the opposite assertion under its own new name, keeps its second half unchanged (a parent that exists takes the leaf), and the module docstring says what the writer does now. Nothing else in that file moves, and record_batch is not touched: it stays the writer of a press, for the reason its own docstring gives. (7) docs/ops/s47-acceptance.md: the composed statement at each of the three depths with its parameters and its RETURNING columns; the three states an ancestor can be in and what each leaves behind; the six callers with the path each passes and what its write does on a fresh session before and after; the printed audit of a session after one rig, after two rigs on two stills and after two threads writing two leaves under an absent parent; the rig count per session per still before and after, with the read-only SQL that measures it off storyboard_jobs.heygen_avatar_id and the one-line query that shows the rigs key has never existed; what the confirm costs, why a doubtful answer rebuilds, and the plain statement that a rig is a free call on the tree's own evidence, against the item's "paid service".
Interfaces. record_audit_key keeps its name, its signature, its return and its one-statement-per-leaf property, and gains exactly one refusal, which no caller on main can reach: four pass a one-element path and have no ancestor at all, save_cast_member writes under cast.members which save_cast guarantees is an object (storyboard_service.py:4361), and _remember_rig writes under rigs, which after this card only this function ever creates and only ever as an object. The refusal is a StoryboardError, the service's own type, which save_cast_member already raises at :4385, so a caller that handles one is handling a type it knows. record_audit, record_batch, recent_batches, get_audit and get_audit_step are untouched, so the poll route, the banner and every audit reader stay exactly as S44 left them. rig_for keeps its signature and its return; _rig_cache and _remember_rig keep theirs. heygen_client gains one read-only function and changes none of the four it has. No route changes, no web file changes, no client type changes and no migration: the column, the readers and the job ledger are all on main in the shape this card uses. Three behaviours change on purpose. A storyboard session that renders on HeyGen starts carrying a rigs object in storyboard_audit, one small string per still, which no reader enumerates (every reader of the blob slices it by key, storyboard_runner.py:95, storyboard_service.py:4522, :5094, routes/video_storyboard.py:754 and :1066) and which the audit bundle therefore carries without any change to its shape. A second render of the same still on the same session stops building a rig and starts making one confirming read instead. And a depth-three or depth-two write into an audit that does not have the parent yet now lands, which is the whole point, so save_cast_member would also work on a session whose cast was written by something other than save_cast, where today it would silently do nothing.
Acceptance. The api tier green as AGENTS.md sets it out, from apps/api, with DESELECT=$(grep -vE '^\s*(#|$)' ci-known-failures.txt | sed 's/^/--deselect /' | tr '\n' ' ') and .venv/bin/python -m pytest app/backend/tests -q -p no:cacheprovider $DESELECT, the targeted files run locally with the venv's own interpreter and pytest's own exit code read from the command. No web check, because no web file changes. THE DATABASE LEG IS HEAVY AND THE CARD SAYS SO: every new DATABASE test this card adds lives in apps/api/app/backend/tests/core/, whose session fixture starts a pgvector/pgvector:pg17 container and applies every unified migration through the real runner (tests/core/conftest.py:34-37 and :100-139), so the targeted run is .venv/bin/python -m pytest app/backend/tests/core/test_storyboard_audit_paths_db.py app/backend/tests/core/test_storyboard_batches_db.py app/backend/tests/test_video_storyboard_render.py -q -p no:cacheprovider, which is the two database files and the one unit file this card writes tests into; it needs docker for the database leg and it is one heavy leg that runs alone on a shared box. A run started inside core/ mints JWT_SECRET and ENCRYPTION_KEY for itself, because that conftest imports the suite's own at :32. In CI it is the api job that runs them, in the same command as the rest of app/backend/tests. SIX PER-CALLER TESTS, one for each caller, each pressing that caller's own entry point against a session whose storyboard_audit is empty, and each asserting the leaf is readable afterwards. ONE IS RED ON THE BASE: _remember_rig on a fresh session leaves rigs holding that image key and that avatar id, where the base leaves the row byte-identical. FIVE ARE GUARDS, green on both sides and labelled as guards, because a one-element path and an invariant parent both work today and the card must not break them: the shot writer's prompts_run, the cast stage's cast_run, the wardrobe scan's character_wardrobe_scan, the song stage's transcribe_run, and save_cast_member after save_cast, which also proves the members invariant still holds where the docstring says it does. FOUR MORE TESTS OF THE WRITER ITSELF, in apps/api/app/backend/tests/core/test_storyboard_audit_paths_db.py, THREE RED ON THE BASE AND ONE A GUARD: a depth-three path into an empty audit lands with both parents built and no sibling touched, where the base writes nothing; two threads on two connections writing two leaves under the SAME absent parent both survive, repeated enough times that a lost update would have shown, where the base loses both; an ancestor that exists and is not an object raises the refusal and leaves the row byte-identical including updated_at, where the base writes nothing and raises nothing; and the guard, a leaf written under a parent that already exists keeps every sibling of that parent, which is the property that cannot go red on a base that has it and is the one a careless rewrite would lose. TWO RIG TESTS, both new, neither needing a database, in apps/api/app/backend/tests/test_video_storyboard_render.py, which is where the HeyGen client is exercised and which already patches httpx.post and httpx.get inside that module rather than the client's own functions (:131-136), so both go through the real client: a cache hit whose confirm says the rig is there returns it and posts neither the upload nor the create, and a cache hit whose confirm says anything else, a failure included, builds a new rig and overwrites the leaf. THE ONE TEST THAT COULD NOT BE WRITTEN and is named here so it is not looked for: nothing proves a real rig has or has not expired, because the tree has no evidence of a rig's lifetime at all and this card calls no provider. Existing pins that stay green unchanged: test_the_rig_cache_is_written_leaf_by_leaf (test_video_storyboard_delivery.py:1530-1542), which reads record_audit_key's source for jsonb_set and reads _remember_rig's for the absence of a cache read, so it holds this card to the same shape; and every test that monkeypatches record_audit_key (test_video_storyboard_transcribe.py:59 and :225, test_video_storyboard_prompt_runner.py:69, test_character_sheets_flow.py:1700, test_video_storyboard_queue.py:366-371, test_video_storyboard_characters.py:643 and :661, test_storyboard_spend_ledger.py:542 and :629), none of which changes because the signature does not. The one existing test that is EXPECTED to change is named in the deliverables: test_record_audit_key_writes_nothing_when_the_parent_of_its_path_is_absent (tests/core/test_storyboard_batches_db.py:273-292) asserts the defect and is turned round in the same commit. No comment the card adds names a card, an item or a phase. gitleaks detect --no-git --source . --config infra/secret-scan/gitleaks.toml --redact clean, then python3 infra/secret-scan/scan-archives.py --tree clean, which is both halves as AGENTS.md mandates them on main, and python3 infra/scripts/test-task-refs.py then python3 infra/scripts/task-refs.py check . clean. Record docs/ops/s47-acceptance.md; open item #155 closed.
Out of scope. The audit's other writers, which do not use this function and are not changed by it: record_audit's shallow top-level merge (storyboard_service.py:5097-5106), which is correct for what it does and is what save_cast needs, and record_batch (:4922-4962), which stays the writer of a press for the reason its own docstring gives, that reading batches and writing it back is the lossy pattern this function exists to avoid. Turning either into a caller of the fixed writer, which lost because neither wants a single leaf: one merges a fragment and the other applies a bound in the same statement. Any change to what a rig IS, how it is keyed or what it is built from: the key stays the source image's R2 key for the reason at storyboard_runner.py:68-72, the sheet-or-still choice at :1009-1017 stays as it is, and a rig built from a character sheet is still shared by every scene that uses that character, which is what makes the fix worth having. Deleting rigs at the provider or bounding how many an account holds, which lost because the tree has no delete call and no list call to bound against, and because a delete of a rig a session may still render with is a different card with a different hazard. Bounding the rigs object the way BATCH_HISTORY_LIMIT bounds batches (:4914-4919), which lost because the two are not alike: a press writes a summary carrying a row per scene, a rig entry is one short string per still, and a session has as many stills as it has scenes. Adding the mode predicate the read carries (:4887) to the write (:4909), which lost on the card's own principle: every caller is a storyboard runner holding a storyboard session, and a predicate added to a write can only make writes disappear, which is the class of defect this card exists to remove. S44's dedicated press statement and its bound, which this card reads and leaves alone; S44 did not use this function and nothing here reaches it. docs/ops/s44-acceptance.md, which recorded the finding and is a record of what was measured then, not a document to correct now. Whether a per-member wardrobe history should exist now that a nested path lands (storyboard_image_runner.py:1599-1610), which this card deliberately does not build: it corrects the comment's false reason and changes no behaviour, and a second slot with a second reader is a want nobody has stated.
Waits on / Unblocks. Waits on nothing. Everything it reads is on main at f1b50ca, which is V52 merged (PR #151) on top of V55 (e212ea1): record_audit_key and its six callers, the rig cache, the job ledger's heygen_avatar_id, the core test fixture and S44's database test. The branch is cut from that commit, so V52's edits to both files this card touches are already under it and no rebase is owed. It shares those same two files with S46, which is cut next, and neither pair of ranges meets. In apps/api/app/backend/services/storyboard_service.py, S46 adds a guarded release beside release_character_claim (:3335-3353) and this card edits record_audit_key (:4894-4911) and four docstring lines of save_cast (:4356-4359). In apps/api/app/backend/services/storyboard_image_runner.py, S46 adds a function beside generate_candidates (:3105) and this card edits the wardrobe scan's comment (:1604-1607). No open PR touches record_audit_key, storyboard_runner.py, heygen_client.py or either storyboard test under tests/core/; #137 and #118 add other files in that directory and change neither its conftest nor the suite's. Both files are shared surface, so whichever of S46 and S47 merges second is re-tested on the merge commit rather than on its own head. Unblocks every later want that needs a nested audit leaf to land on a session that has never had one: a per-member record of anything, a per-still record of anything, and any second cache on this column.
Built as (PR #153 → main 0a09b90, 2026-09-22). Code 2 review rounds → the first returned one must-fix (the confirm read caught Exception and let _request retry, so a retryable answer could make more than one GET against the one-read contract) and one wording, the second APPROVE. Four commits: b900fd4 the tests alone, red by design; b928481 the change; 517eec2 the record; 0394f1f the must-fix and the wording. _audit_key_expression sits beside record_audit_key in storyboard_service.py and composes one statement from COALESCE(storyboard_audit, '{}'::jsonb): for each ancestor path[:depth], shallowest first, it wraps the accumulating expression in jsonb_set(<accumulating>, <ancestor>::text[], CASE WHEN jsonb_typeof(storyboard_audit #> <ancestor>::text[]) IS NULL THEN '{}'::jsonb ELSE storyboard_audit #> <ancestor>::text[] END, true), where every CASE reads the original column, then applies the leaf jsonb_set(<accumulating>, %s, %s::jsonb, true) as before; the statement ends RETURNING storyboard_audit #> %s::text[] IS NOT DISTINCT FROM %s::jsonb AS landed plus one jsonb_typeof(storyboard_audit #> <ancestor>::text[]) AS a<n> per ancestor, read off the new row; the text is 226, 428 and 630 characters at depths one, two and three. record_audit_key keeps its signature and return and has three outcomes: landed true returns; landed false raises StoryboardError naming the shallowest ancestor whose type is not object ("Session N already holds a <kind> at a.b, so a.b.c cannot be written under it. Nothing was changed. Remove or rename that key if the record it holds is no longer wanted."), raised inside the cursor context so the rollback leaves the row byte for byte, updated_at included; no returned row is silent, as every other writer of the column, and the docstring says so. A NULL column is coalesced and lands. has_completed_photo_avatar(avatar_id, *, api_key=None) -> bool in heygen_client.py makes one free GET with retry=False, answers true only for a success whose status is completed, false for every other status and for HeyGenError, and lets any other error propagate; it is not gated and records no usage, like the two free calls beside it. rig_for in storyboard_runner.py calls it on a cache hit: a confirmed hit returns the cached id and posts neither the upload nor the create; a stale hit builds a new rig and overwrites the leaf through _remember_rig, whose behaviour and signature are unchanged. Four save_cast docstring lines and one storyboard_image_runner.py comment were corrected. Measured: eight tests red on the base for their own reasons (a depth-three write answered None; a rig lost on attempt 0 of the two-writer press; no StoryboardError for a scalar ancestor; the rig cache leaf absent; the turned-round batches test [] == ['first']; the cached rig not returned; both rebuild cases answered the cached id), 8 failed and 76 passed on 4966cc2; six guards green on both sides; the two-writer concurrency press over 25 fresh sessions lands both leaves under the same absent parent every round on the branch and loses both on the base; six per-caller tests press each caller's own entry point through the real writer, cursor and connection; the targeted files 84 passed, the render file 65 passed after the fix, seven files that monkeypatch record_audit_key 428 passed with the one pre-existing quarantined failure, the whole api tier 4,746 passed, 11 skipped, 3 deselected; containers before and after identical on every database run. Deviation: commit one spliced the last assertion of test_undecodable_bytes_are_not_disguised_as_png into the new rebuild test; commit two restored it byte for byte with no amend, and the base proof was re-run on the corrected text. Found and not fixed here, filed as open items: the quarantined delivery test reads a web file at a path the import moved (test_video_storyboard_delivery.py:1620-1623), so its quarantine reason is wrong; get_audit reads with AND mode = %s (storyboard_service.py:4887) while the writers do not (:4960); an unreachable backstop raise at :4981 is deliberate. Record docs/ops/s47-acceptance.md; open item #155 closed.
S48 · A stopped press stops once, and the cast chain writes down what it swallowed · S · M · S2-W2 · fix/s/s48-a-refused-press-stops-once (surfaced while building S45, pre-existing on main, open items #158 and #159; carded 2026-09-22)
Context. Measured at main 0a09b90, which is S47 merged (PR #153) on top of S46 (55c38b7). Four broad clauses are left on the frame path, three on the cast chain and one on the whole-board loop, and every one of them turns a refusal or a driver failure into a quiet answer. The three cast clauses are where the item says they are: apps/api/app/backend/services/storyboard_image_runner.py:985, :992 and :998, all three except Exception as exc: # noqa: BLE001, inside prepare_cast (:945-1001). The first wraps one call, sheet_pool(_launch_config(session_id)) (:983), prints that the sheet pool could not be read (:986-987) and returns done with every counter at zero (:988), which is byte for byte the answer the legitimate early return one line above gives a session that uploaded no sheets (:983-984); a dead database and a session with nothing to do are therefore the same record. _launch_config is svc.get_session_config and nothing else (:27-28), so the only failures that reach that clause are the driver's own out of get_cursor and a shape error over a malformed config. There is no stage here whose own failure may degrade. The second wraps read_uploaded_sheets(session_id) (:991), prints that the reference sheets could not be read (:993-994) and falls through to the next step. That stage already isolates every failure of its own one level down: read_one_sheet does not raise at all and records a failed reading instead (:875-897, the clause at :891), and the store write is caught per sheet (:935-940). The only calls left uncaught under it are _launch_config again (:927) and svc.record_spend (:896), both database reads and writes. So what reaches the second clause is never the reading's own failure either. The third wraps apply_matched_sheets(session_id, ensure_cast(session_id)) (:996-997), prints that the cast could not be prepared (:999-1000) and returns. This is the one the item names and it catches the most: ensure_cast re-raises the gateway's refusals by type (:187-190) and degrades only storyboard_llm.StoryboardLLMError (:191-204), and its tail calls match_uploaded_sheets, which re-raises the same refusals by type (:399-400) and degrades only the same one type (:401-416); apply_matched_sheets isolates every member on its own (:1055-1060). S41 took the roster's own error and S45 took the sheet match's and the shot variation's, so everything still arriving at :998 is a gateway refusal, a driver failure or a service refusal, and none of the three is the pairing's own. The item is right about the lines and understates the set: it says these clauses swallow the driver error the sheet match now raises, and they also swallow all six of model_gateway.GATEWAY_ERRORS (apps/api/app/backend/services/model_gateway.py:264-265), which the two stages below them raise on purpose. Where an operator would have read it, and this is what decides the shape of the fix: prepare_cast has exactly one caller, start_cast_preparation (:1210-1216) through _in_background (:1187-1207), and the function that actually runs it is _run_cast_stage (:1167-1184), which already wraps it in a clause of its own (:1177-1182) and writes a record carrying status, error and error_kind into the session audit under cast_run through svc.record_audit_key (:1150, :1183). The place to say it out loud exists, is one level up and is the shape every other create-time stage uses. Two things stop it working. The three clauses never let anything reach it, so the record always reads complete with zero counts. And nothing serves cast_run: the string occurs once in the whole tree outside its own tests (:1150), the scenes poll carries prompts_run and transcribe_run and not this one (apps/api/app/backend/routes/video_storyboard.py:755-759), so even a failed record would be written where only an audit export reads it. The fourth clause has moved and the card says so: the item puts the whole-board loop's per-scene clause near :3280 and it is now :3491, two hundred and eleven lines down, because S46 added generate_candidates_with_record above it (:3162-3246); its content is unchanged. It prints the scene as failed (:3492), counts it (:3493), appends {"scene_id", "position", "error": str(exc)} (:3494-3495) and continues (:3496); the summary is filed once at the end by svc.record_batch in a clause of its own (:3504-3508). What one paused guard costs a board press, counted: one failed scene per scene, not one. Every scene reaches _variant_prompts (:3132-3133), which calls the shot variation and re-raises the gateway's refusals by type (:3016-3019), and the image fan-out re-raises them too (apps/api/app/pipeline/storyboard_images.py:1101-1104). So a press of a forty-scene board makes forty gateway round trips that were all refused before the first one, writes forty entries carrying the same sentence, and the banner says that forty scenes produced no frames and to open them and try again (apps/web/src/pages/video-storyboard/VideoStoryboardScreen.tsx:927-932), which is forty scenes an operator is told to retry and none of which can succeed. Each scene's three rows also end failed carrying the generic sentence _fail_claims writes (:3144), not the refusal's own. The item's "missing key" is two different classes and only one is in the tuple: an unbound workflow key is BindingNotFound (apps/api/app/core/gateway.py:30, raised :83) and is in GATEWAY_ERRORS; a credential the workspace does not hold is provider_key.ModelKeyRefused (apps/api/app/pipeline/utils/provider_key.py:52), which is not in that tuple, is registered as its own refusal beside it (apps/api/app/backend/main.py:325-330 states the reason) and is re-raised by type by the image helper (storyboard_images.py:145-150). It stops the whole press exactly as a paused guard does, and today it is the one that reaches the loop as an ordinary exception. What the single-scene press does with the same refusal: generate_candidates_with_record has no typed clause, so a refusal takes its ordinary one (:3198), releases each claimed row with the refusal's sentence through the guarded statement (:3199-3204), files failed: 1 with one scene entry (:3205-3207), writes the note (:3208-3212) and re-raises the original object (:3213). Both routes refuse a paused guard synchronously before they claim anything (routes/video_storyboard.py:2843 and :2887), so a refusal only reaches either press when the guard is paused, the binding is changed or the credential is removed after the button was pressed. So the two presses already agree in shape and disagree in count and in name: both file a stopped press as a scene that produced no frames, which is a sentence about the scene when the truth is a sentence about the press.
Read first. apps/api/app/backend/services/storyboard_image_runner.py:27-28 (_launch_config, the one call under the first clause), :144-236 (ensure_cast, the typed re-raise at :187-190 and the narrowed degrade at :191-204), :332-425 (match_uploaded_sheets, the typed re-raise at :399-400 and the narrowed degrade at :401-416), :470 (sheet_pool), :875-897 (read_one_sheet, which does not raise, and the clause at :891 that absorbs a refusal per sheet), :900-942 (read_uploaded_sheets and its per-sheet store clause at :935), :945-1001 (prepare_cast and the three clauses), :1004-1061 (apply_matched_sheets and its per-member clause at :1055-1060), :1145-1216 (CAST_STEP, _run_cast_stage and the record it already writes, _in_background, start_cast_preparation), :2993-3040 (_variant_prompts and its three clauses), :3105-3159 (generate_candidates, the release at :3141-3145 and the generic sentence it writes), :3162-3246 (generate_candidates_with_record, the three paths S46 built) and :3443-3509 (generate_all_candidates, the per-scene clause at :3491-3496 and the note write at :3504-3508). apps/api/app/backend/services/model_gateway.py:55-67 (UsageRecordFailed, ModelNotActive), :110-125 and :195-205 (where they are raised), :161-171 (check, the synchronous pre-flight) and :260-265 (GATEWAY_ERRORS, the one list). apps/api/app/core/gateway.py:30-40 (BindingNotFound, SpendPaused), :83 and :134 (their raise sites). apps/api/app/backend/services/workspace_scope.py:45 and :87 (WorkspaceUnresolved). apps/api/app/core/bindings/store.py:55 (BindingsError). apps/api/app/pipeline/utils/provider_key.py:52-70, apps/api/app/pipeline/storyboard_images.py:140-155 (the character-model call, which does re-raise ModelKeyRefused by type), :1098-1110 (the frame fan-out, which re-raises the gateway's own tuple and turns everything else into a failed candidate, so it does not re-raise that class), and :244 with apps/api/app/pipeline/utils/openrouter_client.py:340, where a real image credential refusal is turned into a per-candidate refused result and never raises on the frame path at all. apps/api/app/backend/main.py:310-330 (how each refusal is answered at a route, and why one of them is not in the tuple). apps/api/app/backend/services/storyboard_service.py:232 (StoryboardError), :3356-3383 (release_scene_claim and what include_failed means, the conditional in SQL at :3378-3382), :4956-5013 (record_audit_key as S47 left it, the one-leaf writer this card's record goes through), :5067-5096 (recent_batches and the five fields it keeps, at :5091-5095), :5099-5101 (the three step states, running, complete and failed), :5104-5145 (claim_audit_step, which keys on a running record and a stale heartbeat, so a failed record blocks no later run) and :5194 (get_audit_step). apps/api/app/backend/routes/video_storyboard.py:726-759 (the scenes poll and the three audit keys it already serves), :2812-2861 (the single-scene press, the synchronous check at :2843, the position read at :2848 and the submit at :2853-2860) and :2864-2935 (the whole-board press and its synchronous claim). apps/web/src/pages/video-storyboard/VideoStoryboardScreen.tsx:675-687 (the held presses, the reduction into reportedBatches and the filter at :687 that decides what is drawn) and :920-947 (the report block, its reportedBatches.length > 0 condition at :920 and its three sentence shapes). apps/web/src/lib/videoStoryboardApi.ts:373-400 (StoryboardBatchSummary and StoryboardBatchNotice) and :1950-1961 (the poll answer's type). apps/api/app/backend/tests/test_storyboard_image_runner_gateway_refusals.py:1-75 (the module's account and the REFUSALS table every parametrised pin runs over) and :76-97 and :165-184 (the two pins that must stay green). apps/api/app/backend/tests/test_character_sheets_flow.py:1338-1380 (_Pass, the in-memory pass this card's cast tests reuse) and :1499-1575 (the four tests that press prepare_cast). apps/api/app/backend/tests/test_video_storyboard_e2e.py:932-1021 (the whole-board tests, the isolation pin at :976-999 and the driver-failure pin at :1002-1020) and :2880-2920 (the gate fake every press test uses). apps/api/app/backend/tests/test_storyboard_batch_record.py:1-170 (what a press files, with no database, and the five-field guard at :126). apps/web/src/pages/video-storyboard/VideoStoryboardScreen.test.tsx (how the screen is pressed with a stubbed poll). docs/ops/s45-acceptance.md (what each narrowed stage now raises, and the two items this card closes) and docs/ops/s46-acceptance.md:20-35 and :130-166 (the press table, where a gateway refusal is answered and the parity limit that stands). Open items #158 and #159.
Deliverables. (1) The three cast clauses keep their boundary and every one of them records what it swallowed, because none of the three has a stage error left to narrow to. S41 took the roster's own error and S45 took the sheet match's and the variation's, so what arrives at :985, :992 and :998 is a gateway refusal, a driver failure or a service refusal, and the caller above cannot act on any of them: _run_cast_stage (:1167-1184) writes a record and returns, and propagating would additionally throw away the steps that did work and the counts they earned. So done gains one key, errors, always present and normally empty, and each clause appends one entry to it carrying the step's own name, str(exc) and type(exc).__name__ before it continues or returns exactly as it does today. The first clause's entry is what tells a zero record apart from a session that simply has no sheets, which is the one place today's code cannot be read at all. (2) _run_cast_stage writes the record the operator reads, in the states that already exist. When counts["errors"] is empty the record is what it is today. When it is not, the record takes the existing svc.STEP_FAILED state (storyboard_service.py:5101), keeps the counts, and carries the first entry's sentence as error and its class as error_kind, which is the same two fields the existing failure branch writes (:1180-1182). No new state is added: the stage states stay running, complete and failed (storyboard_service.py:5099-5101), and no reader learns a new shape. A failed record blocks nothing later, because claim_audit_step keys on a running record and a stale heartbeat (storyboard_service.py:5123-5145). (3) The record is served and drawn where it cannot be missed. The scenes poll gains cast_run beside prompts_run and transcribe_run, read off the audit it already holds (routes/video_storyboard.py:754-759), so it costs no second read. The client type gains it. On the screen, the block at VideoStoryboardScreen.tsx:920 renders only when reportedBatches.length > 0, so a cast failure with no reportable press beside it would be invisible; the card therefore makes that block a report container whose condition is either reportable batch data or a cast_run record in the failed state, and inside it the cast failure is one line per entry saying which step of the cast chain did not run and the sentence it failed with. A log line is not enough and a key nobody serves is not enough either, which is why this half is in the card and not in a later one. (4) The loop re-raises a refusal that stops the whole press, and the press stops once. A new clause above the per-scene one (:3491) catches the refusal set by type and does four things: it releases the rows of the scene it was reached on with the refusal's sentence through svc.release_scene_claim on its default include_failed=True, because generate_candidates has already written those rows failed with its generic sentence (:3144) and the default is exactly the case that replaces it; it releases the rows of every scene the loop has not reached yet, from those claims' own version_ids, with include_failed=False, because those rows were never started and a settled row there is not this failure's to relabel; it files the press with the writer and the key it already holds, keeping every scene entry already earned, keeping failed as it stands and adding one sentence; and it re-raises the original object, so the job gate's own log keeps the stack. Scenes already rendered keep their entries and their frames. (5) The one sentence has an optional field of its own, stopped. It is not folded into warnings, which is a list of things that went right badly, and not into dropped, which is one entry per scene and would say the same sentence forty times again. It is written only by the two clauses of deliverables 4 and 6, so a press that ran to the end and an ordinary failing press carry no stopped key at all. recent_batches (storyboard_service.py:5067-5096) preserves that absence: it adds the key to the reduced entry only when the stored entry has it, so an older record and an ordinary record still reduce to exactly the five fields at :5091-5095 and the guard at test_storyboard_batch_record.py:126 stays green unchanged. The client type carries it as an optional field for the same reason, and the banner draws it as its own line above the failure count only when it is present. failed is not incremented for a stopped press: a press that was refused produced no frames anywhere, and telling an operator that forty scenes each produced no frames is the sentence this card exists to delete. (6) The single-scene press answers the same refusal the same way, so the two presses agree. generate_candidates_with_record gains the same typed clause above its ordinary one (:3198): it releases its rows with the refusal's sentence as it does today, files stopped with that sentence and failed: 0 with no scene entry instead of failed: 1 with one, and re-raises the original object. Nothing else in that function moves, and generate_stills (routes/video_storyboard.py:2812-2861) is not touched. (7) The refusal set is one name in one place and it is not a second copy of the gateway's. model_gateway.GATEWAY_ERRORS (model_gateway.py:264-265) stays the gateway's own list and is not widened, because more than twenty call sites consume it. storyboard_image_runner defines one module-level name over it, model_gateway.GATEWAY_ERRORS, and both new clauses use that name and nothing else, so the loop and the single-scene wrapper cannot drift apart. The credential class is not in the set, because it cannot reach either clause: the frame path turns it into a per-candidate refused result (openrouter_client.py:340, storyboard_images.py:244) and the variation path turns it into a StoryboardLLMError (storyboard_llm.py:405), so naming it here would state a stop that never happens. Whether a board press ought to stop on it is open item #173. The sheet match (:399-400) and the variation (:3016-3019) are not changed and do not need to be: each re-raises the gateway's own by type and lets everything that is not StoryboardLLMError propagate, so the three sites agree on every class in the set after this card. They do not agree about a credential refusal, and nothing here makes them: storyboard_llm.py:405 turns that class into a StoryboardLLMError before either site sees it, so the variation's narrowed degrade absorbs it, which is open item #173 and not this card's work. The comment at the new name says which two kinds of refusal it holds and why a press that meets one cannot meet anything else on the next scene, and names no card, item or phase. (8) docs/ops/<id>-acceptance.md: the four clauses with the exact line each was on, what each one caught and what each one now records; the printed cast_run record after a failed pool read, after a failed sheet read that still reached the roster, and after a failed pairing, beside the record a clean chain writes; the printed note and poll answer after a board press stopped on the first scene, on the fourth scene of ten and on a scene that failed for its own reason; the row states of a released scene in each of those three cases; the two presses' answers to one paused guard side by side, which is the parity #159 asks for; and the measured count that one paused guard cost a forty-scene press before this card and costs it after.
Interfaces. prepare_cast keeps its name, its signature and its stance that nothing here may fail the session; its return gains one key, errors, always present. _run_cast_stage keeps its signature and its return, and adds no state to the stage record: a swallowed cast error produces the existing failed status with counts retained, in the fields that branch already writes. record_audit_key is called exactly as it is called today, with the one-element path [CAST_STEP], which is the shape S47 left untouched, so nothing in that function changes for this card. generate_all_candidates keeps its name, its signature and its return, and gains one raise: a press stopped for the whole board now leaves by an exception instead of returning a summary, which the job gate already handles for the single-scene press and logs the same way. generate_candidates_with_record keeps its name, its signature, its return and its rule that what leaves it is the object that arrived. generate_candidates, claim_frames, _fail_claims, fail_claims, set_version_status and release_scene_claim are not changed at all, so the rows of a scene that failed for its own reason stay byte for byte what they are today. recent_batches gains one optional field and drops none: stopped appears in a reduced entry only when the stored entry carries it, so a press recorded before this card, and every press that was not stopped, reduce to the same five fields they reduce to today and every reader written before this keeps working. The scenes poll gains one key, cast_run, read off the audit it already holds. The two client types gain one optional field each, and the screen's report container gains one condition and one line. No route signature changes, no service call is added to any route, and there is no migration: the audit column, the note, the poll and the report block are all on main in the shape this card uses. Four behaviours change on purpose. A create-time cast chain that swallowed a failure now leaves a record that says so, and the board says so. A board press stopped by the gateway stops at the scene it was stopped on instead of calling the gateway once per scene. The scenes that press had claimed and not reached are released instead of reading as generating for the claim window and then as scenes with no frames. And a stopped press is no longer counted as scenes that produced no frames, on either press.
Acceptance. The api tier green as AGENTS.md sets it out, from apps/api, with DESELECT=$(grep -vE '^\s*(#|$)' ci-known-failures.txt | sed 's/^/--deselect /' | tr '\n' ' ') and .venv/bin/python -m pytest app/backend/tests -q -p no:cacheprovider $DESELECT, the targeted files run locally with the venv's own interpreter and pytest's own exit code read from the command. The targeted api run is .venv/bin/python -m pytest app/backend/tests/test_character_sheets_flow.py app/backend/tests/test_storyboard_image_runner_gateway_refusals.py app/backend/tests/test_video_storyboard_e2e.py app/backend/tests/test_storyboard_batch_record.py -q -p no:cacheprovider. No heavy leg: every test this card adds is in memory, none starts a container and none needs docker, because the cast chain's stores are monkeypatched by _Pass and the press tests run through the e2e fake cursor. The web tier as AGENTS.md sets it out, because three web files change: cd apps/web && npm ci && npm run lint && npm run typecheck && npm run build, and the targeted web run is npx vitest run src/pages/video-storyboard/VideoStoryboardScreen.test.tsx. .venv/bin/python -m py_compile clean over every changed Python file. Four tests of the cast chain, all RED ON THE BASE, in test_character_sheets_flow.py: a driver failure on the session config read records the first step's failure with its class and returns a record an operator can tell apart from a session with no sheets, where the base returns a zero record and says nothing; a driver failure inside the sheet read records the second step and still reaches the roster, where the base records nothing; a gateway refusal out of ensure_cast records the third step with SpendPaused as its class, where the base prints and returns; and _run_cast_stage over a chain that swallowed anything writes cast_run in the existing failed state with the counts kept, where the base writes it complete. One test of the poll, RED ON THE BASE, in test_storyboard_batch_record.py, which already presses the scenes poll for the held presses (:160): the answer carries cast_run with the failed record, where the base answers without the key. One reduction test, RED ON THE BASE, in test_storyboard_batch_record.py: recent_batches carries stopped for a stopped press and omits the key entirely for an ordinary press and for a record written before this card, which is what keeps the five-field guard at :126 true. Four tests of the presses, all RED ON THE BASE, in test_video_storyboard_e2e.py, two of them parametrised over the whole refusal table the gateway-refusals module already holds (test_storyboard_image_runner_gateway_refusals.py:36-43) : a refusal on the first scene of three stops the press, calls the gateway once and not three times, files one sentence with no scene counted as failed, and leaves the two later scenes' rows released with that sentence and their scenes with no entry of their own, where the base files three entries and makes three calls; a refusal on the second scene of three keeps the first scene's entry and its frames and files the sentence once; and the single-scene press meets the same refusal with the same sentence in the same field, which is the parity #159 asks for and is red on both sides of the wrapper today. One web test, RED ON THE BASE, in apps/web/src/pages/video-storyboard/VideoStoryboardScreen.test.tsx: a poll answer carrying a stopped press draws the stopped sentence and no "produced no frames" count, and a poll answer carrying a failed cast_run and no reportable press still draws the report container with the cast line in it, where the base draws nothing in either case. Two existing pins that prove the isolation survives, green on both sides and labelled as guards: test_one_failing_scene_does_not_stop_the_others (test_video_storyboard_e2e.py:976-999), which is S46's behaviour and the loop's whole reason, and test_a_database_failure_at_the_variation_fails_that_scene_in_the_batch_note (:1002-1020), which proves a driver failure is still one scene's failure and not a stop. Existing pins that must stay green unchanged: both parametrised gateway pins and the narrowing pins beside them (test_storyboard_image_runner_gateway_refusals.py:76-97, :98-164, :165-184, :185-260), which hold S41's and S45's stages to the types they catch; the nine tests of test_storyboard_batch_record.py, including the five-field guard at :126, whose reduced shape is unchanged for every record that was not stopped; and test_the_pages_are_read_BEFORE_the_roster_is_paired (test_character_sheets_flow.py:1499-1518) and the monkeypatch of prepare_cast at :1714. Three existing tests are expected to change and none is deleted, every assertion kept: test_a_step_that_dies_leaves_the_ones_before_it_standing (:1521-1531) and test_an_unreadable_pool_still_reaches_the_roster (:1563-1575) each keep their stub and every assertion and gain one that the swallowed failure is now in the record, which is what the card adds to the behaviour they pin; and test_a_session_with_no_sheets_is_left_to_read_its_roster_lazily (:1534-1544) asserts the whole return by equality and gains "errors": [] to it, with its meaning unchanged. No comment the card adds names a card, an item or a phase. bash infra/secret-scan/scan-tree.sh clean, which is the one command AGENTS.md mandates at AGENTS.md:39 and which runs both halves over one private copy of the set a commit can carry, and python3 infra/scripts/test-task-refs.py then python3 infra/scripts/task-refs.py check . clean. Record docs/ops/<id>-acceptance.md; open items #158 and #159 closed.
Out of scope. S41's narrowed roster read and S45's narrowed sheet match and shot variation, which are done and are what made this card's decision possible: each already re-raises the gateway's own by type and degrades only its own model error, so narrowing them again would be work on a stage that has none left. The reaper and every queue claim, which decide what happens to work that was abandoned rather than stopped, and which no line of this card reaches. The single-scene route's own handling: generate_stills (routes/video_storyboard.py:2812-2861) is unchanged, and the only thing deliverable 6 changes on that press is inside generate_candidates_with_record, where the refusal is filed as a stop instead of as one failed scene; the synchronous model_gateway.check() at :2843, the position read at :2848, the claim at :2850 and the slot hook at :2858-2859 all stay exactly as S46 left them. Widening model_gateway.GATEWAY_ERRORS to hold ModelKeyRefused, which loses because that tuple is the gateway's own and is consumed at more than twenty sites, three of them in other packages, and because the app answers the two refusals with different statuses on purpose (main.py:310-330); this card names one set over it for its own two clauses, holding the gateway's classes and no other, and changes the tuple not at all. Every other broad clause in the frame runner, which is thirty-four in the file by grep -n 'except Exception\|except BaseException\|except:' apps/api/app/backend/services/storyboard_image_runner.py | wc -l and is listed here by line so the next carding pass can take them. On the cast path and not in this card, each with the reason it stays: :891 in read_one_sheet, which absorbs a gateway refusal and a credential refusal per sheet and writes them as a failed reading, so a paused guard reads as a page nobody can read, which is the same defect one level down and is filed as open item #170 for its own card rather than ridden in here; :935, which loses one reading and keeps the others and is the isolation the stage is built on; :1055 in apply_matched_sheets, which loses one member's build and keeps the rest, the same argument; :1177 in _run_cast_stage and :1204 in _in_background, which are the outermost clauses that write the record this card feeds and must stay broad for exactly that reason; :58 and :91 on the plate reads; :776; :1139. Elsewhere in the file: :1611 on the wardrobe leak scan, which S45 put out of scope for the same reason and whose comment S47 corrected; :1725, :1952, :2048, :2125, :2759, :3095, :3141, :3418, :3507; the five clauses S46 wrote inside generate_candidates_with_record (:3198, :3202, :3211, :3227, :3244), which are that card's measured shape and are changed by this one only where deliverable 6 says; and the six BaseException clauses (:1368, :1848, :1968, :2287, :2358, :3214), which separate a stop from a failure and are a different question from this card's. The parity limit S46 recorded and filed as open item #161, that a press failing after one frame is ready is filed as a scene that produced no frames on both presses, which this card neither fixes nor worsens because it does not touch the failure path's counts. Changing what the report container says for a press that genuinely failed scene by scene, or what a released row's sentence means to the scene's own pane.
Waits on / Unblocks. Waits on nothing. S47 is merged (PR #153), and this card starts from that main, 0a09b90, so the two files it shares with S47 carry S47's work already and there is no second merge to re-test against it. Everything the card reads is on that commit: the three cast clauses, _run_cast_stage and its record, record_audit_key in the form S47 left it, the narrowed stages S41 and S45 left, release_scene_claim and its include_failed rule, generate_candidates_with_record, the note, the poll and the report block. S47 moved every service anchor after storyboard_service.py:4386 down, so the lines this card quotes in that file are the post-merge ones: record_audit_key at :4956, recent_batches at :5067-5096, the three step states at :5099-5101 and claim_audit_step at :5104-5145. The runner's lines did not move, because S47's only change there was four lines of one comment at :1604-1607. No other open pull request or in-build card touches this card's files: V56 is a web renderer outside video-storyboard, and A9 is outside apps/api/app/backend altogether, so neither shares a file with it. Unblocks every later reading of the cast chain, which from here says what actually failed, and every later want that needs a press to stop rather than repeat: a refusal that arrives mid-press on any other fan-out in this file can copy the shape, and the clause at :891 that hides the same refusal one sheet at a time can be taken with the record this card serves already drawn.
Built as (PR #154 → main de99c60, 2026-09-22). Code 3 review rounds → the first returned one must-fix (the shared refusal set still carried the missing-credential class, and a real credential refusal never raises at that boundary at all, because the transport turns it into a value at apps/api/app/pipeline/utils/openrouter_client.py:340 and the image layer turns that value into one refused candidate at storyboard_images.py:244, so a clause for it would have been dead code), the second one wording point on the two docstrings, the third APPROVE. Six commits: 7646c37 the tests alone, red by design; de6803f the cast chain and the stop; c2ed614 the board; 5fa92f7 the record; cf24620 the must-fix; 685a916 the docstrings. The three cast clauses keep their boundary, because the caller above them writes a record and returns, and propagating would throw away the steps that did work and the counts they earned; each now appends an entry to a new errors key on the answer, always present, carrying the step's own name, the error's sentence and its class, so a record of zeros can be told apart from a session that has nothing to do, and a chain that swallowed a failure is recorded as failed and not as complete. Both presses read ONE module-level set, PRESS_REFUSALS = model_gateway.GATEWAY_ERRORS (apps/api/app/backend/services/storyboard_image_runner.py:34), at :3267 in the whole-board loop and :3581 in the single-scene wrapper S46 added, so the two cannot drift apart: a refusal releases the claim it was holding with the refusal's own sentence, releases every later claim that is still pending with include_failed=False (:3601, :3595) so a row that settled on its own is never relabelled, writes stopped and finished_at on the batch, records it, and re-raises. The board then says so, because the batch summary carries stopped, the scenes poll returns the cast_run, and the screen draws both. Measured: 23 api cases and 2 web cases red on the base, each for its own reason, proven in a second worktree cut from 0a09b90 with this card's sources absent; the six cases of each parametrised press test are the six classes of model_gateway.GATEWAY_ERRORS (SpendPaused, ModelNotActive, UsageRecordFailed, WorkspaceUnresolved, BindingNotFound, BindingsError); two existing pins stay green on both sides, and the one that matters most is test_a_database_failure_at_the_variation_fails_that_scene_in_the_batch_note, because a driver failure at the shot variation is still one scene's failure and not a stop, the new clause catching by type. On a three-scene press the base makes three gateway calls and files three failed scenes and this makes one call and stops; on a forty-scene board that is forty calls against one. The whole api tier read 1 failed, 4,782 passed, 11 skipped and 3 deselected, and that one failure is not this card's: it is test_video_storyboard_delivery.py::test_two_callers_at_once_are_never_handed_the_same_path, open item #133, which failed the same way alone at the base with these sources absent and passes alone on the branch. Nothing was quarantined and ci-known-failures.txt is untouched. Deviation, argued and agreed: the settled-row test was written around a missing credential while that class was believed to stop a press, and it is the only end to end proof that the loop releases the claims it never reached without relabelling a row that settled on its own, so the class it raises became BindingNotFound and the test stayed, which is why the red-on-base count is 23 and not 22. Found and not fixed here: a scene that failed for its own reason keeps the generic row sentence (storyboard_image_runner.py:3209) while the note carries the driver's own, so the banner and the scene's pane disagree, which the card puts out of scope; read_one_sheet absorbs a gateway refusal per sheet and writes it as a failed reading (:901), the same defect one level down, already open item #170; and the delivery helper at test_video_storyboard_delivery.py:164-167 is the one ffmpeg call in its chain without check=True, so it turns an ffmpeg that exits under load into an assertion about a missing line and drops the exit status and the standard error, which is the mechanism open item #133 could not name before and is now written into it. Filed with the merge: open item #173, that an image credential the workspace does not hold is converted to one refused candidate and so cannot stop a press, whose repair moves the image-result contract step3_imagegen and the key-refusal caller tests depend on, plus the redaction sentence at apps/api/app/pipeline/storyboard_llm.py:414. Record docs/ops/s48-acceptance.md; open items #158 and #159 closed.
S49 · A refinement reads its roster once, and the redraw's other refusal names the member the panel shows · S · S · S2-W2 · fix/s/s49-one-roster-read-for-a-refinement (surfaced while building S43, pre-existing on main, open items #153 and #154; carded 2026-09-22)
Context. Measured at main 0a09b90, which is S47 merged (PR #153). A roster read is one SELECT of the whole session audit column, not a cheap lookup: svc.get_cast (apps/api/app/backend/services/storyboard_service.py:4364) returns get_audit_step(session_id, CAST_KEY) (:4376), get_audit_step reads get_audit (:5194-5196), and get_audit is SELECT storyboard_audit FROM sessions WHERE id = %s AND mode = %s (:4913-4921). Two service functions take that read for themselves when they are handed none: member_slug at :4457-4458, and read_character at :4581-4582, which takes it only when the slug is truthy and no roster came with the call, so everything this card measures bites on a session with a roster and a member being refined and never on the primary. refine_character (apps/api/app/backend/services/storyboard_image_runner.py:2193) reads once at :2215 and threads that roster into svc.member_slug (:2219), svc.read_character (:2220) and _instruct_fields (:2265-2266), which is what S43 built. Its two render calls do not take it: the tweak at :2311-2316 and the rework at :2333-2340 both call _render_carrying_the_note, whose signature is (session_id, instructed, **fields) (:2344) and which hands its keywords straight to _render_character_sheet at :2357, and neither call names cast. _render_character_sheet already declares cast: dict | None = None at :1740, inside the signature that runs :1730-1741, so this is threading alone and no signature moves. It reads the roster in two places: svc.read_character(session_id, slug, cast) at :1781, which takes a read for a member when cast is None, and svc.read_character(session_id, slug) at :1956, the clause that answers a sheet that was drawn and could not be stored, which passes no cast at all although the parameter is in scope. The counts per path, for a member, are therefore: a refinement refused before the render reads once (:2215); a tweak that renders reads twice (:2215, :1781); a rework that renders reads twice (the same two); the rare store-failure branch of either reads six times on the measured path of a tweak whose store step raises, three its own (:2215, :1781, :1956) and three inside update_character_text (storyboard_service.py:4819, :4820, :4888, open item #174); and a redraw reads once (:2388, threaded into the render at :2419). The item's account that S43 took a refinement from three reads to two is confirmed for a refinement that draws, and it is incomplete for one that does not: a refinement refused before the render went from two to one, which the item does not say. S43's own record files the same finding (docs/ops/s43-acceptance.md:183-190). Four line numbers in the item have moved and one is now a different statement: the item gives the render calls at :2200 and :2220, the render's read at :1766 and the redraw's threaded call at :2286; measured here they are :2311 and :2333, :1781, and :2414-2419, and :2220 is live today as refine_character's own read_character and not a render call. The third caller of _render_character_sheet is _invent_character at :1371-1375, and it passes cast=cast already, so a refinement is the only caller in the tree that holds a roster and does not hand it over; one test calls the function directly with neither slug nor cast (apps/api/app/backend/tests/test_video_storyboard_m2.py:908), which is the primary and reads nothing. On the second item, the redraw's other refusal is at :2407-2412 and opens There is no written description of this character to draw from; the cast is in hand from :2388 and the record from :2390, and eleven lines above it the first refusal already names the member through character_cast.sentence_name (:2396-2397). S43 tabulated this sentence and left it, with the reason that it never printed a slug (docs/ops/s43-acceptance.md:44, :191-196). The one answer is character_cast.sentence_name(cast, slug, record) (apps/api/app/pipeline/character_cast.py:165-197): the character's own name, else the part the roster gives the slug, else UNSHOWN_CHARACTER, the literal the character (:162). Every refusal on the two paths, by line: :2208 Unknown refine mode: {mode!r}. Use tweak or rework. and :2210 A refinement needs a note saying what to change., both raised before :2215, so neither the record nor the cast is in hand; :2170-2173 the instruction refusal and :2229-2231 There is no sheet for {who} to refine., both already through sentence_name (S43); :2236-2238 Tweak changes the current sheet, so there has to be one. Use Rework to build the sheet from a description instead., where the record and the cast are both in hand and the sentence names the sheet rather than any person; :1862 the in-flight sentence, whose text is the shared constant _SHEET_IN_FLIGHT at :1653-1654 and whose other raise is :1683, inside _refuse_if_sheet_in_flight, where neither the record nor the cast is in hand; :2398-2400 There is no character for {who} to draw., already through sentence_name (S43); and :2407-2412, the one this card changes. What one harness can prove is limited, and that limit decides the shape of the count pins. _Refine (apps/api/app/backend/tests/test_character_sheets_flow.py:2001-2101) counts roster reads through its own get_cast stub (:2050-2052, :2055) but also stubs svc.read_character (:2058-2059) and runner._render_character_sheet (:2078-2080), so no test in that file can see either of the render's reads today, and S43's count pin test_a_refinement_reads_the_roster_once (:3192-3203) is taken on a refinement that renders nothing and reads 1 on the base and on the branch. The store-failure read has to be counted where the real render runs, and that is test_video_storyboard_e2e.py: its world harness (:409-528) leaves svc.read_character and svc.store_character_sheet real, stubs the healthy upload at :418-419 and the roster at :455, and already makes a store fail by raising from svc.r2_service.upload_bytes (:897-899, inside test_a_version_is_never_ready_before_its_bytes_are_stored at :891-908). Nine test files stub get_cast (test_character_sheets_flow.py 14 times, test_character_starting_points.py and test_video_storyboard_characters.py 3 each, and once each in test_image_key_refusal_callers.py, test_video_storyboard_e2e.py:455, test_storyboard_image_runner_gateway_refusals.py, test_storyboard_audit.py:159, test_character_claim_release.py:234 and test_storyboard_spend_ledger.py); one stubs _render_character_sheet (test_character_sheets_flow.py, 4 stubs); and test_character_model_failures.py is the only file that stubs refine_character and redraw_character, through its route table (:190-192) and its own stub at :378. The sentence this card changes is pinned once, and for the primary alone: test_drawing_again_with_no_description_refuses_instead_of_doing_nothing (apps/api/app/backend/tests/test_video_storyboard_e2e.py:1998-2020) drives redraw_character(SESSION_ID, user_id=1) with no slug and asserts no written description, so the primary's word decides whether it stays green.
Read first. apps/api/app/backend/services/storyboard_image_runner.py:1730-1745 (_render_character_sheet's signature, with cast already optional at :1740), :1772-1781 (the one read and the paragraph that says why the cast is threaded), :1940-1956 (the store-failure clause and its second read), :1653-1654 and :1657-1684 (_SHEET_IN_FLIGHT and _refuse_if_sheet_in_flight, the refusal with nothing in hand), :1855-1862 (the same sentence raised inside the render window, where the cast is in scope), :1371-1375 (_invent_character's render call, the shape to copy), :2092-2093 and :2163-2179 (_instruct_fields's cast keyword and its named refusal), :2193-2240 (refine_character, the read at :2215, the threaded calls, the two refusals), :2265-2266 (the instruction call), :2308-2341 (the two render calls that pass no cast), :2344-2360 (_render_carrying_the_note, which forwards its keywords whole) and :2363-2419 (redraw_character, the threaded read, the named refusal at :2396-2400, the unnamed one at :2407-2412 and the threaded render call at :2414-2419). apps/api/app/backend/services/storyboard_service.py:4364-4376 (get_cast), :4442-4459 (member_slug and the read it takes when handed none), :4564-4585 (read_character, the cast parameter and the rule that it reads only for a truthy slug it was given no roster for), :4913-4921 (get_audit, the read this is all about) and :5194-5196 (get_audit_step). apps/api/app/pipeline/character_cast.py:143-156 (part_name) and :159-197 (UNSHOWN_CHARACTER and sentence_name, the three steps). apps/api/app/backend/tests/test_character_sheets_flow.py:1865-1880 (_NAMELESS and _PANEL_CAST, the roster the sentence tests use), :2001-2101 (the _Refine harness, the read counter at :2026 and :2050-2052, the three stubs that hide the render's reads), :3047-3189 (S43's twelve sentence pins) and :3192-3203 (S43's count pin). apps/api/app/backend/tests/test_video_storyboard_e2e.py:409-528 (the world harness, the healthy upload at :418-419, the roster stub at :455, and svc.read_character and svc.store_character_sheet left real), :891-908 (a store made to fail, by the raise at :897-899), :1155-1167 (a cast member built in this harness), :1801-1821 (a tweak driven in it), :1998-2020 (the one pin of the sentence this card changes) and :2440-2444 (_CAST_OF_TWO). apps/api/app/pipeline/storyboard_images.py:954-967 (is_placeholder_prompt, the condition that reaches the changed refusal). docs/ops/s43-acceptance.md:33-44 (every sentence in these functions and what was decided about each) and :181-196 (both items as they were filed). Open items #153 and #154.
Deliverables. (1) A refinement's render calls take the roster the refinement already holds: cast=cast is added to the tweak call at :2311-2316 and to the rework call at :2333-2340. _render_carrying_the_note is untouched, because it already forwards every keyword it is given, and _render_character_sheet is untouched above :1781, because cast is already its parameter at :1740. From here a refinement that draws reads the roster once, the way a redraw does. (2) The store-failure clause at :1956 reads with the cast the function holds, svc.read_character(session_id, slug, cast), so the rare branch that answers a sheet bought and not stored does not take a third read for a member. This read is named in neither item and is measured here. It is one word on one line inside a function this card is already threading, and it is what takes the refinement's own reads on the store-failure branch from three to one and the redraw's own from two to one, the whole measured path of a tweak whose store step raises going from six reads on the base to four after, because update_character_text keeps three of its own (open item #174). (3) The paragraph at :1776-1780, which says the cast is None for the callers that do not hold one, is corrected to say what is then true: every caller in the product hands one over, and the parameter stays optional because the primary never needs it and a direct caller may have none. (4) The redraw's second refusal names the member the panel shows, in the shape the three S43 sites use. Today the sentence at :2407-2412 opens There is no written description of this character to draw from. After, the subject comes from who = character_cast.sentence_name(cast, slug, character) if slug else "this character", so a member with a built name reads There is no written description of Rosie to draw from, a member the roster holds without a name reads of Sons Friend, a slug the roster does not hold reads of the character, and the primary reads of this character, exactly as it does today. Everything after that phrase is unchanged, word for word. (5) Two refusals on these paths keep their words, and the record says why for each. The tweak refusal at :2236-2238 keeps its sentence because its subject is the sheet and not the person: it names no character at all, so there is no phrase to replace, and naming a member there would be new text rather than a subject swap, which is the reading S43's record already recorded for it. The in-flight sentence keeps its words because it is one shared constant (:1653-1654) raised from the build, the refine and the redraw paths, and at one of its two raises (:1683) neither the record nor the cast is in hand, so naming a member would split one condition into two sentences for no gain to the operator, who pressed the button on one character's panel. (6) The _Refine harness gains the two product rules it currently hides, so a roster read inside a render can be counted at all: its read_character stub takes the roster read the service takes, which is one get_cast when it is handed a slug and no cast (storyboard_service.py:4581-4582), and its _render_character_sheet stub makes the one read the real function makes, svc.read_character(sid, kw.get("slug"), kw.get("cast")), which is storyboard_image_runner.py:1781. No existing assertion is weakened and no stub loses a behaviour; only one test asserts the counter today and its refinement draws nothing, so it stays at 1 on both trees. The store-failure read is counted in test_video_storyboard_e2e.py instead, where the render, the store and read_character are all real and no harness rule has to be mirrored. (7) One comment at each changed site, saying that a refinement holds the roster it was going to read again, and that a sentence an operator reads names what the panel shows. No comment names a card, an item or a phase. (8) docs/ops/<id>-acceptance.md: the roster reads per path before and after, as a table with a line number for every read and a row for the store-failure branch; every refusal on the two paths with its sentence, whether the record and the cast are in hand and what was decided; the changed sentence printed in four states, a member with a name, a member without one, a slug the roster does not hold and the primary, produced by running the real function through the harness rather than typed; and the harness rules added, each with the product line it mirrors.
Interfaces. Exposes nothing new and changes no signature. _render_character_sheet keeps its parameter list, and cast stays optional at :1740 for the primary and for a direct caller. refine_character, redraw_character and _render_carrying_the_note keep their signatures, their returns and their refusal types, and _render_carrying_the_note keeps forwarding whatever keywords it is given. Consumes character_cast.sentence_name and part_name unchanged, and the two service reads through the cast parameter each already declares. No route, no client and no web file changes: the sentence lives in the server, and searching the tree for its text returns the runner and one test. One thing an operator can see changes, which is the subject of one refusal on the redraw path; everything else here is the same answer bought with one read fewer.
Acceptance. The api tier green as AGENTS.md sets it out, from apps/api, with DESELECT=$(grep -vE '^\s*(#|$)' ci-known-failures.txt | sed 's/^/--deselect /' | tr '\n' ' ') and .venv/bin/python -m pytest app/backend/tests -q -p no:cacheprovider $DESELECT, the targeted files run locally with the venv's own interpreter and pytest's own exit code read from the command. The targeted api run is .venv/bin/python -m pytest app/backend/tests/test_character_sheets_flow.py app/backend/tests/test_video_storyboard_e2e.py app/backend/tests/test_video_storyboard_m2.py app/backend/tests/test_character_claim_release.py app/backend/tests/test_image_key_refusal_callers.py app/backend/tests/test_character_model_failures.py -q -p no:cacheprovider. No heavy leg: every test this card adds runs in memory on a stubbed harness, none starts a container and none needs docker. No web tier, because no web file changes. .venv/bin/python -m py_compile clean over every changed Python file. Five tests of the roster reads, in test_character_sheets_flow.py: a tweak that renders reads the roster once, RED ON THE BASE, where the base reads twice; a rework that renders reads the roster once, RED ON THE BASE, where the base reads twice; the tweak's render call is handed the roster the refinement holds, RED ON THE BASE, where the base call carries no cast keyword at all; the rework's render call is handed it too, RED ON THE BASE, for the same reason; and a redraw that renders reads the roster once, GUARD, which is what it does today and what this card must not cost. Those counts are only worth what the harness under them is worth, so the _Refine stub of svc.read_character takes its roster read exactly when the service takes one, which is when it is handed a slug and no cast (storyboard_service.py:4581-4582); a stub that always reads, or never reads, makes the count prove nothing. One test of the store-failure branch, RED ON THE BASE, in test_video_storyboard_e2e.py: a tweak of a cast member whose store step raises reads the roster four times, where the base reads six times (:2215, :1781, :1956, and three inside update_character_text, open item #174); the store is made to fail the way this file already fails one, with a local function that raises RuntimeError monkeypatched onto svc.r2_service.upload_bytes (test_video_storyboard_e2e.py:897-899), the member is built first while the upload is healthy (:418-419, in the shape of :1155-1167), and the count is taken on the roster stub at :455 with svc.read_character and svc.store_character_sheet left real, so the rule being counted is the product's own and not a stub's. Four tests of the changed sentence, in test_character_sheets_flow.py, on _PANEL_CAST: a member with a built name is named Rosie, RED ON THE BASE, where the base says this character; a member the roster holds with no name yet is named Sons Friend, RED ON THE BASE, where the base says this character; a slug the roster does not hold is named the character, RED ON THE BASE, where the base says this character; and the primary still says this character, GUARD, which is the word the card keeps. One test of the decision to keep a sentence, GUARD: a tweak refused for want of a current sheet still reads Tweak changes the current sheet, so there has to be one. for a named member, which holds the written reason in deliverable 5 to the code. Existing pins that must stay green unchanged, named: S43's twelve sentence pins (test_character_sheets_flow.py:3047-3189) and S43's count pin test_a_refinement_reads_the_roster_once (:3192-3203), all GUARD, the count pin still reading 1 because its refinement draws nothing; test_drawing_again_with_no_description_refuses_instead_of_doing_nothing (test_video_storyboard_e2e.py:1998-2020), GUARD, which drives the primary through the changed sentence and is the one existing pin of it; the render-keyword assertions on the flow harness (test_character_sheets_flow.py:2129, :2197, :2208-2210, :2355), which read by key and must not be rewritten to read by equality; the four assertions that a refinement drew nothing (:2112, :2140, :2157, :3017); and the direct render call in test_video_storyboard_m2.py:908, which passes no cast and must keep working. No existing test is deleted and no assertion is weakened; the _Refine harness gains the two rules of deliverable 6 and nothing else. No comment the card adds names a card, an item or a phase. bash infra/secret-scan/scan-tree.sh clean, which is the one command AGENTS.md mandates at AGENTS.md:39, and python3 infra/scripts/test-task-refs.py then python3 infra/scripts/task-refs.py check . clean. Record docs/ops/<id>-acceptance.md; open items #153 and #154 closed.
Out of scope. S43's three sites (:2168-2169, :2227-2228, :2396-2397), because each already asks the one answer and there is nothing left to change there; their pins ride along as guards and that is all this card asks of them. The two refusals raised before the roster is read, :2208 and :2210, because neither has a record or a cast in hand and neither is about a character. The in-flight raise at :1683, for the same reason: _refuse_if_sheet_in_flight is handed a session and a slug and holds neither the record nor the roster, and its sentence is the shared constant the render window raises too. The library pick's own second read, where _pick_saved is handed the roster at :2652 and then calls svc.read_character(session_id, slug) without it at :2665, while its two neighbours pass it (:2504, :2581): it is the same shape on the starting-point path, which this card does not touch, and it is filed as open item #171. The roster's own error handling, which is S41's narrowed read and is a question about what a failed read does rather than about how many are taken. The render's own behaviour and its prompt: the claim, the bytes, the settle, the sheet prompt and the identity lock are untouched, and the only change inside _render_character_sheet is which object its two reads are handed. Every other roster read in this file (:141, :169, :236, :774, :1128, :1276, :2465, :2918, :3355), which belongs to the build, the cast preparation, the starting points or the batch paths and is nobody's second read on these two. The panel's own labels, which go through display_name where every slug is on the screen and are right as they are.
Waits on / Unblocks. Waits on nothing. Everything this card reads is on main at 0a09b90: refine_character and redraw_character as S43 left them, _render_character_sheet with its cast parameter, sentence_name, the two service reads and both harnesses. No open pull request touches this card's files: the open ones are #118, #136 and #137, all on the meta ads port, and none of the three carries a storyboard file. S48 is carded and not yet built and edits the same runner at prepare_cast (:945-1001), _run_cast_stage (:1167-1184), generate_candidates_with_record (:3162-3249) and generate_all_candidates (:3443-3512), the four measured whole here; this card edits :1730-1970, :2193-2341 and :2363-2419, so no range meets and neither card moves a line the other quotes. The two cards do share two files, apps/api/app/backend/services/storyboard_image_runner.py and apps/api/app/backend/tests/test_character_sheets_flow.py, where S48 adds four cast-chain tests and this card edits the _Refine harness at :2001-2101, so whichever of S48 and this card merges second is re-tested on the merge commit with the targeted api run above. Unblocks every later reader on the refine and redraw paths that wants the roster the caller already holds, which from here is threaded the whole way down on both, and every later refusal on these paths that has a member in hand, which from here has one rule and one exception written down rather than a mixture.
S50 · A scene that failed part-way says what it kept · S · S · S2-W2 · fix/s/s50-a-part-failed-scene-says-what-it-kept (surfaced while planning S46's build, pre-existing on main, open item #161; carded 2026-09-22)
Context. Measured at main de99c60, which is S48 merged (PR #154) on top of S47 (0a09b90). A scene that fails after one of its candidate rows is already ready is filed as a scene that produced no frames, and the banner says so while the scene's own pane shows the kept frame. The failure entry is written twice, by two literals, and the two agree key for key and in order. The whole-board loop prints the scene (apps/api/app/backend/services/storyboard_image_runner.py:3607), counts it (:3608), appends {"scene_id", "position", "error": str(exc)} (:3609-3610) and continues (:3611). The single-scene wrapper releases its rows (:3282-3287), sets failed = 1 (:3288), appends the same three keys in the same order (:3289-3290), stamps the second instant (:3291), writes the note in a clause of its own (:3292-3295) and re-raises the object that arrived (:3296). Neither carries anything about rows that are already ready. The success entry does: both presses write {"scene_id", "position", "ready": result.get("ready", 0)} (:3616-3617 and :3322-3323), and that count is the rows this press made ready, built at :3216 and returned at :3220. So one key already exists, already means a count of this press's kept rows, and is already absent from exactly the entry that needs it. Who reads the entry, measured: recent_batches does not, and says so (apps/api/app/backend/services/storyboard_service.py:5074-5077); its reduction keeps five fields (:5099-5103) and copies stopped when the stored entry has it (:5104-5105), and no scene row survives it. The poll route answers last_batch whole and batches reduced (apps/api/app/backend/routes/video_storyboard.py:765-766), so the client does receive the scene rows on last_batch alone. The client type already allows both keys on one entry: scenes: { scene_id: number; position: number; ready?: number; error?: string }[] (apps/web/src/lib/videoStoryboardApi.ts:379). The banner reads no scene row on either path: with a history it draws batches (apps/web/src/pages/video-storyboard/VideoStoryboardScreen.tsx:689-690), and without one it builds a single notice out of lastBatch carrying failed, dropped, warnings and stopped and nothing else (:691-699). Its sentence is {b.failed} scene{b.failed === 1 ? '' : 's'} produced no frames. Open them and try again. (:962-963), printed under the condition b.failed > 0 (:960). The one place the kept frame surfaces is the scene's own pane, which lists the attempts that did not produce a frame with each row's status and provider_message (:1348-1365), and the strip's tooltip (apps/web/src/components/video-storyboard/FrameSlots.tsx:440). So the count an operator needs is not in the record and could not reach the banner even if it were. The exact path to a part-way failure, and why it exists: generate_candidates reads the scene at :3186, outside its try; the try runs from :3191 to :3205 and its clause releases every claimed row with one generic sentence and re-raises (:3206-3210); nothing inside that try has stored a picture yet, so a failure there leaves no row ready. The finalise loop is at :3212-3215, outside the try, and that is what makes a part-way failure possible: _finalise (:2789-2812) writes the cost (:2797), writes a non-ok status (:2799), stores the bytes under a clause of its own (:2802-2810) and writes ready last (:2811), so the only calls under it that can raise past it are four database writes — the cost (:2797), the non-ok status (:2799), the failed write the storage clause itself makes when a store did not land, and the final ready write (:2811) — and a raise on the second row leaves the first row ready with its key. There is a second part-way path the item does not name: the return re-reads the scene at :3218, also outside the try, after every row has been finalised, so a raise there leaves every kept row kept. Both are database failures, which is what the item says and what the code shows. How rare, plainly: rare, and the realistic cause is the driver. The store itself is already absorbed (:2804-2809), but the failed write that clause makes when a store did not land is a statement of its own and can raise past the clause (:2823 on the tree at review), so what is left is one of four statement failures against the session's own database. The note write can meet the same failure, and then it is printed and dropped (:3294-3295 and :3602-3603) and the operator sees nothing new at all: the board reads as it read before, and the kept frame shows in the scene's pane with no sentence beside it. This card cannot improve that case and does not claim to. It pays where the failure is one statement and not the connection: get_cursor takes a pooled connection per call, commits on the way out and rolls back on any other way out (apps/api/app/backend/db.py:72-97), so a deadlock, a statement timeout, a serialisation failure or one bad value leaves the next call healthy, the note lands, and today it lands saying the scene produced no frames. What S48 changed underneath this, measured from git show de99c60 --stat and the diff against 0a09b90: it added one refusal set, PRESS_REFUSALS = model_gateway.GATEWAY_ERRORS (:34), one guarded release helper _release_claim (:3150-3167), and one typed clause above the ordinary one in each press (:3267-3280 and :3581-3605), each of which writes summary["stopped"] (:3274, :3598), files no scene entry and re-raises. It added errors to the cast chain's return (:1004, filled at :1018, :1026, :1033 through _swallowed at :955-960), served cast_run on the poll (routes/video_storyboard.py:768), and gave the reduction its conditional copy of stopped (storyboard_service.py:5104-5105), the two client types their optional field (videoStoryboardApi.ts:387 and :407) and the banner its own sentence and its report container (VideoStoryboardScreen.tsx:945, :955-959, :984-1000). It changed no key of the failure entry and no key of the success entry. It did move this card's anchors, and every line above is the post-merge one: _finalise went from :2744 to :2789, generate_candidates from :3105 to :3170, generate_candidates_with_record from :3162 to :3227, generate_all_candidates from :3443 to :3526, and the ordinary per-scene clause from :3491-3496 to :3606-3611. The item's own numbers were taken before S46 and are now wrong in three places, and the measured ones stand: the loop's failure entry is at :3606-3611 and not near :3277-3285; the finalise loop outside the try is at :3212-3215 and not near :3023-3026; the banner's sentence is at :960-965 and not near :927-931. The decision: the failure entry gains the key the success entry already has, ready, measured from the rows rather than guessed, and omitted when it cannot be measured; and because no reader reduces a scene row, the press gains one integer beside failed so the banner can tell the two kinds of failed scene apart in the one sentence it draws.
Read first. apps/api/app/backend/services/storyboard_image_runner.py:34 (PRESS_REFUSALS, the one name S48 put over the refusal set), :2735-2750 (_record, the refine path's own finaliser and the second call site of _finalise), :2789-2812 (_finalise, the storage clause at :2802-2810 with the failed write inside it, the four writes that can raise past it at :2797, :2799, that write and :2811), :3088-3128 (claim_frames, and how many rows a press claims), :3130-3147 (_fail_claims and fail_claims, the unguarded release and its best-effort loop), :3150-3167 (_release_claim, the guarded release S48 added and the shape a new best-effort helper copies), :3170-3224 (generate_candidates, the read outside the try at :3186, the try at :3191-3205, the clause and release at :3206-3210, the finalise loop outside the try at :3212-3215, the ready list at :3216 and the second read at :3218), :3227-3329 (generate_candidates_with_record, the refusal clause at :3267-3280, the ordinary clause and its entry at :3281-3296, the interrupt clause at :3297-3313 and the warned-press entry at :3315-3328), :3334-3345 (_scenes_needing_frames, which is why a whole-board press never claims a scene that already has frames), :3513-3523 (_batch_instant) and :3526-3624 (generate_all_candidates, the refusal clause at :3581-3605, the ordinary clause and its entry at :3606-3611, the success accumulation at :3612-3617 and the guarded note write at :3619-3623). apps/api/app/backend/services/storyboard_service.py:1555-1588 (_frame_counts and get_scenes, the per-scene counts and why they are not this press's), :2762-2777 (pending_version_count, the naming and statement shape a new count copies), :2790-2799 (set_version_status, the unguarded flip), :2802-2825 (list_versions and get_version, the per-row reads that exist today), :3356-3383 (release_scene_claim and its conditional UPDATE, which is why a kept row survives a release), :5021-5064 (BATCH_HISTORY_LIMIT and record_batch, the one statement that files a press) and :5067-5107 (recent_batches, the five kept fields at :5099-5103 and the conditional copy at :5104-5105). apps/api/app/backend/db.py:72-97 (get_conn and get_cursor, one pooled connection per call, rolled back on every way out). apps/api/app/backend/routes/video_storyboard.py:742-768 (the scenes poll and the four records it serves), :2820-2870 (the single-scene press and what it submits) and :2873-2932 (the whole-board press and its synchronous claim). apps/web/src/lib/videoStoryboardApi.ts:365-369 (StoryboardDroppedScene), :371-388 (StoryboardBatchSummary and the scene row's two optional keys at :379), :390-408 (StoryboardBatchNotice and why stopped is optional) and :1994-2006 (the poll answer's type). apps/web/src/pages/video-storyboard/VideoStoryboardScreen.tsx:689-699 (the held presses and the single notice built from the last summary), :704-707 (the filter that decides what is drawn), :945-1001 (the report container, the stopped sentence at :955-959, the failure sentence at :960-965, the drops at :969-973 and the cast block at :984-1000) and :1348-1365 (the attempts pane, the one place a kept frame's siblings are already explained). apps/web/src/components/video-storyboard/FrameSlots.tsx:436-440 (the strip's tooltip). apps/api/app/backend/tests/test_video_storyboard_e2e.py:156-408 (_Cur, the fake cursor, its prefix dispatch, the pending-claim count handler at :319-326, the status write at :331-337 and the guarded release at :338-351), :978-1001 and :1004-1022 (the two isolation pins over the whole-board loop), :2902-2932 (_Started and the started fixture, how a single-scene press is run), :3257-3275 (_DB_DOWN, _one_scene, _press_one_scene), :3290-3441 (S46's press tests, with the part-completed press at :3410-3441 and its equality assertion at :3439-3441), :3446-3489 (S46's guards), :3517-3523 (_three_scenes) and :3526-3629 (S48's four refusal tests). apps/api/app/backend/tests/test_storyboard_batch_record.py:29-38 (_press, a press with no database), :94-105 (the reduction's field set), :126-146 (the five-field guard) and :149-169 (S48's absence guard). apps/api/app/backend/tests/test_storyboard_image_runner_gateway_refusals.py:36-43 (the refusal table both parametrised press tests run over). apps/api/app/backend/tests/core/test_storyboard_batches_db.py (the real-database ordering and bound, which this card leaves alone). apps/web/src/pages/video-storyboard/VideoStoryboardScreen.test.tsx:280-396 (the banner cases, with the exact sentence pinned at :314-315) and :398-447 (S48's stopped press and failed cast chain). docs/ops/s46-acceptance.md (the parity limit this card closes) and docs/ops/s48-acceptance.md (the refusal set, the stopped field and the reduction's absence rule). Open item #161.
Deliverables. (1) One shared helper in storyboard_image_runner.py, beside _release_claim (:3150-3167), answers how many of a press's claimed rows are ready, and answers nothing at all when it cannot find out. It takes the claimed ids, calls one new service read, and returns the count; it wraps that call in a clause of its own and returns None after printing, the way _release_claim and the note writes already swallow their own second failure, because the exception on its way out is the one an operator has to see and a second failure from the same dead database would replace a sentence that names its cause with one that names nothing. It is a shared helper and not two copies for the reason S48 made PRESS_REFUSALS one name: its call sites are exactly the two places the failure entry is built, :3288 and :3608, so the two presses cannot drift apart about what a kept frame is. (2) The count is read from the rows and never counted in memory. _finalise returns the version id for a stored row, for a refused or blocked row and for a row whose store failed alike (:2801, :2810, :2812), so a caller counting what came back would count a storage failure as a kept frame; the row state _finalise wrote at :2811 is the only exact answer. One new service function beside pending_version_count (storyboard_service.py:2762-2777) does it in one statement over the claimed ids, SELECT COUNT(*) FROM storyboard_scene_versions WHERE id = ANY(%s) AND status = 'ready', and raises like every other read in that module. It counts the press's own rows and not the scene's, because a scene pressed with Generate more frames already holds frames from an earlier press and _frame_counts (storyboard_service.py:1555-1588) would credit this press with them. (3) The failure entry gains ready, the key the success entry already carries with the same meaning and the same type (:3617, :3323). It is a count and not the row ids: the ids would be a second list nothing reduces, since recent_batches carries no scene row at all (storyboard_service.py:5074-5077), and the rows are already the authority for which frame was kept, printed by the scene's pane (VideoStoryboardScreen.tsx:1348-1365) and the strip's tooltip (FrameSlots.tsx:440). When the helper answered nothing the key is left out entirely. It is never written as zero in that case, because zero is a measurement and would restate the very sentence this card exists to delete with the authority of one; an absent key means the count is not known, which is exactly the state every record written before this card is in, so one path on the screen draws both. A measured zero is written, because it is the honest answer for a scene that kept nothing and it is what keeps today's sentence correct. (4) The press summary gains one integer, part_failed, counted where failed is counted (:3288 and :3608) and incremented only when the helper answered above zero. It exists because the entry alone cannot reach the banner: the reduction drops every scene row on purpose, and building the same number a second time in the screen's own code is the drift S48 removed by naming the refusal set once. failed keeps its meaning exactly, the count of scenes that failed, because every reader and both presses already agree on it and S48's stopped decision is built on it; part_failed is a subset of it and is never added to it. summary["ready"] is not touched: it accumulates from a returned result (:3612-3613, :3320-3321) and a failure path has no result to read, so folding a measured count into a total built from returns would make one number mean two things. (5) The reduction copies part_failed when the stored entry has it, one line beside the copy S48 added (storyboard_service.py:5104-5105), so a clean press, an ordinary failing press, a stopped press and every press recorded before this card reduce to exactly the fields they reduce to today. The client type gains the same field twice and optionally for the same reason: part_failed?: number on StoryboardBatchSummary (beside :387) and on StoryboardBatchNotice (beside :407). The scene row's type at videoStoryboardApi.ts:379 is not changed, because it already declares ready?: number beside error?: string on one object. (6) The banner tells the two kinds of failed scene apart, in the block that already prints one sentence (VideoStoryboardScreen.tsx:960-965). A scene that kept nothing keeps today's sentence word for word, {n} scene{s} produced no frames. Open them and try again., counted over failed less part_failed. A scene that kept something gets its own, {n} scene{s} stopped part-way and kept one or more frames. Open them to review or generate more., counted over part_failed, because a whole-board press skips a scene that already holds frames (:3334-3345), so the other sentence's closing instruction names an action that scene no longer has. part_failed is what decides between them and nothing else; when it is absent or zero the first sentence prints over failed alone, which is the board every older record draws. The two sentences read under stopped and never instead of it: stopped prints first and alone (:955-959) because a refusal is about the press, and a press can carry both, having been refused at one scene after an earlier scene had already failed part-way. The single notice built from the last summary carries the new field across beside stopped (:692-698), so a session with no history draws the same sentences. The filter at :704-707 is not changed, because a press with a part-failure already has failed > 0. (7) The fake cursor gains one handler for the new statement, and it is placed above the existing one at test_video_storyboard_e2e.py:319-326. That handler matches the prefix SELECT COUNT(*) FROM storyboard_scene_versions and would otherwise swallow the new read and answer it with the pending-claim guard's count, which is a wrong answer rather than a missing one. The new handler matches the longer prefix and counts the rows whose id is in the list and whose status is ready. (8) docs/ops/s50-acceptance.md: the failure entry before and after, key by key, for both presses, with the line of each write; the paths by which a press reaches that entry with a row already ready, and which call raises on each, naming all four writes that can raise past _finalise, the storage clause's own failed write among them; the printed note and poll answer after a single-scene press that kept one of three, after a whole-board press whose second scene kept one of three, after a press that kept nothing, and after a press whose count read failed with the entry that leaves; the row states in each of those cases; the banner's two sentences beside a stopped press's; what an operator reads for a record written before this card; and a plain statement of the case this card cannot improve, where the connection is gone and the note write fails with the count read.
Interfaces. Exposes one new service read, which counts a list of candidate rows in the ready state, and one new runner helper, which is the only caller of it and has exactly two call sites of its own. generate_candidates is not changed at all: its signature, its return, its raise, its try boundary and its finalise loop stay byte for byte what they are, so the rows of every press keep the states and sentences they have today. _finalise, claim_frames, _fail_claims, fail_claims, _release_claim, set_version_status and release_scene_claim are not changed either. generate_all_candidates and generate_candidates_with_record keep their names, their signatures, their returns and their raises, and each gains two lines inside the ordinary failure clause it already has. Neither refusal clause is touched, so a stopped press still files no scene entry and still gains no field. The press summary gains one optional key, part_failed, written only by those two clauses, so a clean press, a warned press and a stopped press carry no such key. recent_batches gains one optional field and drops none, copied only when the stored entry has it, so every reader written before this card meets the shape it already meets. The two client types gain one optional field each, and the scene row's type is unchanged. record_batch is called exactly as it is called today with a summary that is still one JSON object, so the write statement, its ordering and its bound are untouched and no migration exists. No route signature changes and no route gains a service call. Three behaviours change on purpose. A failure entry now says how many frames that scene kept, when that can be established. A press now says how many of its failed scenes kept something. And the banner stops telling an operator that a scene produced no frames when the scene's own pane is showing one, in the one case where the count was readable; where it was not, the board says what it says today, which is the honest limit of a card that cannot outlive the database it reads.
Acceptance. The api tier green as AGENTS.md sets it out, from apps/api, with DESELECT=$(grep -vE '^\s*(#|$)' ci-known-failures.txt | sed 's/^/--deselect /' | tr '\n' ' ') and .venv/bin/python -m pytest app/backend/tests -q -p no:cacheprovider $DESELECT, the targeted files run locally with the venv's own interpreter and pytest's own exit code read from the command. The targeted api run is .venv/bin/python -m pytest app/backend/tests/test_video_storyboard_e2e.py app/backend/tests/test_storyboard_batch_record.py app/backend/tests/test_storyboard_image_runner_gateway_refusals.py -q -p no:cacheprovider. No heavy leg: every test is in memory through the fake cursor (test_video_storyboard_e2e.py:156-408) and the press gate (:2902-2932), none starts a container and none needs docker. The web tier as AGENTS.md sets it out, because two web files change: cd apps/web && npm ci && npm run lint && npm run typecheck && npm run build, and the targeted web run is npx vitest run src/pages/video-storyboard/VideoStoryboardScreen.test.tsx. .venv/bin/python -m py_compile clean over every changed Python file. Six tests RED ON THE BASE. In test_video_storyboard_e2e.py: a single-scene press whose second finalise raises files an entry carrying the kept count beside its error and a press carrying part_failed: 1, where the base files the three keys alone, and this is the existing test_a_press_that_fails_after_a_stored_frame_keeps_it_and_is_still_filed (:3410-3441) with its equality assertion at :3439-3441 updated and every other assertion kept; a whole-board press of three scenes whose second scene's second finalise raises keeps the first scene's success entry, files the second scene's failure entry with the kept count and files failed: 1 with part_failed: 1, where the base writes neither; and a press whose finalise raises and whose count read raises too: the new service read is replaced with one that raises, the replaced read is asserted to have been called exactly once, the entry is filed with no kept key at all and the press with no part_failed, and the object that leaves the press is the finalise's own exception and not the read's, where the base is red because the new read does not exist to be called at all — the absence of the integration itself, which is the case that proves the absence is not a zero; and a press whose every row finalised ready and whose closing scene re-read, the one outside the try after the finalise loop (:3218), raises files the entry with the full kept count — three kept of three, a state only this path produces — and the press with failed: 1 and part_failed: 1, where the base files the same entry with no kept key at all. In test_storyboard_batch_record.py: the reduction carries part_failed for a part-failed press and leaves the key out entirely for an ordinary failing press, for a clean press and for a record written before this card, where the base does not know the key. In apps/web/src/pages/video-storyboard/VideoStoryboardScreen.test.tsx: a poll answer carrying failed: 2 with part_failed: 1 draws one sentence for the scene that produced nothing and one for the scene that kept its frames, and an answer carrying failed: 1 with no such field still draws today's sentence alone, where the base draws the one sentence twice over. GUARD tests, green on both sides and labelled as guards. The kept row stays ready with its key after the press has filed its entry (:3433-3434), which is the property the count describes and can only go red on a branch whose count read rewrites a row. The object that leaves a press whose count read also failed is the object that arrived, which can only go red on a branch that lets the count read escape past the real cause. A clean press and an interrupted press still file nothing (:3476 and :3462), so no count read runs on a path with no entry. And recent_batches still answers no presses at all for a record it cannot read (test_storyboard_batch_record.py:172). Existing pins that must stay green unchanged, including the ones S48 added: all four of S48's refusal tests (test_video_storyboard_e2e.py:3527-3555, :3559-3587, :3590-3608, :3611-3629), two of them parametrised over the whole refusal table, because a stopped press files no scene entry and therefore never reaches the new read; S48's absence guard (test_storyboard_batch_record.py:149-169) and the five-field guard beside it (:126-146), both of which assert a field set by equality and both of which stay true because the new key is copied only when it is stored; S48's web cases (VideoStoryboardScreen.test.tsx:398-447); the two isolation pins over the whole-board loop, test_one_failing_scene_does_not_stop_the_others (:978-1001) and test_a_database_failure_at_the_variation_fails_that_scene_in_the_batch_note (:1004-1022), the second of which fails inside the try where no row can be ready, so its scene reads a measured zero and its assertion on the error sentence is untouched; every other S46 press test and guard (:3290-3408, :3446-3489); the two poll tests (test_storyboard_batch_record.py:183 and :205); the parametrised gateway pins in test_storyboard_image_runner_gateway_refusals.py; and core/test_storyboard_batches_db.py whole, because the summary is still one JSON object written by one unchanged statement. One existing test is expected to change and none is deleted, every assertion kept: test_a_press_that_fails_after_a_stored_frame_keeps_it_and_is_still_filed (:3410-3441), whose entry equality gains the kept count and whose press gains one assertion. No comment the card adds names a card, an item or a phase. bash infra/secret-scan/scan-tree.sh clean, which is the one command AGENTS.md mandates at AGENTS.md:39 and which runs both halves over one private copy of the set a commit can carry, and python3 infra/scripts/test-task-refs.py then python3 infra/scripts/task-refs.py check . clean. Record docs/ops/s50-acceptance.md; open item #161 closed.
Out of scope. Moving the finalise loop inside generate_candidates' own try (:3191-3215) so a late failure released its rows the way an early one does, which is the change that would make this failure look like every other failure and loses on measurement: the clause at :3206-3210 calls _fail_claims, which flips any row by id with no guard at all (storyboard_service.py:2790-2799), so running it after a picture is stored would rewrite a ready row as failed and lose a frame that was paid for, which is the exact hazard the guarded release exists to prevent (storyboard_service.py:3356-3383). Making the failure rarer rather than better described, which is what a retry of the finalise that raised, or an idempotent finalise, or a second connection for the status write would each be: they are decisions about the driver, each costs a second write against a database that has just refused one, and none of them changes what the banner says when the failure happens anyway. The reaper and every queue claim, which decide what becomes of work that was abandoned rather than failed, and which no line of this card reaches; the rows of a part-failed scene are already settled, by _finalise for the ones it reached and by the release for the ones it did not. Counting the scene's ready frames instead of the press's, which loses because a scene pressed with Generate more frames already holds frames from an earlier press and the entry would then claim this press kept what it never made; _scenes_needing_frames (:3334-3345) keeps the whole-board press clear of that, and the single-scene press is exactly where it does not hold. Writing a zero when the count could not be read, which loses because it states a measurement that was never taken and states the one sentence this card exists to correct. Carrying the per-scene rows through recent_batches, which S44 kept out on purpose so the poll answer cannot grow with the board and with every press of it (storyboard_service.py:5074-5077), and which would make a press's answer unbounded to say one number. Changing what failed counts so a part-failed scene leaves it, which loses because failed is the field every reader and both presses already agree on and the field S48's stopped decision is built on; the new integer is a subset of it and is never added to it. Adding the kept count into summary["ready"], which accumulates only from a returned result (:3612-3613, :3320-3321) and would make one number mean two things. Carrying refused and blocked beside the kept count on a failure entry, which loses because those are per-candidate outcomes with no result object to read on a failure path, and the scene's own pane already prints each of them with the provider's own sentence (VideoStoryboardScreen.tsx:1348-1365). _record (:2735-2750), the refine path's own finaliser, which calls _finalise for a single row outside any press and files no note. The two refusal clauses S48 wrote (:3267-3280, :3581-3605), which file no scene entry by design and are not touched. Open item #173, the credential refusal S48 filed, and the thirty-four broad clauses S48 listed by line for a later carding pass. Changing what a released row's sentence says, or what the scene's own pane draws for a kept frame.
Waits on / Unblocks. Waits on nothing. S48 is merged (PR #154) and this card starts from that main, de99c60, so everything it reads is already there in the shape it reads: the refusal set and both refusal clauses, the guarded release helper, the reduction's conditional copy, the two client types' optional field, the report container and its stopped sentence, and the four refusal tests. There is no second merge to re-test against S48. The three pull requests open today are #118, #136 and #137, all of them lane A over services/scraper, db/migrations/unified/meta_ads, infra, docs and apps/api/app/backend/tests/core, and the file lists of all three were read: none of them touches any of this card's five files, so there is no shared file and nothing to re-test on a merge commit. One consumed interface is shared and is named for that reason: #118 edits infra/scripts/task-refs.py and infra/scripts/test-task-refs.py, which this card runs in its acceptance and does not change, so whichever of the two merges second re-tests that one command on the merge commit. V56 is merged (810b27f) and its sweep already judged the two web files this card edits: neither declares or renders markdown, and the recorded drift analysis for this card says both pass it. S49 is merged (442bf73); it edited the same runner file in the refinement and redraw regions, far above :3150, a shared file and no range, and it also changed this card's test file test_video_storyboard_e2e.py near :910, far above the press tests this card adds, so both shared files are recorded and neither range overlaps. A52 is merged (f79b410) and touches none of this card's files. Every anchor above was measured before those three merges and the lines have moved; the content each anchor names is unchanged, the recorded drift analysis proves no merge stops this card, and the builder's first deliverable is an anchor table found by content on the main it starts from. Unblocks every later reading of a press that ended part-way: the note now says what a scene kept, the banner tells a scene with a frame apart from a scene with none, and the next card that wants a per-scene sentence in the banner has a press-level number to reduce and a precedent for carrying one conditionally.
S51 · A press refused an image slot is filed where the board reads it · S · S · S2-W2 · fix/s/s51-a-refused-slot-is-filed (surfaced while building S46, pre-existing on main, open item #165; carded 2026-09-22)
Context. Measured at main de99c60, which is S48 merged (PR #154) on top of S47 (0a09b90). Two buttons generate frames and both hand their run to the image job gate with a hook for the case where the run never starts. The single-scene route is generate_stills (apps/api/app/backend/routes/video_storyboard.py:2820-2870): it checks the gateway (:2852), reads the scene's position (:2857-2858), claims the three candidate rows (:2859-2860), submits (:2862-2869) and answers {"started": true, …} (:2870). Its hook is on_error=lambda exc: storyboard_image_runner.fail_claims(claimed, "Generation could not start: no image slot could be taken.") (:2867-2868). The whole-board route is generate_all_stills (:2873-2932): it checks the gateway (:2896), runs the preflight (:2897), claims every scene (:2906-2907), splits that answer into claims and dropped (:2908), refuses a press that reserved nothing (:2909-2919), submits (:2923-2930) and answers (:2931-2932). Its hook is the same call over every claimed row of every scene, [vid for claim in claims for vid in claim["version_ids"]], with the same sentence (:2927-2929). The item names those two hooks at :2858-2859 and :2918-2919, which were their lines before S48 merged; both have moved nine lines down, and the card carries the lines it measured. What each hook does: fail_claims (storyboard_image_runner.py:3144-3147) calls _fail_claims (:3130-3141), which writes svc.set_version_status(version_id, "failed", provider_message=message) for each row, one at a time, best effort, printing a warning for any row it could not write (:3137-3141). What neither hook does: it files nothing in the session audit, it names no scene by its position, it carries none of the drops the whole-board route computed, and it uses the unguarded status write (storyboard_service.py:2790-2799) rather than the guarded release S46 put beside it (:3356-3383). So the operator watches three rows stop reading as generating and is told nothing else. The gate is app/core/jobs/limits.py. submit never blocks and returns a Future (:279-300, module entry :487-490) that both routes discard. A pool worker takes the job (:402-407) and _run_one (:409-429) asks the ledger for a slot first (:411); the hook is called from _fail (:393-400, the call at :397), and _fail is reached from exactly one place, the clause around that acquire (:412-416). The image kind's ledger is the box-wide one (LOCK_NAMESPACES :103-107, built at :447-453), and PostgresLedger.acquire (:209-221) opens its own connection (:210, :182-187), tries the slots (:212) and, when the box is full, waits in a loop that sleeps and tries again (:215-217). A full box therefore never reaches the hook. The only way out of that method other than a slot is the clause at :218-220, which closes the connection and re-raises, so the one cause that reaches the hook is a slot ledger that could not be reached or could not answer, which is the module's own sentence for it (:52-54). The state of the press when it fires: the route has already answered {"started": true}, the rows are already pending because every one of them was created that way (claim_frames :3088-3127, the write at :3123-3126; claim_all calls it per scene at :3499-3500), the drops are already computed (:3501-3506) and already gone, because the answer that carried them is the answer the client has finished with, and the job body has not run, so no scene was attempted and nothing was spent. The whole-board failure shape this card must write from the hook is generate_all_candidates (:3526-3624): the summary is built at :3573-3575 with scenes, ready, refused, blocked, failed, warnings, dropped, started_at and finished_at; the refusal clause S48 added (:3581-3605) releases the scene it stopped on through _release_claim on its default (:3592), releases every scene it never reached with include_failed=False (:3595-3597), sets stopped to the refusal's sentence (:3598), sets finished_at (:3599), writes the note in a clause that prints and drops its own failure (:3600-3603) and re-raises (:3605); the ordinary clause (:3606-3611) counts the scene and appends {scene_id, position, error}; otherwise the note is written once at the end (:3619-3623). _release_claim (:3150-3167) is the guarded release, best effort per row, over svc.release_scene_claim (storyboard_service.py:3356-3383), whose one conditional statement moves a row only while it is pending, or also while it is failed when include_failed is true (:3377-3382), and answers rowcount > 0 (:3383). The single-scene press writes the same shape through generate_candidates_with_record (:3227-3329), whose refusal clause (:3267-3280) files stopped with failed left at zero and no scene row. The readers, all four: recent_batches (storyboard_service.py:5067-5107) reduces a press to five fields (:5099-5103) and copies stopped only when the stored entry has it (:5104-5105); the poll answer carries last_batch whole and batches reduced (routes/video_storyboard.py:761-768, the two at :764-765); the client types are StoryboardBatchSummary (apps/web/src/lib/videoStoryboardApi.ts:373-388, stopped? at :383-387) and StoryboardBatchNotice (:397-408, stopped? at :404-407); the banner reads them at VideoStoryboardScreen.tsx:689-699 with the last_batch fallback carrying stopped (:698), filters to the presses with something to report (:704-707) and draws the stopped sentence first and on its own (:955-959) above the failure count (:960-965) and the drops (:969-973). The design question the item raises is whether the hook can reach the writer at all, and the lines say it can. The route already imports storyboard_image_runner in its service block (routes/video_storyboard.py:26-33) and already calls a runner function that exists only for this hook: fail_claims, whose docstring says so (:3145-3146) and whose only two callers in the tree are the two hooks. Everything the shape needs is private to the runner and already written: _batch_instant (:3513-3523), _release_claim (:3150-3167), svc.record_batch (storyboard_service.py:5024-5064) and the key both presses mint (:3260, :3572). So nothing has to be lifted out of the runner, and the card adds one function beside fail_claims and calls it from both hooks. One more line decides the order of work inside it: the report block draws only while nothing is in flight (VideoStoryboardScreen.tsx:945, over generating at :677, which is true while any scene holds a pending row), so releasing the rows is not tidiness beside the note, it is what lets the note be drawn at all.
Read first. apps/api/app/backend/routes/video_storyboard.py:2820-2870 (generate_stills whole, the hook at :2867-2868) and :2873-2932 (generate_all_stills whole, the claim split at :2908, the hook at :2927-2929). apps/api/app/core/jobs/limits.py:49-54 (what the module says a hook is for), :209-221 (PostgresLedger.acquire, the wait loop at :215-217 and the one clause that leaves by an exception at :218-220), :279-300 (submit and the discarded Future), :393-400 (_fail, the hook call at :397 and the log line at :394), :409-429 (_run_one, the acquire at :411 and the clause that reaches _fail at :412-416), :443-465 (the registry, _default_ledger and gate), :468-484 (install and reset). apps/api/app/backend/services/storyboard_image_runner.py:34 (PRESS_REFUSALS and the comment above it), :3088-3127 (claim_frames, the pending write at :3123-3126), :3130-3147 (_fail_claims and the public fail_claims), :3150-3167 (_release_claim), :3206-3210 (the other caller of _fail_claims, which stays), :3227-3329 (generate_candidates_with_record whole, the refusal clause at :3267-3280), :3467-3510 (claim_all, the drop shape at :3501-3506), :3513-3523 (_batch_instant), :3526-3624 (generate_all_candidates whole). apps/api/app/backend/services/storyboard_service.py:2790-2799 (set_version_status, the unguarded write), :3356-3383 (release_scene_claim, the guarded one), :5024-5064 (record_batch), :5067-5107 (recent_batches, the stopped paragraph at :5079-5083). apps/api/app/backend/routes/video_storyboard.py:761-768 (the poll answer). apps/web/src/lib/videoStoryboardApi.ts:373-388 and :397-408 (both client types). apps/web/src/pages/video-storyboard/VideoStoryboardScreen.tsx:677 (generating), :689-699 (the fallback), :704-707 (the filter), :945-1002 (the report container, the stopped sentence at :955-959). apps/api/app/backend/tests/test_job_limits.py:19-23 (the autouse gate reset), :238-246 (_BrokenLedger), :249-261 (the one test that drives a real slot refusal today). apps/api/app/backend/tests/test_video_storyboard_e2e.py:2941 onward (_Started and the started fixture, whose submit takes on_error and drops it on the floor), :3260-3275 (the one-scene helpers), :3277-3489 (S46's tests), :3491-3629 (S48's tests). apps/api/app/backend/tests/test_storyboard_batch_record.py:28-36 (_press), :126-146 (the five-field guard), :149-169 (S48's absence guard).
Deliverables. (1) One new function in storyboard_image_runner.py, beside fail_claims (:3144-3147), is what both hooks call, and it replaces fail_claims rather than sitting next to it. It lives in the runner and not in the route because the shape it writes is composed entirely of runner-private parts, and it is called from the route because that is where the hook fires; nothing has to be lifted out for that, since the route already imports the runner (routes/video_storyboard.py:26-33) and fail_claims is already a runner function that exists for this hook alone. The public wrapper is replaced and not kept, because its own docstring says it exists for the gate's slot failure (:3145-3146) and nothing else in the tree calls it, so leaving it would leave a second way to end a press that never started, which is the drift S48 removed by naming the refusal set once. _fail_claims stays exactly as it is, because its other caller is the clause inside generate_candidates (:3206-3210), which is a press that did start and owns rows it may already have settled. (2) The function releases the rows first and files the note second, in that order and for the reason generate_candidates_with_record already states (:3254-3258): a released row makes the scene retryable at once even when the note is lost. Here it is stronger than that, because the report block on the board draws only while nothing is in flight (VideoStoryboardScreen.tsx:945, over generating at :677), so a note filed beside rows still reading pending would be a note nothing draws. Both halves are best effort. The release is already per row inside _release_claim (:3162-3167). The note write takes a clause of its own that prints and drops, the same clause the two presses use (:3276-3279, :3600-3603), because a hook that raises is caught by the gate and logged as a hook that raised (limits.py:398-399), which would replace the operator's account with a second line in a log. (3) The release is the guarded one on include_failed=False, not the unguarded status write. S48's rule applies here in its stricter half. The rule is in the function's own docstring (storyboard_service.py:3369-3374): a run that failed part-way owns a row still pending and a row it already released with a generic sentence, and a run that was stopped owns only the first. A press that never started is that second case with nothing else in it, because every row it holds was created pending by the claim (:3123-3126) and the job body never ran, so no row of this press can hold a stored picture. The guard therefore changes nothing that happens today and states what the caller owns, and it takes the last route-side caller off the unguarded write (storyboard_service.py:2790-2799), which narrows open item #167 by one caller without changing that function. (4) The entry is the whole-board failure shape with the keys the two presses already write, and it carries stopped rather than a count of scenes. The keys are scenes, ready, refused, blocked, failed, warnings, dropped, started_at, finished_at and stopped, which is the summary built at :3573-3575 plus the field S48 added. scenes is empty and failed is zero, because no scene was attempted and telling an operator that eight scenes each produced no frames is the sentence S48 exists to delete; this is what makes the entry distinguishable from a failed press, which is failed: 1 with a scene row naming the scene and its error (:3288-3290, :3608-3610) and which means the work ran and that scene produced nothing. The two stopped causes are told apart by the sentence the banner prints, and the operator does something different on each: on Generation could not start: no image slot could be taken. the press can simply be pressed again, because the box failed to reach its slot ledger and nothing about the press was wrong, while on a gateway sentence such as Spending is paused for this workspace. pressing again buys the same refusal, because the cause is the workspace's binding, guard or credential and that has to be fixed first. dropped carries the whole-board route's own drops, passed in from the hook, because the route's answer is the only other place they ever existed and it is gone by the time the hook fires; the single-scene hook passes none and the field is an empty list, as it is on that press today (:3262). The two instants are one call to _batch_instant (:3513-3523) used for both, so the record says plainly that the press occupied no time, and started_at keeps the fixed-width form the history is ordered by (record_batch :5038-5042). (5) The sentence is one literal in one place and the hooks pass no message. Today it is written out twice (routes/video_storyboard.py:2868 and :2929), which is two copies of an operator-facing sentence in a file that does not own it. The function holds it, so the rows and the note cannot disagree about what happened, and each hook becomes one call carrying the session, the rows and, on the whole board, the drops. The exception the gate handed the hook is not put in the entry: it is the driver's own text about a connection, the gate has already logged it against the job key (limits.py:394), and the operator's sentence is the one the board prints. (6) stopped is the word, and the three places that describe it say what it now covers. The field already means a press that ended before it could do the work, the reducer already copies it and only when it is there (storyboard_service.py:5104-5105), both client types already carry it as optional (videoStoryboardApi.ts:383-387, :404-407) and the banner already draws it first and on its own (VideoStoryboardScreen.tsx:955-959), so the board needs no new branch and no reader learns a new shape; a second word would give the reducer a second conditional copy, both types a second optional field and the banner a third branch to say the same thing in the same place. What needs correcting is the prose, which says in all three places that the field appears only on a press the workspace refused outright: recent_batches (:5079-5083), the two client type comments (:383-386, :404-406) and the banner comment (:952-954) are reworded to say that the press ended before any scene was attempted and that the sentence says why, naming the two causes, a refusal the workspace gave and a slot the box could not take. None of the three names a card, an item, a phase or a wave. (7) The counts stay out of it. failed is not incremented, ready, refused and blocked stay at zero, and no warning is appended, so a press that took no slot reduces to one sentence and whatever was dropped before it. That is the choice S48 made for a refused press and for the same reason, and it is what keeps the five-field guard (test_storyboard_batch_record.py:126-146) and the absence guard (:149-169) green with no edit. (8) docs/ops/s51-acceptance.md: the two hooks with the exact line each was on and the line each is on after; the printed note and poll answer after a single-scene press whose slot could not be taken, and after a whole-board press whose slot could not be taken with two scenes dropped at the claim; the row states of a released press in both cases, including the state of a row that was not pending; the banner's own sentence quoted from the rendered text; the same three printed beside a press stopped by a paused guard, which is the parity this card holds with S48; and the measured account of what the board said before this card and says after it.
Interfaces. Exposes one new runner function, which the two routes are the only callers of: record_press_never_started(session_id: int, version_ids: list[int], *, dropped: list[dict] | None = None) -> None. It releases those rows through svc.release_scene_claim with include_failed=False and the sentence it holds, then files one batch entry through svc.record_batch under a key of its own, and it returns None and raises nothing, because the gate calls it from a clause and a hook that raises is logged as a hook that raised (limits.py:398-399). dropped is claim_all's own list shape, {scene_id, position, reason} (:3501-3506), carried in rather than recomputed, for the reason generate_all_candidates already carries it (:3561-3564). Retires fail_claims (:3144-3147), whose only callers are the two hooks it replaces and which no test calls. _fail_claims (:3130-3141) keeps its name, its signature and its caller inside generate_candidates (:3209). _release_claim, _batch_instant, record_batch, release_scene_claim, recent_batches and set_version_status keep their names, their signatures and their behaviour. No new batch field, no new state, no new route, no new poll key and no new client type. Both client types gain no field and lose none; their comments on stopped? change and the field's presence rule does not. The scenes poll answer is byte for byte the shape it is today for every press that reaches the runner at all.
Acceptance. The api tier green as AGENTS.md sets it out, from apps/api, with DESELECT=$(grep -vE '^\s*(#|$)' ci-known-failures.txt | sed 's/^/--deselect /' | tr '\n' ' ') and .venv/bin/python -m pytest app/backend/tests -q -p no:cacheprovider $DESELECT, the venv's own interpreter and never a bare pytest, with pytest's own exit code read from the command and the passed, skipped and deselected counts quoted in the record. The targeted api run is .venv/bin/python -m pytest app/backend/tests/test_video_storyboard_e2e.py app/backend/tests/test_storyboard_batch_record.py app/backend/tests/test_job_limits.py -q -p no:cacheprovider. No heavy leg: every test is in memory through the fake cursor (test_video_storyboard_e2e.py:156-408), and the gate the new tests install has a ledger that refuses without a database, so none starts a container and none needs docker. The web tier as AGENTS.md sets it out, because comments in two web files change: cd apps/web && npm ci && npm run lint && npm run typecheck && npm run build. .venv/bin/python -m py_compile clean over every changed Python file. Three tests RED ON THE BASE, each run on the base first with its own failure quoted in the record by its reason and not by a count, and all three in test_video_storyboard_e2e.py on a fixture of the card's own that installs a real JobGate over a ledger that refuses and resets the registry around itself the way test_job_limits.py:19-23 does. The route discards the Future that limits.submit returns (:2862), so the fixture puts a capturing shim over limits.submit that delegates to the installed real gate and keeps the Future it returned, and each test waits on that Future inside pytest.raises the way test_job_limits.py:253-255 waits on its own: a single-scene press whose slot cannot be taken files one held press carrying the hook's sentence under stopped, with failed zero, scenes empty, dropped empty and the two instants equal, where the base files nothing at all and the read finds no press; a whole-board press of two claimable scenes and one that could not be claimed files the same entry and keeps the dropped scene with its position and its own sentence, where the base files nothing and the drops exist nowhere after the route has answered; and the rows of a stopped single-scene press move through the guarded statement, proved without the race the naive shape carries: the fixture's refusing ledger does not raise at once but waits on an event the test holds, so after the submit the test captures the delegated Future, sets one claimed row of the press to ready while the worker is still inside acquire, then releases the event and asserts the Future raises inside pytest.raises; the release then leaves the ready row exactly as it was while the pending ones move to failed carrying the hook's sentence, where the base's unguarded write rewrites the ready row whatever it says, and the race is real on the base shape because the worker can call the hook between the submit and any later test action (limits.py:402). Seven tests more: five are GUARD tests, green on both sides and labelled as guards, and two, the release that cannot stop the note and the two presses agreeing, are RED ON THE BASE (corrected in review: the base files no entry for a refused slot, so a property of that entry cannot hold there, and each is proved red on the base with its own failure quoted like the three above). The reduction of what a refused slot filed answers the five fields plus stopped, green on both sides because the reduction already copies stopped when a stored entry has it (:5104-5105), so it guards the new entry's shape. A poll answer whose newest press carries stopped with the slot sentence, failed zero and one dropped scene draws the stopped line first and the drop under it and no failure count, green on both sides because the screen already renders stopped before drops and suppresses the failure count and an existing test proves the sentence (VideoStoryboardScreen.test.tsx:402); it is the board's reading of the entry the three red tests produce. A note that cannot be written does not take the release with it: record_batch is made to raise, the slot is refused, and the rows are still released and the hook still returns, so the gate logs no hook that raised. A release that cannot be written does not stop the note: one row's release is made to raise, and the entry is still filed and the other rows still move. The two presses agree: the entry a refused slot writes and the entry a paused guard writes have the same key set and differ only in the sentence, asserted side by side, which is the parity S48 established. An ordinary failing press is unchanged: failed is one, the scene row is the exact object S46 pinned with the measured ready S50 added to it (0 for this press), and no stopped key exists. And the reduction gains nothing for anybody else: an ordinary press, a clean press and a record written before the field existed all reduce to exactly five fields. Existing pins that must stay green unchanged, none of them edited and none deleted: S46's, which are test_the_submitted_run_carries_the_scenes_position (test_video_storyboard_e2e.py:3277), test_a_failed_press_is_written_where_the_board_reads_it (:3290), test_the_released_rows_carry_the_drivers_own_sentence (:3314), test_a_press_whose_first_read_fails_strands_no_claim (:3332), test_an_interrupted_press_strands_no_claim (:3350), test_a_warned_press_files_the_warning_the_banner_draws (:3367), test_the_guarded_release_moves_only_the_rows_the_run_still_owns (:3390), test_a_press_that_fails_after_a_stored_frame_keeps_it_and_is_still_filed (:3410), test_a_note_that_cannot_be_written_does_not_replace_the_cause (:3446), test_an_interrupted_press_files_nothing (:3462) and test_a_clean_press_files_nothing (:3476); S48's four refusal tests and the plain-sentence test beside them, test_a_refused_press_answers_the_plain_sentence_the_board_prints (:3491), test_a_refusal_on_the_first_scene_stops_the_whole_press (:3527), test_a_refusal_part_way_keeps_the_frames_the_press_already_earned (:3559), test_a_stopped_press_leaves_a_settled_row_of_a_scene_it_never_reached (:3590) and test_the_single_scene_press_answers_a_refusal_the_way_the_board_does (:3611); the two isolation pins over the whole-board loop (:978-1001 and :1004-1022); in test_storyboard_batch_record.py, test_one_scenes_press_reduces_to_the_same_five_fields (:126), S48's absence guard test_a_stopped_press_carries_its_sentence_and_nothing_else_gains_a_field (:149), test_a_record_that_cannot_be_read_is_no_presses_at_all (:172) and the two poll tests (:183 and :205); and in test_job_limits.py, test_a_job_whose_slot_cannot_be_taken_is_failed_not_hung (:249), which is the gate's own account of the path this card hangs work on. The started fixture (beginning at :2941 on the tree at review) is left as it is and its submit keeps dropping on_error, because every test built on it is about a press that ran. No comment the card adds or rewrites names a card, an item, a phase or a wave, and python3 infra/scripts/test-task-refs.py then python3 infra/scripts/task-refs.py check . clean from the repository root. bash infra/secret-scan/scan-tree.sh clean, the one command AGENTS.md mandates at AGENTS.md:39, with both halves named in the record. Record docs/ops/s51-acceptance.md; open item #165 closed.
Out of scope. Open item #166, the scene row read twice before a single-scene press claims anything: its row says to fold it into the next card that changes claim_frames's signature, and this card changes no signature of claim_frames and does not open it at all, so the fold would be work with no carrier here. The line that item cites has moved, and the open questions below say where it is now. Open item #167, the unguarded set_version_status: its row says it comes after this one, and narrowing it is a sweep over every caller of that function with a decision per caller about which of them may keep the unguarded write, which is a card and not a clause. This card removes one caller of it and changes the function in no way. Making the job gate call on_error for a body failure as well as for a slot it could not take, which S46 put out of scope and answered another way: the runner writes a body failure itself (:3281-3296), so a hook for it would be a second writer of one entry. Adding the slot refusal to PRESS_REFUSALS (:34): that set is what the runner's two clauses catch, and a slot refusal never reaches the runner, so naming it there would state a stop that cannot happen. Retrying the submit or holding the press while the ledger recovers: a full box already waits inside the ledger (limits.py:215-217), so the one cause left is a database the box cannot reach, which a retry loop inside a request that has already answered cannot mend. Any new word for the batch field or any new branch in the banner, for the reason deliverable 6 gives. Serving the per-scene rows in the poll answer, which recent_batches leaves out on purpose (:5073-5077) and which this entry has none of anyway. Changing _fail_claims (:3130-3141) or the clause that calls it (:3206-3210), which belongs to a press that did start and owns rows it may already have settled, a different ownership question that S46 answered.
Waits on / Unblocks. Waits on nothing. Everything this card reads is on main at de99c60, and no open pull request touches a file it changes: #137, #136 and #118 were listed file by file, and every file the three of them touch is under apps/api/app/backend/tests/core/, db/migrations/ or services/scraper/; not one reaches routes/video_storyboard.py, storyboard_image_runner.py, storyboard_service.py, either web file, or any of the three test files. Two pieces of work share a file with it and neither blocks it. S49 is merged (442bf73); it edited storyboard_image_runner.py in the refinement and redraw regions and test_character_sheets_flow.py, a test file this card does not open. V56 is merged (810b27f) and its sweep read the two web files whose comments this card rewrites, so their lines may have moved with it; the recorded drift analysis says the sweep stops nothing here. A52 is merged (f79b410) and touches no anchor this card names. Every anchor above was measured at de99c60 and the lines have moved; the content each anchor names is unchanged, and the builder's first deliverable is an anchor table found by content on the main it starts from. S50 shares three files: storyboard_image_runner.py, where it edits the failure entry at :3288-3290 and :3607-3610 and adds a helper beside _release_claim (:3150-3167) while this card replaces fail_claims at :3144-3147, adjacent and not overlapping; apps/web/src/lib/videoStoryboardApi.ts, where it adds a field to both types and this card rewrites two comments; and test_video_storyboard_e2e.py. The order is S50 first, because its change is to an entry that already exists and that this card's entry does not have at all, so this card rebases onto a file S50 has already moved, while the reverse would make S50 re-read a region this card had just rewritten. The rule for both neighbours is the same one the merged cards have used: a shared file is a shared surface, so whichever of the two merges second is re-tested on the merge commit, and a merge-commit run that was cancelled is not a pass. Unblocks the #167 card, which finds one fewer caller of the unguarded write when it starts. Nothing waits on it.
S52 · A status write and an audit write say what they expect to find · S · M · S2-W2 · fix/s/s52-a-statement-says-what-it-expects (surfaced while building S46 and S47, pre-existing on main, open items #167 and #169; carded 2026-09-22)
Context. Measured at main de99c60, which is S48 merged (PR #154) on top of S47 (0a09b90). Two statements in one file address a row by its id alone while something else in the same file addresses the same rows with a predicate. The first is set_version_status (apps/api/app/backend/services/storyboard_service.py:2790-2799), whose whole body is UPDATE storyboard_scene_versions SET status = %s, provider_message = COALESCE(%s, provider_message) WHERE id = %s (:2796-2797), taking version_id, status and an optional provider_message and returning nothing. Its docstring says what it is for, "to promote 'pending' to 'ready' once the bytes are stored, or to mark a storage failure" (:2792-2793), and its statement says none of that: it writes whatever status it is given to whatever id it is given, whatever that row holds. It has six callers, all measured below, and the item's claim about them is right for five and needs one correction. The second is the session audit column. get_audit reads SELECT storyboard_audit FROM sessions WHERE id = %s AND mode = %s (:4913-4921, the predicate at :4917), and so do the column's other two readers, _roster_order (:1714-1717) and get_character (:4903-4906). Four of its seven writers address the row by id alone: record_audit_key (:4990-4992, the clause at :4991), record_batch (:5047-5063, at :5062), record_audit (:5214-5218, at :5217) and the audit write inside save_scene_prompts (:5299-5303, at :5301). The item says those four are what every other writer of the column does, and that is the one place its account of the code is wrong: the other three writers already carry the predicate. claim_audit_step has WHERE id = %s AND mode = %s AND status_id = 1 (:5143), touch_audit_step has WHERE id = %s AND mode = %s (:5174) and fail_audit_step has the same (:5198). So the column's own convention, where a statement states one at all, is id plus mode, and four statements do not state it. Why these two items are one card and not two. They are the same question asked of two tables, and the answer is one rule: a write that addresses a row it does not own has to say what it expected, and has to tell its caller when it found something else. The shapes for telling the caller already exist in this one file and they are two, fifteen hundred lines apart. The guarded twins return a plain answer: release_character_claim (:3335-3353) writes WHERE id = %s AND status = 'pending' (:3350) and returns cur.rowcount > 0 (:3353), and release_scene_claim (:3356-3383) writes WHERE id = %s AND (status = 'pending' OR (%s AND status = 'failed')) (:3380) and returns the same (:3383), with include_failed naming which settled states the caller owns (:3369-3374). record_audit_key refuses by name: it asks the row what happened in its own RETURNING (:4981-4993) and raises StoryboardError naming the key it found in the way (:4995-5006) or naming a leaf that did not land (:5011-5013). Choosing which of those two belongs to which statement is one decision. Taken in two cards it is taken twice, by two authors, over the same file, and the two answers can differ. There is a second, narrower seam, and S51 is the reason it exists: that card leaves _fail_claims (storyboard_image_runner.py:3130-3141) and its caller (:3206-3210) alone on purpose, and writes down that the ownership question there is a different one for another card. _fail_claims is a caller of set_version_status (:3139) and it sits beside _release_claim (:3150-3167), which is the guarded twin already wrapped for exactly this. So this card is the one S51 named, and #167 is what it has to answer to close it. The third reason is the file. storyboard_service.py is already shared by the S50 and S51 cards and by two items filed today, and every separate pull request against it is another merge commit to re-test. One honest statement about both items: neither is a live defect on de99c60. Every caller of set_version_status writes a row nothing else can reach, proved caller by caller below, and every session id that reaches an audit writer came from the mode-scoped resolver (:1515-1539, the predicate set at :1527) or from a session the same request had just created as a storyboard session (routes/video_storyboard.py:446-447). So this card closes two hazards, and the tests that can be made red are made red at the function and the runner, with the row state set up directly, which is the device S46's own release pin uses (test_video_storyboard_e2e.py:3390-3407) and S48's uses (:3590-3608).
Read first. apps/api/app/backend/services/storyboard_service.py:2790-2799 (set_version_status whole, its docstring and its unguarded statement), :3335-3353 (release_character_claim, the guard at :3350 and the answer at :3353), :3356-3383 (release_scene_claim, the ownership docstring at :3369-3374, the guard at :3380, the answer at :3383), :1705-1720 (_roster_order, the read's predicate at :1715), :1515-1539 (get_storyboard_session_detail, the resolver every storyboard route runs first and the four predicates it pins at :1527), :4901-4910 (get_character), :4913-4921 (get_audit), :4924-4953 (_audit_key_expression, and the device at :4939-4941 that sets a row part back to itself rather than replacing what somebody wrote), :4956-5013 (record_audit_key whole: the docstring's three facts at :4965-4974, the statement at :4988-4993, the named refusals at :4995-5013), :5024-5064 (record_batch, the statement at :5047-5063), :5115-5156 (claim_audit_step, the predicate at :5143), :5159-5178 (touch_audit_step, at :5174), :5181-5202 (fail_audit_step, at :5198), :5210-5219 (record_audit), :5293-5303 (the two writes at the end of save_scene_prompts). apps/api/app/backend/services/storyboard_image_runner.py:2789-2812 (_finalise whole, its three status writes at :2799-2800, :2806-2809 and :2811), :3130-3141 (_fail_claims), :3144-3147 (fail_claims), :3150-3167 (_release_claim), :3195-3215 (the clause that calls _fail_claims at :3209 and the finalise loop at :3212-3215), :3088-3127 (claim_frames, the pending create at :3123-3126). apps/api/app/backend/services/storyboard_runner.py:1428-1457 (_store_and_finalize's signature and the create_version that makes the row it later writes), :1511-1531 (its two status writes at :1512-1515 and :1526-1528). apps/api/app/backend/routes/video_storyboard.py:174-178 (_session_or_404), :446-447 (the one route-level call of record_audit). apps/api/app/backend/tests/test_video_storyboard_e2e.py:156-166 (the fake cursor's default state, the rowcount at :160), :327-351 (its two status handlers, the plain one at :331-337 and the guarded one at :338-351), :377-391 (its two audit-write handlers), :3390-3407 (S46's direct pin over the release statement), :3590-3608 (S48's, which settles a row directly before the press). apps/api/app/backend/tests/test_storyboard_candidate_failures.py:80-121 (the store that stands in for the status write at :91 and the refusal test that drives _fail_claims at :107-121). apps/api/app/backend/tests/test_video_storyboard_queue.py:375-397 (the render worker's fakes, the status one at :386-389). apps/api/app/backend/tests/core/test_storyboard_audit_paths_db.py:64-98 (the database fixture, the session insert at :72-75), :103-315 (its ten pins). apps/api/app/backend/tests/core/test_storyboard_batches_db.py:54-65 (the same fixture), :85-284 (its eleven pins). apps/api/app/backend/tests/test_video_storyboard_delivery.py:1536-1543 (the pin that reads record_audit_key's own source). apps/api/app/backend/tests/test_character_claim_release.py:625-650 (the character twin's answer pinned both ways). /root/development-processes/skynet/final/skynet-task-specs.md S47's Out of scope, which weighed this exact predicate and declined it, and its reason.
Deliverables. (1) One new service function beside the twins, and the two callers that own their rows keep the plain write. settle_scene_claim(version_id: int, status: str, provider_message: str | None = None) -> bool goes under release_scene_claim (storyboard_service.py:3356-3383) and is the twins' shape with the target status supplied instead of fixed: UPDATE storyboard_scene_versions SET status = %s, provider_message = COALESCE(%s, provider_message) WHERE id = %s AND status = 'pending', answering cur.rowcount > 0. The expectation is written into the statement and not passed as a parameter, because all three callers that need it expect the same thing and a parameter would invite a fourth expectation nobody has weighed, which is how set_version_status came to accept any status for any row. It is pending because that is what a claim is: claim_frames creates every candidate row that way (storyboard_image_runner.py:3123-3126) and so does the render worker (storyboard_runner.py:1437-1457). (2) _finalise's three writes become that call, each one logging the difference when the row had already moved. _finalise (storyboard_image_runner.py:2789-2812) is the settle half of a claim the same press took, and it writes three different outcomes: the provider's own status on a candidate that came back not ok (:2799-2800), failed when the bytes could not be stored (:2806-2809), and ready when they were (:2811). All three expect a row still pending, and all three write it unguarded today, so a row another writer had settled would be relabelled by whichever of the three ran. The false answer is printed in the runner's own warning shape (:3141, :3167) and _finalise returns the version id unchanged, as it already does in every branch. It must not raise: it runs once per candidate inside the comprehension at :3212-3215, and raising would take the sibling candidates of the same press with it, which is the property test_one_failed_candidate_does_not_take_its_siblings_with_it exists for (test_storyboard_candidate_failures.py:150-152). (3) _fail_claims goes, and its one clause calls the guarded release it should always have called. The clause at storyboard_image_runner.py:3206-3210 fires when the prompt build or the generate call raised before any candidate was finalised, which is provable from the order: _finalise runs only after that try succeeds (:3212-3215), so every claimed row this clause holds is still pending and has never been touched. That is exactly include_failed=False, the ownership release_scene_claim's docstring writes down for a run that was stopped (:3372-3374), and _release_claim (:3150-3167) is already that call wrapped best effort per row. So the clause becomes _release_claim(claimed, "Generation could not start. Try the scene again.", include_failed=False) and _fail_claims is deleted, because a second wrapper over the same wrapper is the drift this card is removing. This is the change S51 named and left, so the two do not disagree; if the two land in the other order, fail_claims (:3144-3147) calls _release_claim instead and S51 then deletes it, and the outcome is the same either way because the rows a refused press holds are pending on both paths. (4) set_version_status stays, for its two remaining callers, and its docstring says which callers may use it. _store_and_finalize creates the row it writes, in the same function, thirty lines above each write (storyboard_runner.py:1437-1457), and no other writer of any kind can reach a row that function has just made; the clip queue's own guard is a partial unique index giving one active job per scene, and the reaper deliberately clears nothing on the row it requeues. The worker owns the row's whole life and not only its birth: it writes ready at :1512-1515 and its own encompassing clause writes failed back over the same row at :1525-1528 when a later step of the same call raises. So the two writes at :1512-1515 and :1525-1528 are the case the plain statement is for, and the docstring says it in one sentence: this writes a row whose whole life the caller owns, from the create in the same call to the revert of its own ready write when a later step raises, and a caller settling a row it merely claimed uses settle_scene_claim, which says what it expects and answers whether it moved anything. That sentence is the item's success condition, because it is what tells the next caller which of the two it is. (5) The audit column's four unconditional writers gain the predicate its three conditional writers and all three of its readers already carry, and a miss is named rather than silent. record_audit_key (:4991), record_batch (:5062), record_audit (:5217) and the audit write inside save_scene_prompts (:5301) each gain AND mode = %s, appended after the id so the statement's opening text and its leading parameters do not move, which is what keeps the in-memory fakes that match on those two things green. A predicate on its own would only let a write vanish, which is the reason S47 weighed this and declined it, and that reason is answered here and not ignored: one shared helper beside them reads the session once, only on the path where nothing matched, and answers whether the row is gone or is a session of another mode. A row that is gone stays silent, which is the behaviour record_audit_key's docstring already promises (:4973-4974) and which every one of the four has today. A session of another mode raises StoryboardError naming the id, the mode found and the column, in record_audit_key's own refusal voice (:4995-5006). One helper and not four copies, because four copies of a sentence is how the two hooks S51 is fixing came to hold the same literal twice. (6) The writers that already carry the predicate are not touched and the reads are not loosened. claim_audit_step (:5143), touch_audit_step (:5174) and fail_audit_step (:5198) are right as they stand and their status_id and status clauses are theirs. get_audit (:4917), get_character (:4904) and _roster_order (:1715) keep the predicate, because dropping it would widen a read that runners call with a bare id and no other scope, which is the opposite of what either item asks for. The storyboard_stage write beside the audit write in save_scene_prompts (:5294-5297) is a different column and is left alone, named in the record so the next reader knows it was seen. (7) docs/ops/s52-acceptance.md: every caller of set_version_status before and after, with the line each was on and the line each is on, and for each the one sentence that says why it keeps the plain write or takes the guard; the row states of a press whose claim had already been settled, before and after, for both the finalise path and the could-not-start clause; the printed refusal of an audit write aimed at a session of another mode, quoted; the same write aimed at a session that is gone, showing it still says nothing; the seven audit statements listed with the predicate each carries after this card; and the measured statement that neither item was reachable from any caller on de99c60, with the evidence for it. (8) The finalise stores nothing over a row it no longer owns (added in review). On the ok path the picture is uploaded under an object key unique to this attempt, so an upload can never replace the object another row points at, and one conditional statement then sets both r2_key and status = 'ready' only where the row is still pending with the predicates settle_scene_claim carries. That conditional write is the ownership decision; no earlier pending check is relied on. When it moves zero rows, the object this attempt uploaded is deleted and the same refusal warning is logged. The record shows, for a row another writer had settled ready, its r2_key and stored bytes before and after the press.
Interfaces. Exposes one new service function, settle_scene_claim(version_id: int, status: str, provider_message: str | None = None) -> bool, whose only callers are _finalise's three writes and which answers whether this call was the one that moved the row, copying release_character_claim (:3353) and release_scene_claim (:3383) rather than inventing a third answer. Exposes one private helper beside the audit writers that says why an audit write matched nothing, called only from those four and only when nothing matched. Removes _fail_claims (storyboard_image_runner.py:3130-3141), whose one clause moves to _release_claim. set_version_status keeps its name, its signature, its statement and its behaviour, and gains two sentences of docstring. release_scene_claim, release_character_claim, _release_claim, get_audit, get_character, _roster_order, claim_audit_step, touch_audit_step and fail_audit_step keep their names, their signatures and their behaviour. record_audit_key, record_batch, record_audit and save_scene_prompts keep their names, their signatures and their returns; each gains one predicate and one refusal that no caller on main can reach, which is the shape S47's own refusal has (:4995-5006). No new route, no new poll key, no new client type, no web file changed and no migration. The scenes poll answer, the batch record and the session audit's stored shape are byte for byte what they are today.
Acceptance. The api tier green as AGENTS.md sets it out, from apps/api, with DESELECT=$(grep -vE '^\s*(#|$)' ci-known-failures.txt | sed 's/^/--deselect /' | tr '\n' ' ') and .venv/bin/python -m pytest app/backend/tests -q -p no:cacheprovider $DESELECT, the venv's own interpreter and never a bare pytest, with pytest's own exit code read from the command and the passed, skipped and deselected counts quoted in the record. The targeted api run is .venv/bin/python -m pytest app/backend/tests/test_video_storyboard_e2e.py app/backend/tests/test_storyboard_candidate_failures.py app/backend/tests/test_video_storyboard_queue.py app/backend/tests/test_video_storyboard_delivery.py -q -p no:cacheprovider. The database pins under app/backend/tests/core are run in the full tier and not on a workstation: that package builds a real Postgres in docker and its own fixture file says a missing docker fails loudly rather than skipping, so the record quotes their result from the tier run. No web tier is run, because this card changes no file under apps/web; the record says so rather than leaving a missing command. .venv/bin/python -m py_compile clean over every changed Python file. Nine tests RED ON THE BASE (corrected in review: the settle's pin and the source-agreement pin are red on the base, because settle_scene_claim does not exist there and four of the ten predicates are missing there; and deliverable 8 adds two, the settled picture that keeps its key and bytes, and the store whose statement fails, which deletes its upload and settles failed), each run on de99c60 first with its own failure quoted in the record by its reason and not by a count. Three of them are made red the way S46 and S48 make theirs red, by putting a row into the state the guard is about and then driving the real press, which is what test_the_guarded_release_moves_only_the_rows_the_run_still_owns does (test_video_storyboard_e2e.py:3394-3402) and what test_a_stopped_press_leaves_a_settled_row_of_a_scene_it_never_reached does when it settles a claim directly before the press (:3597-3599); no caller can produce these states today, and that is the hazard, so the state is made and the caller is real. In test_video_storyboard_e2e.py: a candidate row that another writer settled as ready is not relabelled when the press finalises it, and the settle says it moved nothing, where the base writes the provider's status straight over the stored picture; the same row settled as refused keeps the provider's own sentence through the finalise, where the base replaces it; and a press whose generate call raises before any candidate is finalised hands back only the rows it still owns, so a sibling row that had settled keeps its own sentence, where the base rewrites all three as failed through the plain statement. In app/backend/tests/core/test_storyboard_audit_paths_db.py, against the real column: an audit write aimed at a session of another mode is refused by name and the row and its updated_at stand, where the base writes the leaf into a session no reader of this column will ever return, which the same test proves by reading it back through get_audit and finding nothing; the fixture already inserts the session's mode explicitly (:72-75), so the other-mode session is that one parameter. And in the same file, a press record aimed at a session of another mode is refused the same way, where the base files it into a row recent_batches can never be handed. Two more RED tests in test_video_storyboard_e2e.py, for deliverable 8: a candidate another writer settled ready with an r2_key keeps that key and its stored bytes when the press finalises a successful result for it, where the base uploads over it and relabels; and a store whose conditional statement raises deletes the object this attempt uploaded, the error still reaches the finalise, and the row settles failed with the existing sentence. Two more RED tests that pin the new statements, labelled RED ON THE BASE with the failure each shows there. The settle answers the twins' answer: a pending row moves and says True, a ready row is untouched and says False, asserted directly over the statement the way S46's pin asserts over the release. Three GUARD tests, green on both sides and labelled as guards, because each pins a behaviour this card must not change. The render worker still uses the plain write: the row it creates and settles in one call moves on both the ready path and the store-failure path, with no guard between them. A session that is gone still says nothing: every one of the four audit writers aimed at an id that matches no row writes nothing, raises nothing and returns, which is the behaviour record_audit_key's docstring promises. The three conditional audit writers still carry their own predicates unchanged, claim_audit_step including its status_id clause. Last, the source-agreement pin, which is RED ON THE BASE as counted above and not a guard: the seven writers and three readers of the column agree: the predicate each statement carries is asserted from the functions' own source, in the shape test_video_storyboard_delivery.py:1541-1542 already reads record_audit_key's source, so the split cannot drift back silently. Two pieces of existing test infrastructure change, both named in the record with the reason and neither changing what is asserted. The fake cursor's plain status handler (test_video_storyboard_e2e.py:331-337) is taught the guard, because the new statement shares its opening text and the fake would otherwise answer a guarded call as though it had moved every row while reporting the default rowcount of zero (:160); it splits on the guard being present, models the rowcount and leaves a row outside the expectation alone, which is what the guarded handler beside it already does (:338-351). The two audit-write handlers (:377-391) set a rowcount of one for a row the fake holds, because the writers now read it. In test_storyboard_candidate_failures.py, the store's stand-in moves from set_version_status to release_scene_claim (:91), which is the sink the clause now writes through; its three assertions (:119-121) are unchanged, and the record shows them passing before and after. Existing pins that must stay green unchanged, none edited and none deleted: in test_video_storyboard_e2e.py, S46's eleven (:3277, :3290, :3314, :3332, :3350, :3367, :3390, :3410, :3446, :3462, :3476) and S48's five (:3491, :3527, :3559, :3590, :3611); in test_storyboard_candidate_failures.py, the refused-batch test's own assertions (:119-121) and the three candidate-failure tests beside it (:126, :138, :150); in test_video_storyboard_queue.py, the render worker's clip tests over its status fake (:386-389); in test_video_storyboard_delivery.py, the source pin over record_audit_key (:1536-1542); in test_character_claim_release.py, the character twin's answer pinned both ways (:629, :646); and under app/backend/tests/core, all ten audit-path pins (:103 to :315) and all eleven batch pins (:85 to :284), every one of which uses a fixture that already creates a storyboard session (:72-75 and :62-64), which is why the predicate leaves them alone. No comment the card adds or rewrites names a card, an item, a phase or a wave, and python3 infra/scripts/test-task-refs.py then python3 infra/scripts/task-refs.py check . clean from the repository root. bash infra/secret-scan/scan-tree.sh clean, the one command AGENTS.md mandates at AGENTS.md:39, with both halves named in the record. Record docs/ops/s52-acceptance.md; open items #167 and #169 closed.
Out of scope. Open item #166, the scene row read twice before a single-scene press claims: its row says to fold it into the next card that changes claim_frames's signature, and this card changes no signature of claim_frames and does not open it. Open items #174 and #175, the roster reads inside update_character_text (storyboard_service.py:4819, :4820, :4888) and inside write_character (storyboard_image_runner.py:2014): their rows say they come after S49 merges, they are a different defect in a different function, and both anchors were read and confirmed so the next card starts from measured lines. Open item #171, the same shape on the pick path, for the same reason. Any change to what release_scene_claim or release_character_claim do, which S46 built and S48 uses and which this card only copies. Widening settle_scene_claim to take the expectation as a parameter, which lost for the reason deliverable 1 gives: the three callers expect the same thing and a parameter is how a statement stops saying what it expects. Giving the clip version row a guard on the render worker's two writes, which lost because the worker owns that row's whole life: it creates the row in the same call (storyboard_runner.py:1437-1457) and its own encompassing clause writes failed back over the same row at :1525-1528 when a later step of that call raises, so a pending guard would silently lose that legitimate revert, and the docstring deliverable 4 writes is what keeps the two plain writes honest instead. Loosening any read of the audit column, which is the other direction the item offers and which would widen a read runners call with a bare id. Adding a predicate to the storyboard_stage write beside the audit write (:5294-5297), which is a different column with its own readers and its own question. Turning record_audit or record_batch into callers of record_audit_key, which S47 weighed and declined because neither wants a single leaf, and which this card does not revisit. The queue's own token gate and the reaper's deliberate refusal to clear a requeued job's provider id, which this card reads to prove the render worker owns its row and changes in no way. Correcting docs/ops/s47-acceptance.md, which recorded what was measured then and is not a document to rewrite now.
Waits on / Unblocks. Waits on nothing. Everything it reads is on main at de99c60, and no open pull request touches a file it changes: #137, #136 and #118 were listed file by file, and their api files are all under apps/api/app/backend/tests/core/test_meta_ads_*.py, test_migrate.py and test_users.py, with everything else under db/migrations/unified/meta_ads/, services/scraper/, infra/ or docs/. #136 is worth naming although it shares nothing: it is the same rule enforced in another package by a test, and its corpus is the scraper's own SQL, so it reaches no file here. #118 changes infra/scripts/task-refs.py and infra/scripts/test-task-refs.py, which this card runs and does not edit. A51 is built and unmerged and edits only docs/CI.md and .github/workflows/ci.yml, neither of which this card touches, so it shares nothing with it. Four pieces of work share a file and none blocks it. S49 is merged (442bf73); it shifted every storyboard_image_runner.py anchor below the refine paths by seventeen lines and added a test near the head of test_video_storyboard_e2e.py, and it touched nothing else this card reads, so the content each anchor names is unchanged and the numbers map by rule: _finalise is :2806-2829 with its three writes at :2816-2817, :2823-2826 and :2828, claim_frames' pending create is :3140-3143, _fail_claims is :3147-3158 with its call at :3156, fail_claims is :3161-3164, _release_claim is :3167-3184, the could-not-start clause is :3223-3227 with the call at :3226, and the finalise comprehension is :3229-3232. The ranges still do not meet — S49's were the refine and redraw paths and this card's are _finalise and the could-not-start clause, so the overlap is the file and not the region — but test_video_storyboard_e2e.py is a file this card adds tests to and S49 moved it, so it is a shared surface and the merge-commit re-test below applies to it. S50 and S51 will move storyboard_image_runner.py again before this card starts, which is why the builder's first deliverable is an anchor table found by content on the main it starts from and not these numbers. S50 edits the failure entry at :3288-3290 and :3607-3610 and adds a helper beside _release_claim, shares test_video_storyboard_e2e.py, and touches a web file this card does not. S51 replaces fail_claims at :3144-3147, rewrites one comment in storyboard_service.py at :5079-5083, and shares test_video_storyboard_e2e.py and test_storyboard_batch_record.py. Items #174 and #175 have no card yet and will meet storyboard_service.py when they do. The recommended order is S50, then S51, then S52. S51 before S52 because S51 removes the public wrapper and S52 removes the private one, so this card starts from a file with one caller left instead of two; the reverse works and costs S51 one line, which deliverable 3 states. S52 last of the three because it is the only one that changes a statement every one of them writes through. The rule for all of them is the one the merged cards use: a shared file is a shared surface, so whichever merges second is re-tested on the merge commit over the shared surface, and a merge-commit run that was cancelled is not a pass. A red main freezes the merge. Unblocks nothing that is waiting, and leaves the next caller of either statement told which form to use.
S53 · A probe that fails names the tool's own reason, and every quarantined line is true · S · M · S2-W2 · fix/s/s53-a-failed-probe-names-its-reason (surfaced while building S42 and S48, pre-existing on main, open items #133 and #168; carded 2026-09-22)
Context. Measured at main de99c60, which is S48 merged (PR #154), and re-measured at f79b410 by the first review round with every cited anchor unchanged. This is the most valuable card left in the backlog, and the reason is arithmetic rather than taste: every other open card fixes one behaviour, and this one is the only card that can stop all of them. A red api run on main freezes every merge until its cause is found, and the api tier can read red on main today for a cause nobody is able to name. Two tests in one file fail the same way. test_two_callers_at_once_are_never_handed_the_same_path (apps/api/app/backend/tests/test_video_storyboard_delivery.py:176-258) failed in a whole-tier run, and test_X3_an_audio_on_clip_offers_three_and_keeps_its_bed (:510-522) failed at the eight-process rung of the load ladder. Both ended at AssertionError: volumedetect reported no mean_volume, raised at :170 inside _mean_volume_db, reached at :241 through run.result() at :246 in the first case and at :522 in the second (docs/ops/s48-acceptance.md:317-330, docs/ops/s42-acceptance.md:216-224). Neither sighting could say why ffmpeg stopped, because the helper is built so that it cannot say. Its ffmpeg call at :164-166 is the one call in this chain that runs without check=True, unlike the five at :38, :98, :112, :122 and :130; it then scans standard error for one line at :167-169 and raises a sentence about that line at :170. The return code is thrown away, the standard error is thrown away, and a tool that exited becomes an assertion about a missing line. No wait can be the cause: timeout=120 at :166 raises TimeoutExpired and both_at_once.wait(timeout=60) at :232 raises BrokenBarrierError, and neither was raised in either sighting. The bytes are not the cause: the file one sighting wrote was still on disk, and the same command over the same bytes answered mean_volume: -33.1 dB (docs/ops/s42-acceptance.md:230-234). So open item #133 has cost two runs and returned nothing twice, and it cannot progress however many more times it is seen. The second item lives in the same file. test_the_storyboard_screen_derives_pending_from_versions (:1617-1630) reads a screen at parents[2] / "frontend" / "src" / "pages" / "video-storyboard" / "VideoStoryboardScreen.tsx" (:1624-1625), which resolves to apps/api/app/frontend/..., a directory that does not exist, so it fails with FileNotFoundError. It is line 10 of apps/api/ci-known-failures.txt, under a header that gives all three entries one shared reason, that they fail as upstream product logic at the imported source commit (:3-7). That reason is true of the other two and false of this one, which S47's record already found and left for the quarantine's owner (docs/ops/s47-acceptance.md:321-330). Measured today, two of the three assertions this test pins are presence checks that pass against apps/web/src/pages/video-storyboard/VideoStoryboardScreen.tsx and the third is a negative check whose forbidden string is absent, so it is not a failing test that needs a better reason. It is a guard filed where it can never run.
Read first. apps/api/app/backend/tests/test_video_storyboard_delivery.py:7-14 (the module's own statement that these tests run real ffmpeg over fixtures ffmpeg itself makes), :36-42 and :45-47 (the availability probe and the skip marker, both evaluated once at import and therefore before any test can change the search path), :87-100, :103-114, :117-124 and :127-131 (the four fixture and probe helpers whose calls end capture_output=True, check=True, timeout=... at :98, :112, :122 and :129-130), :153-170 (_mean_volume_db whole: the temporary file at :162, the seek at :163, the call without check=True at :164-166, the scan at :167-169, the raise at :170), :176-258 (the concurrency test, the barrier built at :195 and waited at :232, the call at :241, the path check at :242, the pool and run.result() at :244-246), :282-293, :472-485 and :510-522 (the other three callers of the helper), :1617-1630 (the dead pin, its path expression at :1624-1625 and its three assertions at :1627, :1628 and :1629-1630), :1633-1647 (the test directly below it, which pins a service function from its own source and is the in-tier precedent for a source pin that works). apps/api/ci-known-failures.txt whole, ten lines: the header at :1-7 and the three entries at :8-10. apps/api/app/pipeline/utils/storyboard_ffmpeg.py:52-55 (FFmpegError), :58-69 (_run, and the message shape at :65-67 that takes the last 500 characters of standard error), :72-79 (_tmp, which the test helper borrows), :488-509 (peak_volume_db, the product twin of the test helper, which omits check=True on purpose and states its reason at :491-495). .github/workflows/ci.yml:60-63 (the api filter, apps/api/** and db/**), :95-102 (the web filter, apps/web/** and e2e/**), :244-245 (ffmpeg installed for the api job), :251-256 (the api step and the deselect line at :253), :341-342 (the web unit run, npm test). AGENTS.md:28-33 (the recipe, and the sentence at :33 that names a count of three). docs/CI.md:10 and :32-34 (the quarantine section, which names no count and holds the owner and the reason in the header comment rather than on any entry). apps/web/src/pages/video-storyboard/VideoStoryboardScreen.tsx:774-784 (scenePending derived at :777-778 from this scene's own versions and sceneGenerating at :783-784) and :1660-1672 (the panel, gated at :1670). apps/web/src/pages/video-storyboard/VideoStoryboardScreen.test.tsx:13-63 (the scene and detail fixtures and the spies the file already installs, with the note at :59-60 that panel reads are stubbed so a test fails only for its own reason) and :256-278 (the two-scene block, the shape to copy). apps/web/src/components/video-storyboard/RenderPanel.tsx:204-210 (the control, disabled by disabled || busy || !hasFrame at :205, labelled Render or Render again at :209). docs/ops/s42-acceptance.md:216-234 (the first sighting and the re-probe), docs/ops/s48-acceptance.md:317-342 (the second sighting and the mechanism named), docs/ops/s47-acceptance.md:321-330 (the record that filed #168).
Deliverables. (1) _mean_volume_db reports ffmpeg's own reason instead of its own guess. The call at :164-166 gains check=True, which is the shape its four fixture and probe neighbours already carry, and the function gains one except subprocess.CalledProcessError as exc that raises AssertionError(f"ffmpeg volumedetect exited {exc.returncode} for {path}: {(exc.stderr or '')[-500:]}"). The tail of 500 characters is the product helper's own number (storyboard_ffmpeg.py:66), and it is enough to hold volumedetect's summary block and the error line above it. (2) The other half of the same defect: the branch that runs when ffmpeg exits clean and prints no summary line keeps its sentence and stops being silent about everything else, so :170 becomes raise AssertionError(f"volumedetect reported no mean_volume; ffmpeg exited {out.returncode} for {path}: {(out.stderr or '')[-500:]}"). Both messages name the path because S42 made these files survive the run on purpose (docs/ops/s42-acceptance.md:28-38), which is the only reason the one re-probe was possible at all, and a message that names the file turns that re-probe from detective work into a copied command. (3) TimeoutExpired is left exactly as it is, uncaught. Its own message already names the command and the seconds, both sightings proved it was not raised, and widening this helper past what the sightings need is how a small repair becomes a rewrite. (4) The dead pin leaves the api tier: :1617-1630 is deleted and no other content changes, and the source pin S52 cites at :1536-1542 keeps its content; it moves to :1601-1607 (corrected in review) because the helper repair adds lines above it, and nothing else is edited. (5) The same guard is rebuilt where the file it watches can trigger it, as a behavioural case in apps/web/src/pages/video-storyboard/VideoStoryboardScreen.test.tsx: a board of two scenes where the focused scene carries current_image_version_id and no pending image version while the other scene carries pending_frame_count: 1, and the Render control is enabled; then the same board with one {kind: 'image', status: 'pending'} version on the focused scene, and the control is disabled. That is the whole content of the three deleted string assertions, stated as behaviour, and it cannot go stale when a variable is renamed. (6) Line 10 of apps/api/ci-known-failures.txt is removed, and the header stops holding one reason for everybody: each remaining entry gets its own comment line directly above it, carrying the owner already named in the header and the reason that is true of that entry alone. The reason must sit on its own line rather than trail the entry, because the deselect recipe keeps whole non-comment lines (AGENTS.md:30, ci.yml:253) and a trailing comment would be handed to pytest as part of the node id. (7) A check that keeps the list honest, as two api tests in a new test_ci_known_failures.py, both reading apps/api/ci-known-failures.txt through Path(__file__).resolve().parents[3] and both asserting the file is there before they read it. The first walks every non-comment line, parses the node id into its file and its name with any parametrised bracket suffix stripped, and asserts the file part exists and the test's def appears in that file's text; on the base it is red because line 10 names a test whose file does not hold it. The second asserts the line directly above each entry is a comment carrying an owner and a reason; on the base it is red because no entry carries one. Two tests and not one, because one test's first failure would hide the other's and the acceptance needs each red for its own reason. They read three files and start no process. (8) AGENTS.md:33 stops naming a count, so the sentence cannot go stale the next time a line joins or leaves.
Interfaces. Exposes nothing new to any product caller, because nothing here is product code. _mean_volume_db keeps its name, its signature (data: bytes, *, start: float | None = None) -> float, its return, and the exception type it raises on both failing paths, AssertionError, so the four surviving call sites at :241, :292-293, :485 and :522 are untouched and every one of them still reads as a test failure in the same place in pytest's report. Only the message text changes, and only when the helper was already going to fail. The api tests' contract with the quarantine file is unchanged: one node id per line, comments and blank lines ignored, which is what the deselect recipe reads. The file gains comment lines and loses one entry, and the recipe's own grep already drops every comment, so neither AGENTS.md:30-31 nor ci.yml:253 needs a change. The web screen and the render panel change not at all: scenePending (VideoStoryboardScreen.tsx:777-778), sceneGenerating (:783-784) and the gate at :1670 are correct today and the card only gives them a guard that runs. The new web case consumes the fixtures and spies the test file already declares at :13-63 and adds no export. The new api test consumes nothing but the standard library and the two paths it reads.
Acceptance. The api tier green as AGENTS.md sets it out, from apps/api, with DESELECT=$(grep -vE '^\s*(#|$)' ci-known-failures.txt | sed 's/^/--deselect /' | tr '\n' ' ') and .venv/bin/python -m pytest app/backend/tests -q -p no:cacheprovider $DESELECT, the targeted files run locally with the venv's own interpreter and pytest's own exit code read from the command. The targeted api run is .venv/bin/python -m pytest app/backend/tests/test_video_storyboard_delivery.py app/backend/tests/test_ci_known_failures.py -q -p no:cacheprovider, and ffmpeg is on the box so the marked tests run rather than skip. One run more, recorded in the record as corroboration and not as the sole proof: the same tier without the deselect list shows exactly two failures, both in test_onscreen_pipeline_geometry.py, which is what proves the list is now the size it says it is; the size is proved as well by the two quarantine tests, which read every entry the tier does not deselect. No test starts a container and none needs docker; the database package under app/backend/tests/core is untouched and is CI's run. The web tier as AGENTS.md sets it out, because one web file changes: cd apps/web && npm ci && npm run lint && npm run typecheck && npm run build, and the targeted web run is npx vitest run src/pages/video-storyboard/VideoStoryboardScreen.test.tsx. .venv/bin/python -m py_compile clean over every changed Python file. Three tests RED ON THE BASE and one GUARD (corrected in review: the target-existence test is green on the base). The two ffmpeg ones cannot be made red by load or by timing, because each puts a stand-in ffmpeg first on the search path from tmp_path through monkeypatch.setenv and the stand-in decides its own exit: a stand-in that writes one line to standard error and exits 3 makes the helper raise a message carrying 3 and that line, where the base raises the bare sentence and carries neither; a stand-in that exits 0 and prints a banner with no mean_volume: line makes the helper raise a message carrying the exit status and the banner's tail, where the base raises the bare sentence alone. The two quarantine tests of deliverable 7 do not depend on each other: the target-existence one is a GUARD, green on the base because the dead pin's own definition still stands there (test_video_storyboard_delivery.py:1617), and proved by mutation instead: on the base with the pin deleted and line 10 kept, it fails naming line 10; the reason-line one is red on the base, because no entry carries a reason of its own at all and the one shared reason is false of line 10. Two GUARD cases in the web file: the enabled case above, which is red on the base only in the sense that it does not exist there, and the disabled case beside it. The guards that must stay green and are named in the record: the four surviving callers of the helper with real ffmpeg (:241, :292-293, :485, :522), which prove the repair did not touch the path where ffmpeg works; the availability probe and the marker (:36-47), which run before any stand-in exists; the source pin above the deleted block (:1536-1542 on the base, :1601-1607 after the helper repair), whose content does not change; and the two geometry entries, which still fail for the reason their new line gives. python3 infra/scripts/test-task-refs.py then python3 infra/scripts/task-refs.py check ., and no comment added here names a card, an item or a phase. bash infra/secret-scan/scan-tree.sh as AGENTS.md:39 mandates. The record is docs/ops/s53-acceptance.md, and it carries both messages verbatim, the stand-in scripts, the two tier runs with and without the list, and the sentence that open item #133 stays open.
Out of scope. Anything that makes the underlying race rarer rather than legible: a retry around the helper, a lock over the ffmpeg calls, serialising the concurrent callers, or a smaller worker count. The reason is that none of them can be shown to work. The trigger is the load of the whole tier and it could not be produced on demand; a probe of four callers over six rounds, driving the same calls in the same order, failed 0 of 24 (docs/ops/s48-acceptance.md:326-328). A change nobody can measure is not a fix, and each of these would also destroy the evidence the next sighting has to produce. Quarantining the concurrency test, for the same reason the card exists: an entry whose reason is that nobody knows is the exact defect the other half of this card is correcting, committed on purpose. The two geometry entries, test_merge_events_combines_back_to_back_captions and test_merge_events_uses_original_timeline_gap_when_present, because their stated reason is true today: both were run at this commit and both fail on grouping logic with assert 2 == 1, which is upstream product logic and not a runtime regression, so there is nothing to correct and their fix is their own card. peak_volume_db's missing check=True (storyboard_ffmpeg.py:499-507), because it is deliberate and its docstring gives the reason at :491-495: a probe that fails there must not fail a paid render, and the caller reads a None. That reason is what makes the test helper's copy of the shape wrong, and correcting the production twin would break a stated decision. The temporary files the helper leaves behind, because S42 made that deliberate (docs/ops/s42-acceptance.md:28-38) and it is the only reason the one useful re-probe exists. docs/CI.md:32-34, because it names no count and puts the owner and the reason in the header comment, so deliverable 6's per-entry lines strengthen the contract past what it promises and the file itself needs no edit; built work already edits that file and another edit buys nothing. .github/workflows/ci.yml, because the check is an ordinary api test and runs inside the step that is already there; a new workflow step would contend with two built branches over one file for no gain. The web screen's own behaviour, because scenePending and the gate are correct and only the guard moves. Whether the api job should also run the quarantined tests to prove they still fail: it costs little, 1.53 s in pytest and 3.4 s of wall time for the three at this commit, but a quarantine is where a flaky test goes, and a check that fails when a quarantined test passes turns a flake into a frozen main, which is the failure this whole card exists to remove.
Waits on / Unblocks. Waits on nothing. Everything it reads is on main at de99c60: the helper and its five neighbours, the four callers, the dead pin, the quarantine file, the two path filters and the two tier steps, the screen and its test file. No open pull request touches a file it changes; the open ones are #118, #136 and #137, all on the meta ads port, and none carries a storyboard file, a web page or the quarantine list. Four pieces of work share a surface with it and none blocks it. S49 is merged (442bf73) and edited storyboard_image_runner.py, test_character_sheets_flow.py and test_video_storyboard_e2e.py, so it shares no file with this card at all; both land test files under app/backend/tests, which is a directory and not a surface. A52 is merged (f79b410) and edited docs/CI.md, so the Read first's docs/CI.md anchors were measured before that merge and are re-measured on the tree the build starts from; A51 is built and unmerged and edits docs/CI.md and .github/workflows/ci.yml, and this card deliberately touches neither, so the overlap is designed away rather than managed. V56 is merged (810b27f); it swept five web files, none of them under src/pages/video-storyboard/, so no anchor here moved with it. S50 and S51 both edit apps/web/src/pages/video-storyboard/VideoStoryboardScreen.test.tsx, which is this card's one web file and therefore a real shared surface; S50 adds cases to the batch banner block at :280-397 and S51 adds one case for a stopped press, and this card adds a new block for the render control, so the regions differ but the file is one. S52 runs test_video_storyboard_delivery.py in its targeted set and cites the source pin at :1536-1542 without editing the file, so the two cards share that file in one direction: this card deletes lines below the pin and S52 asserts the pin, which makes the file a shared surface, and the merge-commit re-test below applies to whichever of the two merges second. The recommended order is S50, then S51, then S53, so this card's web block is added last and rebases once instead of three times. The rule for all of them is the one the merged cards use: a shared file is a shared surface, so whichever merges second is re-tested on the merge commit over the shared surface, and a merge-commit run that was cancelled is not a pass. A red main freezes the merge, which is the thing this card is about. Unblocks every card that has to merge, by making the one failure that can freeze them all say why it happened. It does not close open item #133. It cannot: the cause is not known, the trigger is whole-tier load, and nothing measured here can prove a race stopped. What it closes is the reason #133 has been unable to move, and what #133 still needs afterwards is one more sighting on a loaded tier, which will now carry ffmpeg's exit status, its standard error and the path of the file it was reading, and then a fix aimed at whatever that names. Open item #168 is closed by this card.
S54 · A refusal that stops the sheet reading is told once, not once per sheet · S · S · S2-W2 · fix/s/s54-a-refused-sheet-read-is-told-once (surfaced while carding #158 and #159, pre-existing on main, open item #170; carded 2026-09-22)
Context. Measured at main de99c60, which is S48 merged (PR #154) on top of S47 (0a09b90). The item's line numbers are the ones it was written against and every one of them has moved: by ten above prepare_cast, because S48 added PRESS_REFUSALS and its comment at the head of the file (apps/api/app/backend/services/storyboard_image_runner.py:27-34), and by forty five at _variant_prompts (:3016-3019 to :3061-3064), because S48 also added thirty five lines in prepare_cast; the measured numbers are used throughout below and the item's are named beside them where they differ. read_one_sheet (:885-907) reads one uploaded reference sheet, bills it and hands back the block to store. It wraps exactly one call, images.describe_supplied_character(r2_service.download_bytes(key)) (:899-900), in a clause that catches everything (:901, which the item calls :891). The clause prints one warning line (:902-904) and returns images.failed_read_block(exc) (:905), which is {"identity": {}, "variants": [], "read_from": {}, "instruction": <hash>, "status": "failed", "error": str(exc)} (apps/api/app/pipeline/storyboard_images.py:661-675). Its own docstring states the rule in bold: it does not raise (:892-894). That rule is right for what it was written for and wrong for what it also catches. The one call under it reaches the character model boundary, storyboard_images._character_model_call (:140-155), and that boundary deliberately re-raises two kinds of refusal by type: model_gateway.GATEWAY_ERRORS (:143-144) and provider_key.ModelKeyRefused (:145-150). So a paused spend guard, an inactive model, a workspace that could not be resolved, an unbound workflow key, a bindings read that broke, a usage write that failed, and a credential the workspace does not hold all arrive at :901 as themselves, and all seven are written down as one page the model could not make sense of. None of the seven is about the page. What an operator ends up reading, printed: for a paused guard the stored error is Spending is paused for this workspace. and the log line is [storyboard][warn] session 1: reference sheet sheets/a.png could not be read (Spending is paused for this workspace.); assign it and fill it in by hand.; for a missing credential the stored error is No production.storyboard.llm call was sent: openrouter carries no stored credential on this workspace and the OpenRouter key from settings or the environment is empty. Set the openrouter key, then try again. (apps/api/app/pipeline/utils/provider_key.py:98-109, with str(exc) equal to detail at :95) and the same log line wraps it in the same advice to fill the page in by hand. Both sentences tell the operator to do something to a page when the thing to do is to the workspace. How many times, counted: read_uploaded_sheets (:910-952) loops over sheet_pool(_launch_config(session_id)) (:937), skips any entry that already has a reading (:938-940) and calls read_one_sheet for every one that does not (:942). Nothing in that loop catches a refusal, so for a pool of N unread sheets one paused guard costs N model round trips, N printed warnings, N stored failed readings and done["failed"] == N. The pool is append-only and uncapped (:480-500), so N is whatever the session uploaded. This is the same defect as #158 and #159 one level down, and the three stages above it in the same file already answer it correctly: ensure_cast re-raises the gateway's refusals by type (:197-200, which the item calls :187-190) and degrades only storyboard_llm.StoryboardLLMError (:201-214); match_uploaded_sheets re-raises the same by type (:409-410, item :399-400) and degrades only the same one type (:411-426); _variant_prompts re-raises the same by type (:3063-3064, item :3016-3019, moved by forty five rather than ten because S48 also added thirty five lines in prepare_cast) and degrades only the same one type (:3065-3080). read_one_sheet is the one stage of the four that does not, and the reason it does not is historical rather than argued: the docstring's promise was written about an unreadable file, which is the failure the pass must survive, and the refusals arrived at the same clause later. Where a re-raise would land, and this is what makes the card small. read_uploaded_sheets has two callers. prepare_cast:1021-1026 calls it inside the clause S48 built, which appends one entry to done["errors"] carrying the step name, the sentence and the class (_swallowed, :955-960), prints once (:1024-1025) and falls through to the pairing; _run_cast_stage (:1212-1229) then writes that as the existing failed state on the cast_run audit record with error and error_kind (:1216-1221), the scenes poll serves it (apps/api/app/backend/routes/video_storyboard.py:768) and the screen draws one line per step that did not run (apps/web/src/pages/video-storyboard/VideoStoryboardScreen.tsx:990). start_reading_sheets (:1264-1272) calls it through the same _in_background and the same _run_cast_stage, whose own broad clause (:1222-1227) writes the same two fields for a target that raised. So both background callers already have somewhere to put a refusal that escapes, both write it where the board reads it, and neither needs a line changed. That is the whole of what S48 opened: before it, a refusal that escaped read_uploaded_sheets would have been printed into a record that read complete with zero counts. The third caller is the one that changes shape. read_one_sheet is also called synchronously by the re-read button (:1136), whose comment says a failed re-read is recorded and not raised (:1131-1134). A refusal that now leaves that call reaches the app's own handlers: main.py:315-319 answers each of the gateway's six with its own status, and main.py:351-353 answers a credential refusal 503 with its sentence. So the operator who pressed re-read on a paused workspace reads the guard's own sentence with its own status instead of a stored reading that says the page is unreadable. The per-sheet store clause the item asks to be left alone (:945, item :935) wraps svc.record_sheet_read and nothing else (:943-950); it is the isolation the stage is built on, it sees only a database write, and the item is right about it.
Read first. apps/api/app/backend/services/storyboard_image_runner.py:14-24 (the imports, which do not yet include provider_key), :27-34 (PRESS_REFUSALS, whose comment is about a press and not about this stage), :144-236 (ensure_cast, the typed re-raise at :197-200 and the narrowed degrade at :201-214), :332-435 (match_uploaded_sheets, the typed re-raise at :409-410 and the narrowed degrade at :411-426), :480-510 (sheet_pool and its append-only rule), :885-907 (read_one_sheet, the docstring's promise at :892-894, the one call at :899-900 and the clause at :901), :910-952 (read_uploaded_sheets, its docstring's per-sheet promise at :922-926, the loop at :937-951 and the store clause at :943-950), :955-960 (_swallowed), :1005-1034 (prepare_cast and the three clauses S48 left, the second at :1023-1026), :1112-1144 (the synchronous re-read and its comment at :1131-1134), :1200-1229 (_run_cast_stage, the record it writes at :1216-1221 and its own broad clause at :1222-1227), :1232-1272 (_in_background, start_cast_preparation and start_reading_sheets) and :3038-3085 (_variant_prompts, the typed re-raise at :3063-3064). apps/api/app/pipeline/storyboard_images.py:118-155 (_character_model_call, the two typed re-raises at :143-150 that put both classes at this clause), :661-675 (failed_read_block, the stored shape) and the describe_supplied_character chain that reaches the boundary. apps/api/app/pipeline/utils/provider_key.py:52-95 (ModelKeyRefused, its detail and why it is deliberately not one of model_gateway.GATEWAY_ERRORS) and :98-109 (the three sentences). apps/api/app/backend/services/model_gateway.py:260-265 (GATEWAY_ERRORS, the one list). apps/api/app/backend/main.py:310-319 (how each gateway refusal is answered at a route) and :344-353 (how a credential refusal is answered). apps/api/app/backend/routes/video_storyboard.py:726-770 (the scenes poll and the four audit keys it serves, cast_run among them since S48) and the re-read route that reaches :1136. apps/api/app/backend/routes/video_storyboard.py:181-188 (_guard, which maps only StoryboardError to 400 and lets every other exception through) and :1218-1235 (reread_sheet, the re-read route, which calls reread_sheet through _guard). apps/web/src/components/video-storyboard/CharacterPanel.tsx:1935-1946 (the panel's reread, whose catch draws the error's message with onError) and apps/web/src/lib/api.ts:471-496 (ApiError and apiError, which make a string detail the message). apps/api/app/backend/tests/test_character_sheets_flow.py:1340-1397 (_Pass, the in-memory pass every cast test reuses), :1400-1500 (the seven tests that press read_uploaded_sheets directly, at :1407, :1420, :1435, :1448, :1462, :1478 and :1496, among them test_one_unreadable_sheet_does_not_cost_the_others_theirs at :1441, test_a_sheet_that_cannot_be_read_is_recorded_not_dropped at :1455 and test_reading_never_raises_out_of_the_pass at :1471), :1501-1520 (test_the_pages_are_read_BEFORE_the_roster_is_paired), :1560-1640 (the S48 tests that pin errors for the read_uploaded_sheets step, at :1580 and :1631), :1740-1760 (the append route's own pass at :1749), :1811-1832 (test_a_chain_that_swallowed_a_failure_is_recorded_as_failed) and :3078 (the pass inside test_an_unreadable_instruction_does_not_take_the_failure_path_with_it). apps/api/app/backend/tests/test_storyboard_image_runner_gateway_refusals.py:1-75 (the module's account and the REFUSALS table every parametrised pin runs over). docs/ops/s48-acceptance.md, whole, and its "Found and not fixed here" section, which files this clause as this item. Open item #170. Every number in this card is the one measured at de99c60; the table below gives each one's place on main f79b410, found by content, and the builder's first deliverable is the same table re-found by content on the main the branch starts from.
| Anchor, by content | de99c60 | f79b410 | Moved |
|---|---|---|---|
runner imports; the new import goes beside the app.pipeline import | storyboard_image_runner.py:14-24, :22-24 | :14-24, :22-24 | no |
PRESS_REFUSALS and its comment | :27-34 (comment :27-33) | :27-34 | no |
ensure_cast; typed re-raise; narrowed degrade | :144-236; :197-200; :201-214 | same | no |
match_uploaded_sheets; typed re-raise; narrowed degrade | :332-435; :409-410; :411-426 | same | no |
sheet_pool and its append-only rule | :480-510 (:480-500) | same | no |
| the panel's stale-reading offer | :872-882 | same | no |
read_one_sheet; docstring promise; the one call; the broad clause; its warning; its return | :885-907; :892-894; :899-900; :901; :902-904; :905 | same | no |
read_uploaded_sheets; per-sheet docstring; the loop; the skip of any stored reading; the call; the store clause | :910-952; :922-926; :937-951; :938-940; :942; :943-950 (except at :945) | same | no |
_swallowed | :955-960 | same | no |
prepare_cast; the reading step's clause; its one warning | :1005-1034; :1021-1026 (except at :1023); :1024-1025 | same | no |
reread_sheet; its comment; the call; the failed-not-applied rule | :1112-1144; :1131-1134; :1136; :1142-1143 | same | no |
_run_cast_stage; the record; its broad clause | :1200-1229; :1216-1221; :1222-1227 | same | no |
_in_background, start_cast_preparation, start_reading_sheets | :1232-1272 (except :1249, start_reading_sheets :1264-1272) | same | no |
_variant_prompts whole | :3038-3085 | :3055-3102 | +17 |
its try and typed re-raise | :3061-3064 (re-raise :3063-3064) | :3078-3081 (re-raise :3080-3081) | +17 |
| its narrowed degrade | :3065-3080 | :3082-3097 | +17 |
broad clauses left in place, above S49's first change at :1823 | :68, :101, :786, :1088, :1172, :1656, :1770, :1997 | same | no |
| broad clauses left in place, below it | :2093, :2170, :2804, :3140, :3206, :3501, :3622 | :2098, :2175, :2821, :3157, :3223, :3518, :3639 | +5, +5, then +17 |
_character_model_call; the two re-raises; the converter; failed_read_block; its stored error | storyboard_images.py:118-155 (:140-155); :143-144, :145-150; :151-155; :661-675; :673-675 | same | no |
ModelKeyRefused; detail passed to super().__init__; the three sentences; the missing-key one | provider_key.py:52-95; :94-95; :98-109; :103-106 | same | no |
GATEWAY_ERRORS | model_gateway.py:260-265 | same | no |
| gateway handlers; credential handler | main.py:310-319 (:315-319); :344-353 (:351-353) | same | no |
_guard; scenes poll and cast_run; re-read route; the two presses' pre-flight docstrings | routes/video_storyboard.py:181-188; :726-770, :768; :1218-1235; :2852, :2896 (first cited as :2843, :2887, which are 0a09b90 numbers) | same | no |
| the board's one line per step that did not run | VideoStoryboardScreen.tsx:990 | same | no |
the panel's reread; the error wrapper | CharacterPanel.tsx:1935-1946; api.ts:471-496 | same | no |
_Pass; the seven direct calls; pages-before-roster; S48 errors pins; append pass; S48 cast-stage pin | test_character_sheets_flow.py:1340-1397; :1407…:1496; :1501-1520; :1580, :1631; :1749; :1811-1832 | same | no |
the pass inside test_an_unreadable_instruction_does_not_take_the_failure_path_with_it | test_character_sheets_flow.py:3078 (test at :3067) | :3095 (test at :3084) | +17 |
REFUSALS; the boundary's seven-type pin; the missing-key sentence pin | test_storyboard_image_runner_gateway_refusals.py:1-75; test_character_model_failures.py:292-297; :220-223 | same | no |
| the secret-scan rule | AGENTS.md:39 | same | no |
Everything this card changes sits at :14-24 and :885-952 of the runner, above the first line S49 changed (:1823), so none of its own anchors moved; the twelve that moved are the ones it only cites, in _variant_prompts, among the broad clauses it leaves alone and in one test it does not touch.
Deliverables. (1) read_one_sheet re-raises by type the refusals that are not about the page, and keeps its broad clause for the ones that are. Two clauses go above the existing one at :901, in the order and the shape the boundary one level down already uses (storyboard_images.py:143-150): except model_gateway.GATEWAY_ERRORS: raise and except provider_key.ModelKeyRefused: raise. The runner gains one import, from app.pipeline.utils import provider_key, which adds no new dependency direction because the runner already imports the package that owns it. The card does not use the name PRESS_REFUSALS and does not widen it, for two reasons that are worth keeping: that name's comment (:27-33) says what stops a press rather than a scene, and a sheet reading pass is not a press; and the two classes this clause has to let through are exactly the two the character model boundary underneath it lets through, so writing them the same way means a reader checks one rule in one place. This is decided: the card writes the two clauses and leaves PRESS_REFUSALS as it is, and whether the two ways of writing one set become one name later is for S55 to consolidate, not for this card. (2) The broad clause keeps everything it was written for and says so. Below the two new clauses, except Exception as exc: is unchanged: a file that is not an image, an object storage key that is gone, a decode failure, an answer the read could not parse, all still print at :902-904 and return failed_read_block, and the pass still loses one sheet rather than the other three. The docstring's bold promise at :892-894 is corrected in place to say which failures are recorded and which are raised, because the promise as written is now false and it is the sentence a reader of this function is steered to. The comment above the synchronous re-read at :1131-1134 gains the same correction for the same reason. (3) read_uploaded_sheets lets the refusal through and nothing in it changes. The loop at :937-951 has no clause around read_one_sheet, so a refusal leaves the function on the sheet it was met on, having read the ones before it and not called the model for the ones after it. The per-sheet store clause at :943-950 is untouched, because it wraps svc.record_sheet_read alone and a database write failing is one reading lost and not a pass stopped. Its docstring's per-sheet promise at :922-926 gains one sentence naming the exception, in the same words as read_one_sheet's. (4) Nothing above it changes, because S48 already built the places a refusal lands. At create, prepare_cast:1023-1026 records the escape as one errors entry with step read_uploaded_sheets, the refusal's sentence and its class, prints once at :1024-1025, and falls through to the pairing exactly as it does for every other failure of that step. _run_cast_stage:1216-1221 writes cast_run in the existing failed state with that sentence and class. The scenes poll serves it and the screen draws it. On the append path, _run_cast_stage's own broad clause at :1222-1227 writes the same two fields. So the sentence an operator reads for a paused guard becomes one line on the board naming the step and the guard, and for a missing credential one line naming the binding and the key source, in both cases once for the whole pass. No route, no service, no client type and no web file changes. (5) The synchronous re-read answers the refusal instead of storing it. :1136 now raises for the two classes, main.py:315-319 and :351-353 answer them with their own statuses and sentences, and the route is not touched. This is decided: the refusal propagates synchronously. The route (routes/video_storyboard.py:1218-1235) calls through _guard (:181-188), which maps only StoryboardError and does not swallow either class; the app's handlers answer a paused guard 402 and a missing credential 503 with the refusal's own sentence as detail; and the panel's reread (CharacterPanel.tsx:1935-1946) already catches a failed call and draws its message with onError, which apiError (api.ts:471-496) sets to that detail. No web file changes. A reading that failed for the page's own reason still returns the stored block and still refuses to be applied to anybody (:1142-1143). (6) docs/ops/s54-acceptance.md: the clause with the line it was on in the item and the line it is on now; the seven classes that reach it and which two are now raised; the printed stored block and the printed log line for a paused guard and for a missing credential, before and after; the printed cast_run record after a refusal met on the second sheet of four, beside the record the same pool writes when one file is genuinely unreadable; the measured count of model round trips, printed warnings and stored failed readings for a pool of four and a pool of forty, before and after; and what the synchronous re-read answers in each of the two cases.
Interfaces. read_one_sheet keeps its name, its signature and its return shape. Its stance changes on purpose and is the whole card: it raises two classes it used to record, and records everything else exactly as it does today. read_uploaded_sheets keeps its name, its signature and its return shape, which is still {"read": int, "failed": int, "skipped": int}; it gains one behaviour, which is that a refusal that applies to every sheet leaves it instead of being counted into failed once per sheet. Both of its callers already hold a clause for that: prepare_cast:1023 records it as the read_uploaded_sheets step and continues, and _run_cast_stage:1222 records it as the stage's own failure, so prepare_cast's return shape, the errors key S48 added and the cast_run record are all unchanged for this card. sheet_pool, record_sheet_read, failed_read_block, _reapply_sheet and _pool_entry_or_error are not changed at all. model_gateway.GATEWAY_ERRORS is not widened and PRESS_REFUSALS is not read, written or renamed, so this card shares no line of the file with a card that changes either. The synchronous re-read route gains one answer it could not give before, from handlers that already exist and are not touched. There is no migration, no new module-level name, no new audit key and no new stage state. Three behaviours change on purpose. A refusal that applies to the whole pool is told once, on the board, naming the refusal, instead of once per sheet as a page nobody could read. A pool of N unread sheets costs one model round trip on a refused workspace instead of N. And a sheet that was never read is left with no reading at all rather than with a stored failed reading whose error is a sentence about the workspace, so the pass that runs after the guard is lifted reads it rather than skipping it.
Acceptance. The api tier green as AGENTS.md sets it out, from apps/api, with DESELECT=$(grep -vE '^\s*(#|$)' ci-known-failures.txt | sed 's/^/--deselect /' | tr '\n' ' ') and .venv/bin/python -m pytest app/backend/tests -q -p no:cacheprovider $DESELECT, the targeted files run locally with the venv's own interpreter and pytest's own exit code read from the command. The targeted api run is .venv/bin/python -m pytest app/backend/tests/test_character_sheets_flow.py app/backend/tests/test_storyboard_image_runner_gateway_refusals.py -q -p no:cacheprovider. No web tier: no file under apps/web changes. No heavy leg: every test this card adds is in memory on the _Pass stand-in and none starts a container. .venv/bin/python -m py_compile clean over the one changed Python file. Tests, each named by what it proves. test_read_one_sheet_raises_a_refusal_instead_of_returning_a_failed_reading, RED ON THE BASE, parametrised over the six classes of model_gateway.GATEWAY_ERRORS and provider_key.ModelKeyRefused, seven cases built the way PASS_THROUGH builds them in test_character_model_failures.py, in test_character_sheets_flow.py: read_one_sheet raises the object it was handed and prints nothing, where the base returns a block with status == "failed" carrying its sentence and prints the fill-it-in-by-hand warning. test_a_refusal_on_the_second_sheet_stops_the_pass_and_keeps_the_first_reading, RED ON THE BASE, in test_character_sheets_flow.py: this is the counting test for the loop itself, driven directly on read_uploaded_sheets over _Pass with a pool of four unread sheets and a stand-in that answers the first page with a reading and raises model_gateway.SpendPaused("Spending is paused for this workspace.") on every later call; it asserts that the call raises that same object, that the model was called exactly twice (session.calls == [b"a.png", b"b.png"]), that the first sheet's stored reading is kept with status == "ok", that the second, third and fourth sheets carry no read at all, and that the pass printed no [storyboard][warn] line, where the base does not raise, makes four calls, keeps the first reading, stores three failed readings, prints three warnings and returns {"read": 1, "failed": 3, "skipped": 0}. test_a_refused_reading_pass_is_told_once_by_the_cast_chain, RED ON THE BASE, in test_character_sheets_flow.py: prepare_cast over a pool of four unread sheets with a stand-in that refuses every call, and with ensure_cast and apply_matched_sheets replaced by plain returns as the S48 tests beside it do, so that only the reading step can fail; it asserts one model call, exactly one printed [storyboard][warn] line (the one at :1024-1025), exactly one errors entry with step read_uploaded_sheets, the refusal's sentence and error_kind == "SpendPaused", no stored reading on any of the four sheets, and (done["read"], done["failed"]) == (0, 0), where the base makes four calls, prints four warnings from read_one_sheet, stores four failed readings, records no errors entry and returns failed: 4. test_the_append_path_records_a_refused_reading_pass_as_the_stage_failing, RED ON THE BASE, in test_character_sheets_flow.py: _run_cast_stage over read_uploaded_sheets alone writes cast_run in the failed state with the refusal's sentence and class, where the base writes it complete with failed: 4. test_the_re_read_button_raises_a_refusal_instead_of_storing_it, RED ON THE BASE, in test_character_sheets_flow.py: the synchronous re-read raises the refusal and stores nothing, where the base stores a failed reading and returns it; the case for a credential refusal also asserts the sentence carries no part of a key, with the key minted per run. One new test labelled GUARD, green on the base and green on the branch, because no test pins the store clause today (no test in test_character_sheets_flow.py or elsewhere makes record_sheet_read raise): test_a_reading_that_could_not_be_stored_loses_that_sheet_and_keeps_the_others, in test_character_sheets_flow.py, over _Pass with a pool of three unread sheets whose second store raises psycopg2.OperationalError; it asserts the pass does not raise, the first and third sheets are stored with status == "ok", the second carries no read, one could not store warning is printed and the counts are {"read": 2, "failed": 0, "skipped": 0}, which is what :943-950 promises and what the card must leave standing. Existing tests labelled GUARD, green on the base and green on the branch, none changed: test_a_sheet_that_cannot_be_read_is_recorded_not_dropped (:1455), which is the isolation the stage is built on and is driven by a download failure (RuntimeError("not an image")) and not by a refusal; test_one_unreadable_sheet_does_not_cost_the_others_theirs (:1441) and test_reading_never_raises_out_of_the_pass (:1471), both driven by a RuntimeError, which the broad clause still records; test_the_pages_are_read_BEFORE_the_roster_is_paired (test_character_sheets_flow.py:1501-1520); and both parametrised gateway pins with the narrowing pins beside them in test_storyboard_image_runner_gateway_refusals.py, which hold S41's and S45's stages to the types they catch and which this card does not touch. S48 tests that must stay green unchanged, named: test_a_step_that_dies_leaves_the_ones_before_it_standing, test_a_session_with_no_sheets_is_left_to_read_its_roster_lazily, test_an_unreadable_pool_still_reaches_the_roster, test_a_pool_that_could_not_be_read_is_told_apart_from_a_session_with_none, test_a_sheet_read_that_died_is_recorded_and_the_roster_is_still_reached, test_a_refusal_at_the_pairing_is_recorded_with_the_class_that_refused and test_a_chain_that_swallowed_a_failure_is_recorded_as_failed, all in test_character_sheets_flow.py. No existing test is expected to change and none is deleted; if the build finds one that must, it is named in the record with the assertion it keeps and the reason. No comment the card adds names a card, an item or a phase. bash infra/secret-scan/scan-tree.sh clean, which is the one command AGENTS.md:39 mandates and which runs both halves over one private copy of the set a commit can carry, and python3 infra/scripts/test-task-refs.py then python3 infra/scripts/task-refs.py check . clean. Record docs/ops/s54-acceptance.md, which also states that a sheet whose failed reading was stored under a refusal before this card is still a stored dict and so is still skipped by every later pass (storyboard_image_runner.py:937-940), and stays as it is until the operator presses the panel's existing re-read for it; the card changes no stored row. Open item #170 closed.
Out of scope. Making a refusal rarer rather than told once. A synchronous pre-flight before the reading pass, of the kind both presses hold (routes/video_storyboard.py:2852 and :2896), would turn most refused passes into a refusal before any sheet is read, and it loses here for two reasons: it does not fix the case the item is about, because a guard paused after the pass started still arrives mid-loop and still has to be told once, so the card would ship a second mechanism and still need this one; and a pre-flight is a decision about what a create request costs before it answers, which belongs to whoever owns that request's latency and not to a clause that files a refusal. The same argument covers caching the guard's state across the loop. Widening model_gateway.GATEWAY_ERRORS to hold ModelKeyRefused, which loses because that tuple is the gateway's own, is consumed at more than twenty sites, three of them in other packages, and because the app answers the two refusals with different statuses on purpose (main.py:310-319 against :351-353). Widening or renaming PRESS_REFUSALS, which is S55's question: this card writes two clauses that mirror the boundary below it and reads that name not at all, so the two cards share no line. The per-sheet store clause at :945, which wraps one database write and is the isolation the stage exists on; the item asks for it to be left alone and the measurement agrees. What a stored failed reading means to the picker, the dropdown and the panel's stale-reading offer (:872-882), which are unchanged because the card stores no new kind of block. Re-reading a sheet whose reading was stored while a guard was paused, which is a backfill question about rows that already exist and which the panel's own re-read control already answers one sheet at a time. The other broad clauses in the file that S48 listed and left, each of which keeps its reason: :1088 in apply_matched_sheets, which loses one member's build and keeps the rest; :1222 in _run_cast_stage and :1249 in _in_background, which are the outermost clauses that write the record this card feeds and must stay broad for exactly that reason; and :68, :101, :786, :1172, :1656, :1770, :1997, :2093, :2170, :2804, :3140, :3206, :3501 and :3622, which are the same clauses S48's list named at their 0a09b90 lines, re-found by content at de99c60. A scene that failed for its own reason keeping the generic row sentence, which S48 recorded and left and which no line of this card reaches.
Waits on / Unblocks. Waits on S48, which is merged (PR #154) and is the whole of what opens this gate: before it, a refusal that escaped read_uploaded_sheets would have been recorded into a cast_run record that read complete with zero counts, so the card would have had to build the place its own answer is read as well as the answer. It does not, because S48 built it. This card was measured on that main, de99c60, and starts from main f79b410. Three merges landed between the two. S49 is merged (PR #155, 442bf73): it changed storyboard_image_runner.py only from :1823 down (_render_character_sheet, refine_character, redraw_character, twenty four lines added and seven removed) and changed test_character_sheets_flow.py only from :2157 down (the _Refine helper, then two helpers and ten tests added after :3304), so every line this card changes (:14-24 and :885-952 of the runner) and every test region it extends (:1340-1520 and :1560-1832 of the test file) is unmoved; the two files are shared and no range meets, and the anchor table above gives the twelve cited lines that did move. V56 is merged (810b27f) and A52 is merged (f79b410); neither touches a file this card changes or cites. No open pull request touches this card's files: PR #137, PR #136 and PR #118 are services/scraper, db/migrations and apps/api/app/backend/tests/core and none of them names storyboard_image_runner.py or test_character_sheets_flow.py. The queue's merge order is S54, S50, S51, S52, S55, S53, so this card merges first and none of the others lands under it. S50, S51, S52 and S53 are approved cards in the spec and each waits on nothing. S50, S51 and S52 each change storyboard_image_runner.py, all of them at or below :2789 at de99c60: S50 the two failure entries at :3288-3290 and :3607-3610 and a helper beside _release_claim (:3150-3167); S51 a new function replacing fail_claims at :3144-3147; S52 _finalise (:2789-2812), _fail_claims and fail_claims (:3123-3141) and the could-not-start clause (:3206-3210). None of their ranges meets this card's :14-24 or :885-952, and none of the three changes test_character_sheets_flow.py (S51's spec names it only as a file it does not open; their tests go to test_video_storyboard_e2e.py, test_storyboard_batch_record.py and their own files). S53 changes neither file: its spec names both only as files S49 edited. S55, still a draft, is the one card that meets this one textually: it rewrites PRESS_REFUSALS at :34 and one clause in _variant_prompts (:3064-3065 at de99c60, :3081-3082 at f79b410), and it adds the same import, from app.pipeline.utils import provider_key, that this card adds at :22-24. That import line is the one shared line; because this card merges first, S55 rebases onto a runner that already carries the import and does not add it a second time. Each card that merges after this one on a shared file is re-tested on its merge commit. Unblocks a reading pass that can be told apart from a page nobody can read, which is what any later work on the sheet pool needs before it can say whether a reading is missing because the page is bad or because the workspace was refused; and it removes the last of the four sibling stages that disagreed about a refusal, so a reader checking the rule in this file now finds the same answer four times.
S55 · A credential refusal stops a press the way the gateway's refusal does · S · M · S2-W2 · fix/s/s55-a-credential-refusal-stops-a-press (surfaced while building S48, pre-existing on main, open item #173; carded 2026-09-22)
Context. Measured at main de99c60, which is S48 merged (PR #154), and every anchor below keeps that measured number; the table under Measurements maps each one by content to main f79b410, where the runner anchors below :2455 sit seventeen lines lower and the e2e test anchors from :913 down sit thirty-nine lines lower, both moved by S49, and every other file this card cites is byte-identical. Every line the item quotes outside storyboard_image_runner.py is still exact at de99c60, because S48 changed none of the four other files the item names; the one runner anchor it quotes, :3016-3019, is now :3061-3064, and the measured number is used below. The route anchors are this card's own and are measured at de99c60, where S48 moved them nine lines down. S48 stops a whole board press on the gateway's own refusals, through one module-level name, PRESS_REFUSALS = model_gateway.GATEWAY_ERRORS (apps/api/app/backend/services/storyboard_image_runner.py:34), read at :3267 in the single-scene wrapper and :3581 in the board loop. It originally named provider_key.ModelKeyRefused beside them and took it out, with the reason written down (docs/ops/s48-acceptance.md): that class cannot reach either clause, so a clause for it would have been dead code. The measurement confirms that, on both of the two paths a press takes. On the frame path nothing is raised at all. openrouter_client.generate_image_with_retry catches the refusal before any retry (apps/api/app/pipeline/utils/openrouter_client.py:340-348) and returns None, {KEY_REFUSED: refusal.detail}; storyboard_images.generate_one sees image is None (:244), reads the sentence out of the usage (:249) and returns one ImageResult(status="refused") carrying it (:250-255); and the fan-out that collects the candidates re-raises the gateway's own tuple alone (:1101-1108) and turns anything else into a failed candidate (:1109-1113), so a value that was never an exception cannot reach it. On the variation path it is converted. storyboard_llm._call (:368-411) re-raises model_gateway.GATEWAY_ERRORS by type (:403-404) and turns provider_key.ModelKeyRefused into a StoryboardLLMError carrying _key_refusal_sentence(exc) (:405-406, the sentence at :414-429), and _variant_prompts's narrowed degrade absorbs that type (storyboard_image_runner.py:3065-3080), returns the scene's own description repeated once per candidate and a warning saying the shot could not be varied. So what an operator whose workspace has lost a credential is answered with, counted for a three-scene board: three degradation warnings saying the frames will look alike, nine version rows written refused carrying the refusal's sentence, a batch summary of {"ready": 0, "refused": 9, "blocked": 0, "failed": 0} with three scene entries, and no stopped field at all, because nothing was raised. That is a board full of stills that read as the model having tried and failed, once per candidate, which is the same account the comment at storyboard_images.py:1102-1107 rejects in so many words for a paused spend guard. The route's synchronous pre-flight does not prevent it: model_gateway.check reads the spend guard and nothing else (apps/api/app/backend/services/model_gateway.py:161-171), and the credential is checked inside the transport by provider_key.require_sendable (apps/api/app/pipeline/utils/provider_key.py:112-139), so a keyless workspace passes the pre-flight at routes/video_storyboard.py:2852 and :2896 and reaches the press. One level up, the app already disagrees with both paths, and this is the evidence for what the right answer is. storyboard_images._character_model_call (:123-155) is the boundary every character stage reaches the text client through, and it re-raises model_gateway.GATEWAY_ERRORS (:143-144) and provider_key.ModelKeyRefused (:145-150) by type, side by side, with a comment saying the second is answered by the app as its own 503. It is, at main.py:351-353, registered beside the gateway's own handlers (:310-319) and for the reason written at :344-350. That pairing is pinned by a test whose whole job is to fail when the set drifts: test_character_model_failures.py:292-297 asserts the boundary's pass-through set is exactly set(model_gateway.GATEWAY_ERRORS) | {provider_key.ModelKeyRefused}. The class's own docstring is the one voice on the other side, and it is stale (provider_key.py:65-73): it says the class is deliberately not one of GATEWAY_ERRORS because the helpers that degrade around a failed model call re-raise those, "and a workspace with no key is a failed call to degrade around — as it was when it surfaced as a protocol error" (:67-70). Both paths below the boundary do what that sentence says, and the boundary above them and the app's own handler already do the opposite, so the file holds two answers to one question and this card settles it for the boundary's answer and corrects the sentence. Why neither half of the repair is dead code, which is the objection S48's own review raised and the one this card has to answer. The two paths run under different bindings: the variation call runs under production.storyboard.llm (storyboard_llm.py:388, the same binding the character boundary names at storyboard_images.py:142 and whose sentence is pinned at test_character_model_failures.py:220-223) and the frame call under production.image (the sentence pinned at test_provider_key_refusal.py:256). Different workflow keys alone do not make the two credentials independent: a key is chosen per resolved provider (model_gateway.py:127-158, :274-289), the seed binds both keys to the one OpenRouter provider (app/core/bindings/seed.py:102, :105), and both transports use the same fallback, so on the seed a keyless workspace is refused at the variation first, on the first scene, before any image call. A text call works beside an image call refused for its key only when the two calls resolve to different providers, because an admin bound one of the two keys to another provider (the press never names an image model, so its image call always resolves through the production.image binding), and that provider holds no usable key while the fallback is empty; that is the case the item describes, it is refused at the frames after _variant_prompts succeeds, and the section "Where a text credential and an image credential part" under Measurements measures it and names the test that pins it. Each half is the only thing that stops a press in one of the two cases. The contract the item warns about, measured rather than estimated, is in the section below and is what makes this card M rather than S.
Read first. apps/api/app/pipeline/utils/openrouter_client.py:52 (KEY_REFUSED), :281-348 (generate_image_with_retry, the docstring's account of the answer at :291 and :311-315, the gateway re-raise at :335-339 and the refusal branch at :340-348). apps/api/app/pipeline/storyboard_images.py:42-46 (the imports), :68 (CANDIDATES_PER_SCENE = 3), :80 (SILENT_REFUSAL_RETRIES = 3), :123-155 (_character_model_call and its two typed re-raises at :143-150, the shape this card copies), :158-171 (ImageResult and its declared status taxonomy at :162), :210-269 (generate_one, the blocked branch at :240-242, the refused branch at :244-255 and the ready branch at :256-269), :1015-1033 (generate_character_sheet, which reaches generate_one and must keep its answer), :1063-1114 (generate_candidates, the fan-out, its docstring's one-exception rule at :1081-1086, the collect loop at :1098-1113 and the typed re-raise at :1101-1108), :1117-1128 (_candidate_failure) and :1139-1160 (tweak and rework, the other two callers of generate_one). apps/api/app/pipeline/storyboard_llm.py:143-189 (StoryboardLLMError, what attempts means and why the gateway's refusals are never converted), :368-411 (_call, the one boundary every stage reaches the client through, its binding at :388, the gateway re-raise at :403-404 and the conversion at :405-406), :414-429 (_key_refusal_sentence, the redaction sentence), :489-511 (split_script_llm, whose caller's fallback promise constrains what may change at :406) and :997-1056 (generate_shot_variants). apps/api/app/pipeline/utils/provider_key.py:46-49 (UNSENDABLE_WHY), :52-95 (ModelKeyRefused, its status, its detail, and the paragraph at :65-73 saying why it is not in GATEWAY_ERRORS, whose sentence at :67-70 this card corrects), :98-109 (_sentence, the three forms) and :112-139 (require_sendable, and that the key it is handed is the one credential_or chose for the resolved provider). apps/api/app/pipeline/steps/step3_imagegen.py:77-120 (a run's image step, which calls the transport directly and must keep degrading). apps/api/app/backend/services/storyboard_image_runner.py:22-24 (the pipeline import), :27-34 (PRESS_REFUSALS and its comment), :3038-3085 (_variant_prompts, the typed re-raise at :3063-3064 and the narrowed degrade at :3065-3080), :3170-3224 (generate_candidates, the clause at :3206-3210 that fails the claims and re-raises, and the summary at :3217-3224), :3227-3329 (generate_candidates_with_record, S48's stop clause at :3267-3280 and the accumulation at :3320), :3526-3624 (generate_all_candidates, S48's stop clause at :3581 and the accumulation at :3612), :2735-2750 (_record) and :2789-2812 (_finalise, which reads named attributes and never serialises the result). apps/api/app/backend/services/model_gateway.py:161-171 (check, which reads the spend guard alone) and :260-265 (GATEWAY_ERRORS). apps/api/app/backend/main.py:310-319 and :344-353 (the two handlers). apps/api/app/backend/routes/video_storyboard.py:2820-2871 and :2873-2932 (the two presses and their pre-flights at :2852 and :2896). The five test files that hold the contract: apps/api/app/backend/tests/test_image_key_refusal_callers.py, whole; apps/api/app/backend/tests/test_provider_key_refusal.py:238-272; apps/api/app/backend/tests/test_storyboard_candidate_failures.py:47-68, :70-122, :170-214; apps/api/app/backend/tests/test_video_storyboard_split_boundary.py:111-158 and :279-309; apps/api/app/backend/tests/test_character_model_failures.py:227-297. Also apps/api/app/backend/tests/test_video_storyboard_m2.py:330-423, apps/api/app/backend/tests/test_video_storyboard_e2e.py:932-1130, apps/api/app/backend/tests/test_storyboard_batch_record.py (S48's two guards) and apps/api/app/backend/tests/test_storyboard_spend_ledger.py:487-503 (the one test that drives _variant_prompts directly). The credential path, to see where a text key and an image key part: apps/api/app/backend/services/model_gateway.py:99-101 (Resolved.api_key), :127-158 (resolve, and the explicit model's provider credential at :136-140) and :274-289 (credential_or); apps/api/app/core/bindings/store.py:155 and :167-172 (a credential is stored per provider); apps/api/app/core/bindings/seed.py:102 and :105 (both workflow keys on OpenRouter); apps/api/app/pipeline/utils/claude_client.py:117 and openrouter_client.py:103 (the same fallback). apps/api/app/backend/tests/test_video_storyboard_e2e.py:506 and :513-517 (the world fixture's image seam and variation stub). docs/ops/s48-acceptance.md, whole. Open item #173.
The contract, measured. Every caller of the refused image result and every test that asserts its shape, found by grep -rn 'generate_image_with_retry\|generate_one\|ImageResult\|KEY_REFUSED\|ModelKeyRefused' --include=*.py app/ and read.
| Reader | Where | What it depends on | After this card |
|---|---|---|---|
| a run's image step | steps/step3_imagegen.py:84-92 | calls the transport directly; reads image_bytes and never the usage | unchanged |
| the character sheet render | storyboard_images.py:1015-1033 through generate_one | a refused result, so the claimed row ends refused | unchanged |
| a tweak and a rework | storyboard_images.py:1139-1160 through generate_one | the same | unchanged |
| the frame fan-out | storyboard_images.py:1063-1114 | the refused result as a value | raises the refusal instead |
| the press | storyboard_image_runner.py:3204-3224 | the fan-out returning | the clause at :3206-3210 fails the claims and re-raises, unchanged code |
| the version row writer | storyboard_image_runner.py:2789-2812 | status, image, cost_usd, provider_message, prompt, model, by name | unchanged; no serialisation, so a new field reaches nothing |
| Test file | What it asserts about the refused shape | After this card |
|---|---|---|
test_image_key_refusal_callers.py | test_a_run_image_step_marks_each_concept_failed_and_moves_on, two cases (:77-94): the run marks each concept failed, nothing is posted, one cooldown sleep and no backoff. test_a_keyless_sheet_ends_refused_and_leaves_no_claim, three cases (:178-204): the row ends refused, provider_message starts with the refusal's sentence, no claim is left, the character keeps its sheet, and the next press after a key is set draws. test_an_unsendable_key_ends_the_sheet_refused_without_repeating_the_key (:207-219). test_the_redraw_route_answers_both_keyless_presses_and_leaves_no_claim (:222-244): two 200s. | all seven cases green, unchanged. This is the contract the item warns about and this card does not move it: nothing here goes through the fan-out. Its module docstring at :3-5 is corrected, because it says the helper answers (None, {}) and it has never done that. |
test_provider_key_refusal.py | test_the_image_retry_loop_answers_a_refusal_as_a_failed_generation_at_once, two cases (:238-272): image is None, list(usage) == [KEY_REFUSED], usage[KEY_REFUSED].startswith("No production.image call was sent: "), the key is not in it, nothing slept, nothing posted, and a gateway refusal is still raised unretried. | this test changes, named and justified below. The usage still holds exactly one key; the two assertions at :256-257 read .detail on it. |
test_storyboard_candidate_failures.py | test_a_key_refusal_says_a_key_has_to_be_set_instead, two cases (:182-199): generate_one returns refused with the refusal's sentence and no cost, driven over the real client. test_the_documented_status_set_is_what_the_module_writes (:204-214): the declared taxonomy equals the set of status= literals in the module. test_a_gateway_refusal_stops_the_batch_instead_of_labelling_candidates, five cases (:58-67). test_a_refused_batch_leaves_no_claim_behind_and_no_candidate_row, five cases (:107-121). | all green, unchanged. generate_one keeps returning a refused result and the status literals do not change, so the taxonomy test stays true. |
test_video_storyboard_split_boundary.py | test_every_stage_turns_a_keyless_workspace_into_the_sentence_and_sends_nothing, eight cases (:129-143): every stage raises StoryboardLLMError with the exact key sentence and __cause__ is a ModelKeyRefused. test_every_stage_refuses_an_unsendable_key_without_repeating_it, eight cases (:146-158). | all sixteen cases green, unchanged, because the card raises a subclass of StoryboardLLMError from the same line with the same sentence and the same from exc. One other test in this file changes: test_every_storyboard_model_call_passes_the_one_boundary (:279-309) reads _call's handlers by syntax and asserts at :309 that every raise after the first handler names StoryboardLLMError, so the raise this card writes at storyboard_llm.py:406 fails it as written. |
test_character_model_failures.py | test_a_stage_hands_back_the_refusal_its_client_raised and test_the_boundary_re_raises_the_refusal_unchanged over seven refusal types (:250-289), and test_every_type_the_boundary_lets_through_is_covered_above (:292-297). | all green, unchanged. _character_model_call is not touched. |
test_video_storyboard_m2.py | test_a_silent_refusal_surfaces_as_refused_not_a_generic_error (:332-342), test_a_silent_refusal_is_retried_three_times_before_it_is_surfaced (:345-386), test_one_refused_candidate_does_not_fail_the_others (:417-422). | all green, unchanged. A silent refusal is a 200 with no image and still returns (None, {}), which carries no KEY_REFUSED key at all. |
test_video_storyboard_e2e.py | the character sheet fakes at :1069, :1094, :1111 stub the transport to (None, {}), which is a silent refusal and not a key refusal. | green, unchanged. |
So the refused image-result contract moves in exactly one place, and two existing tests change, one on each path: the transport test that asserts the value's type, and the syntax pin that names the class _call raises.
Deliverables. (1) A press stops on a credential refusal, the way it stops on a gateway refusal, and the recommendation is yes. The reasons are three, each measured: the boundary one level up already lets the class through by type beside the gateway's own and is pinned to do so (storyboard_images.py:143-150, test_character_model_failures.py:292-297); the app answers it as its own refusal with its own status (main.py:351-353); and the only statement of the opposite stance is one sentence of the class's own docstring (provider_key.py:67-70), written when the refusal still surfaced as a protocol error, which says a workspace with no key is a failed call to degrade around and which the boundary and the handler above it already contradict. That sentence is corrected in place: the paragraph at :65-73 keeps its first reason (not a gateway refusal, a different status) and says instead that a whole-unit caller stops on the class by type while a per-call caller may still answer it as a failed generation; this makes provider_key.py a changed file, docstring only. (2) The frame path carries the refusal instead of only its sentence. openrouter_client.py:348 returns None, {KEY_REFUSED: refusal}, the refusal itself rather than refusal.detail. The usage still holds exactly one key and still costs nothing. The docstring at :289-297 and :311-315 is corrected to say the refusal object rides the usage and its detail is the sentence, and the print at :347 is unchanged. Nothing outside storyboard_images.py reads that key: grep -rn 'KEY_REFUSED' --include=*.py . answers six product lines, all in these two files, and three test lines, all in one test. (3) generate_one keeps its answer and carries the refusal beside it. ImageResult gains one optional field, refusal: BaseException | None = None, default None, set only on the branch at :250-255 and from nowhere else; the provider_message is refusal.detail in place of the string that rode the usage, so the sentence an operator reads does not change by one character. status stays refused, the declared taxonomy at :162 is untouched, and _finalise and _record read named attributes and serialise nothing (storyboard_image_runner.py:2789-2812, :2735-2750), so the new field reaches no row, no note and no client type. The sheet render, the tweak and the rework all keep exactly the answer they have today, which is the contract test_image_key_refusal_callers.py holds. (4) The fan-out raises what the press must stop on. In the collect loop at storyboard_images.py:1098-1113, after results[index] = future.result(), a result carrying a refusal raises it, and a clause except provider_key.ModelKeyRefused: raise goes directly after the existing typed re-raise at :1101-1108 and before the broad clause at :1109, because a raise inside that try with no such clause would be caught at :1109 and filed as one failed candidate. It shares the comment's argument, which already says in words that a refusal of the whole batch is not one candidate's bad luck, and the docstring's rule at :1081-1086 ("with one exception: the gateway's own refusals are raised") is corrected to name the second. The candidates already submitted still run to their end inside the pool's with block, so a refused scene costs its three transport calls and no more. Everything else in that loop is unchanged, so a decode failure, a provider failure and a worker that died are still one candidate each. (5) The variation path raises by type without breaking the fallback every other stage depends on. storyboard_llm gains one class, StoryboardKeyRefused(StoryboardLLMError), and :406 raises it in place of the bare StoryboardLLMError, from the same exc, with the same sentence from the same _key_refusal_sentence. Every existing except StoryboardLLMError keeps catching it, which is what keeps the create route's promise that a script session is created even when the model split cannot be used, and what keeps all sixteen cases of test_video_storyboard_split_boundary.py green. _variant_prompts then gains one clause between :3064 and :3065, except storyboard_llm.StoryboardKeyRefused: raise, so the one stage that is inside a press re-raises it and the narrowed degrade below keeps absorbing the stage's own error. The subclass inherits attempts, which is empty for a refusal because nothing was billed (storyboard_llm.py:182-189). (6) The refusal set is still one name in one place. PRESS_REFUSALS (storyboard_image_runner.py:34) becomes model_gateway.GATEWAY_ERRORS + (provider_key.ModelKeyRefused, storyboard_llm.StoryboardKeyRefused), and its comment at :27-33 is rewritten to say what the set now holds and why each member stops a press rather than a scene. model_gateway.GATEWAY_ERRORS is not widened, because it is the gateway's own tuple, is re-raised or handled by type in twenty-two except clauses in fourteen non-test files under app/backend and app/pipeline (thirty lines in seventeen files when its definition, PRESS_REFUSALS, the registration loop and five comment or docstring lines are counted; the commands are under Measurements), and is what main.py:315 iterates to register one handler per class with that class's own status. The two clauses that read the name (:3267, :3581) are not otherwise changed, so both presses answer the two new classes with the machinery S48 built: the claim the press was holding is released with the refusal's own sentence, every later claim is released with include_failed=False, stopped carries the sentence, the batch is recorded and the original object is re-raised. The runner needs from app.pipeline.utils import provider_key; S54 adds that exact import ahead of this card, so on the main this card starts from it is already there and is not added twice, and only if S54 has not merged does this card add it. (7) The redaction sentence does not move and says what it says today. _key_refusal_sentence stays at storyboard_llm.py:414-429, called from :406, and its three forms are unchanged. It is built from reason, stored, source and provider and the module constant UNSENDABLE_WHY, and ModelKeyRefused carries no key at all (provider_key.py:60-63, :87-95), so it cannot carry a credential value or any part of one. The same holds for the frame path's sentence, which is refusal.detail, built by provider_key._sentence (:98-109) from the same four names. What is new is where those sentences now appear: on a stopped press they reach summary["stopped"], the batch note, the released rows' provider_message and the board's own line. The card adds no new sentence and no new formatting, and pins the property on each of the new surfaces with keys minted per run. (8) docs/ops/s55-acceptance.md: the two paths with the line each refusal was converted on and what it does now; the printed batch summary and the printed row states for a three-scene board refused at the frames and for one refused at the variation, on the branch and on the base; the two presses side by side on one credential refusal, which is the parity S48 established for the gateway's; the measured count of transport calls, degradation warnings and version rows a three-scene and a forty-scene board cost before and after; the two tests that changed with their assertions before and after; and the list of tests that hold the refused image-result contract, each named, with the run that shows them green on both sides.
Interfaces. openrouter_client.generate_image_with_retry keeps its name, its signature and the shape of its answer, which is still a two-tuple of an optional image and a usage dict; the value under KEY_REFUSED changes from a sentence to the refusal that carries it, and the key count does not change. KEY_REFUSED keeps its name and its value. storyboard_images.generate_one keeps its name, its signature, its return type, its four statuses and every sentence it writes; ImageResult gains one optional field with a default, so every construction site that does not set it is unchanged and every reader that does not name it is unchanged. generate_character_sheet, tweak and rework are not touched and keep their answers, which is what keeps the run's image step and the character sheet's claimed row exactly as they are. generate_candidates keeps its name, its signature and its return, and gains one raise and one typed clause, the same shape it already has for the gateway's own refusals. ModelKeyRefused keeps its class, its attributes and its status; one sentence of its docstring changes. storyboard_llm._call keeps its name, its signature and its sentence; what it raises for a credential refusal becomes a subclass of what it raises today, so every caller that catches StoryboardLLMError keeps catching it and the create route's fallback promise is kept. StoryboardLLMError is not changed. _variant_prompts keeps its name, its signature and its return, and gains one raise for one class. PRESS_REFUSALS keeps its name and gains two members. generate_all_candidates and generate_candidates_with_record are not changed at all: they already stop, release, record and re-raise for whatever is in the set. _record, _finalise, claim_frames, _fail_claims, release_scene_claim, recent_batches and the scenes poll are not changed. No route signature changes, no client type changes, no web file changes and there is no migration: stopped is already on the batch, already reduced by recent_batches and already drawn by the screen, all of it shipped by S48. Three behaviours change on purpose. A board press on a workspace that cannot send an image credential stops at the first scene instead of writing three refused stills per scene for every scene. A board press on a workspace that cannot send the storyboard credential stops instead of producing every scene's frames from one unvaried description. And on both, the operator reads one sentence naming the binding and the key source, in the field S48 built for exactly that, instead of a board of stills that read as the model having tried.
Acceptance. The api tier green as AGENTS.md sets it out, from apps/api, with DESELECT=$(grep -vE '^\s*(#|$)' ci-known-failures.txt | sed 's/^/--deselect /' | tr '\n' ' ') and .venv/bin/python -m pytest app/backend/tests -q -p no:cacheprovider $DESELECT, the targeted files run locally with the venv's own interpreter and pytest's own exit code read from the command. The targeted api run is .venv/bin/python -m pytest app/backend/tests/test_image_key_refusal_callers.py app/backend/tests/test_provider_key_refusal.py app/backend/tests/test_storyboard_candidate_failures.py app/backend/tests/test_video_storyboard_split_boundary.py app/backend/tests/test_character_model_failures.py app/backend/tests/test_video_storyboard_m2.py app/backend/tests/test_video_storyboard_e2e.py app/backend/tests/test_storyboard_batch_record.py app/backend/tests/test_storyboard_spend_ledger.py -q -p no:cacheprovider. No web tier: no file under apps/web changes. No heavy leg: every test this card adds runs in memory or over a stubbed httpx.post, none starts a container. .venv/bin/python -m py_compile clean over the five changed product Python files. Tests, each named by what it proves. test_a_board_press_stops_once_on_an_image_credential_refusal, RED ON THE BASE, in test_video_storyboard_e2e.py: this is the counting test the rule needs on the frame path, driven with a three-scene board, the world fixture's successful variation stub kept (test_video_storyboard_e2e.py:513-517) and its image seam at :506 answering what the real helper answers for a refused key, (None, {KEY_REFUSED: refusal}), with the refusal built by provider_key.require_sendable over a Resolved whose provider holds no credential and not by hand; this test counts the press, and the test after it proves that the case it drives, a text credential that works beside an image credential that is missing, is one a workspace can hold; it asserts three calls into the image seam, the first scene's concurrent fan-out of CANDIDATES_PER_SCENE, and not nine, stopped carrying the refusal's sentence, failed and refused at zero, no scene entry, and all nine claimed rows failed carrying the sentence, where the base makes nine calls, files refused: 9 with three scene entries, nine rows refused and no stopped key at all. test_a_text_credential_and_an_image_credential_part_only_across_providers, RED ON THE BASE, two cases, in test_provider_key_refusal.py: model_gateway.resolve is replaced by one fake that answers production.storyboard.llm on one provider holding a stored key and production.image on a second provider holding none, with config.OPENROUTER_API_KEY empty and httpx.post recorded; claude_client.call_claude under production.storyboard.llm sends one request carrying the first provider's key, while openrouter_client.generate_image_with_retry for one prompt sends nothing and answers (None, {KEY_REFUSED: refusal}) with refusal.provider naming the second provider and refusal.stored false; in the second case both keys resolve to one provider holding no credential, which is the seed's shape (app/core/bindings/seed.py:102 and :105 bind both to OpenRouter), and both transports refuse and send nothing. The base fails the first case because its usage holds the sentence and not the refusal. This is the proof chosen over narrowing the press test to an injected refusal, because it runs both real transports over one resolution, which is where the two credentials are chosen (claude_client.py:117 and openrouter_client.py:103, both through model_gateway.credential_or at model_gateway.py:274-289 with the same fallback), and an injected refusal alone would pass even if the split could never occur. test_a_board_press_stops_once_on_a_storyboard_credential_refusal, RED ON THE BASE, in test_video_storyboard_e2e.py: the counting test on the variation path, three scenes, generate_shot_variants raising what _call raises for a refused key after this card, a StoryboardKeyRefused whose __cause__ is a ModelKeyRefused built by require_sendable; it asserts one variation call and not three, and no call at all into the image seam, which is the direct pin that a workspace whose text provider holds no key stops at the variation and never reaches the image transport, whatever the image provider holds; stopped carrying the sentence and no degradation warning, where the base makes three variation calls, writes three warnings and nine rows. test_the_single_scene_press_answers_a_credential_refusal_the_way_the_board_does, RED ON THE BASE, in test_video_storyboard_e2e.py: the parity S48 established for the gateway's refusals, now for this one, both classes. test_the_image_transport_hands_its_caller_the_refusal_and_not_only_its_sentence, RED ON THE BASE, two cases for a missing and an unsendable key, in test_provider_key_refusal.py: the usage holds one key whose value is the refusal, whose detail is the same sentence, where the base holds the sentence alone. test_the_candidate_fan_out_raises_a_key_refusal_and_isolates_everything_else, RED ON THE BASE, in test_storyboard_candidate_failures.py: the fan-out raises the refusal object itself, and in the same test a decode failure and a worker that died are still one candidate each, where the base returns three refused results. test_a_keyless_variation_is_raised_by_type_and_a_model_failure_still_degrades, RED ON THE BASE, in test_storyboard_candidate_failures.py: _variant_prompts re-raises the credential refusal and still degrades an ordinary StoryboardLLMError into the repeated description and the warning, where the base degrades both. test_no_sentence_a_stopped_press_writes_carries_any_part_of_a_key, RED ON THE BASE, in test_video_storyboard_e2e.py: with a key minted per run, neither the stopped sentence, nor the batch note, nor any released row's provider_message, nor anything printed holds the key or any part of it, where the base has no stopped field to read. Tests labelled GUARD, green on the base and green on the branch, none changed, and this is the contract the item warns about: the seven cases of test_image_key_refusal_callers.py, which are the run's image step and the character sheet's three renders and the redraw route; test_a_key_refusal_says_a_key_has_to_be_set_instead and test_the_documented_status_set_is_what_the_module_writes in test_storyboard_candidate_failures.py; the sixteen cases of test_every_stage_turns_a_keyless_workspace_into_the_sentence_and_sends_nothing and test_every_stage_refuses_an_unsendable_key_without_repeating_it in test_video_storyboard_split_boundary.py, which are the create route's fallback promise and the whole reason the card uses a subclass; the three tests of test_character_model_failures.py:250-297; and test_a_silent_refusal_surfaces_as_refused_not_a_generic_error, test_a_silent_refusal_is_retried_three_times_before_it_is_surfaced and test_one_refused_candidate_does_not_fail_the_others in test_video_storyboard_m2.py, which prove a silent refusal is still a refused result and not a stop. S48 tests that must stay green unchanged, named: test_a_refusal_on_the_first_scene_stops_the_whole_press and test_a_refusal_part_way_keeps_the_frames_the_press_already_earned, both parametrised over the six classes of model_gateway.GATEWAY_ERRORS; test_a_stopped_press_leaves_a_settled_row_of_a_scene_it_never_reached; test_the_single_scene_press_answers_a_refusal_the_way_the_board_does; test_a_stopped_press_carries_its_sentence_and_nothing_else_gains_a_field; test_the_scenes_poll_carries_the_cast_run_the_board_has_to_draw; test_one_failing_scene_does_not_stop_the_others; and test_a_database_failure_at_the_variation_fails_that_scene_in_the_batch_note, which is the one that matters most, because a driver failure at the variation must still be one scene's failure and not a stop. Two existing tests change and each is named and justified. test_the_image_retry_loop_answers_a_refusal_as_a_failed_generation_at_once (test_provider_key_refusal.py:238-272, two parametrised cases): two of its eleven checks move, at :256-257, from usage[KEY_REFUSED].startswith(...) and secret not in usage[KEY_REFUSED] to the same two checks on .detail, and its docstring's "the refusal's sentence ... in the usage" at :244-245 says the refusal rides the usage. Every other check is kept, including list(usage) == [KEY_REFUSED] at :255, which is still true because the usage still holds one key and no cost. It must change because it is the one test that asserts the type of that value, which is the one thing this card moves on the frame path, and a card that left it alone would be leaving the contract unpinned rather than unchanged. test_every_storyboard_model_call_passes_the_one_boundary (test_video_storyboard_split_boundary.py:279-309): the one assertion at :309, which requires every raise in _call's later handlers to name StoryboardLLMError, accepts StoryboardKeyRefused as well, and nothing else in the test moves. It must change because it pins by name what _call raises, which is the one thing this card moves on the variation path; the rule it exists for, that no model failure leaves _call as anything but the stage's own error or a gateway refusal, still holds because the new class is a subclass. Nothing is deleted. One test docstring is corrected with no assertion touched: test_image_key_refusal_callers.py:3-5 says the helper answers (None, {}), which has never been true of a key refusal. No comment the card adds names a card, an item or a phase. bash infra/secret-scan/scan-tree.sh clean, which is the one command AGENTS.md:39 mandates and which runs both halves over one private copy of the set a commit can carry, and python3 infra/scripts/test-task-refs.py then python3 infra/scripts/task-refs.py check . clean. Record docs/ops/s55-acceptance.md; open item #173 closed; the new open item for ensure_cast and match_uploaded_sheets filed and named in the record.
Out of scope. Making a refusal rarer rather than told once. Adding the credential to the routes' synchronous pre-flight, so a press is refused before it claims anything, loses here for three reasons: model_gateway.check reads the spend guard and is called by every service that creates, restarts or retries a session (model_gateway.py:161-165), so widening it changes far more than a press; a credential removed after the button was pressed still arrives mid-press and still has to be told once, so the card would ship a second mechanism and still need this one; and the check would have to resolve two bindings rather than one, which is a decision about what a press costs before it answers. Widening model_gateway.GATEWAY_ERRORS, for the reason S48 wrote down and this card re-measured: it is the gateway's own tuple, it is named in twenty-two except clauses in fourteen non-test files, every one of which would start treating a credential refusal as a gateway refusal where today it lets it fall to a later clause, and main.py:315 iterates it to register one handler per class while the credential refusal has its own at :351-353. Folding S54's two clauses in read_one_sheet into one widened name: that function is a sheet reading pass and not a press, the name's comment says what stops a press rather than a scene, and its pair is exactly the pair _character_model_call lets through (storyboard_images.py:143-150, pinned at test_character_model_failures.py:292-297), which excludes StoryboardKeyRefused because nothing below the character boundary can raise it, so the one widened set would name a member that function can never meet. The two spellings stay, each matching the boundary under it. Changing storyboard_llm._call to re-raise ModelKeyRefused by type, which is the obvious fix and is wrong: it would break the create route's promise that a script session is created even when the model split cannot be used, turn a keyless create into a 503 with no session, and fail all sixteen cases of the two parametrised tests at test_video_storyboard_split_boundary.py:129-158. The subclass exists so that none of that happens. The two other narrowed degrades that now absorb the subclass exactly as they absorb its parent, ensure_cast (storyboard_image_runner.py:201-214) and match_uploaded_sheets (:411-426): neither is inside a press, each runs once per session rather than once per unit, and so neither is the rule this item is about. Their behaviour is unchanged by this card and a keyless workspace still reaches an empty cast recorded failed and a pool left unassigned. Whether those two should refuse rather than degrade is a real question and a different one, and this card creates a new open item for it, filed at the merge with the two line references and numbered when it is filed, rather than riding it in here. The character sheet's refused row, the tweak's and the rework's, and a run's image step, which are the contract the item warns about and which this card deliberately does not move; each keeps the answer it has, and the tests that hold them are listed as guards. What the screen draws for a stopped press, which S48 built and which this card reuses without a line of web change. The scene-level generic row sentence at storyboard_image_runner.py:3209, which S48 recorded and left. The other broad clauses in the frame runner that S48 listed by line and left.
Waits on / Unblocks. Waits on S48, which is merged (PR #154) and is the whole of what opens this gate: PRESS_REFUSALS, the two clauses that read it, stopped, the release rules for a claim the press was holding and for one it never reached, the reduction in recent_batches and the line the screen draws are all on main in the shape this card uses, and none of them existed before it. This card was measured on that main, de99c60, and adds no web file and no new field to any of them. main is now f79b410, and three merges landed after the measurement. S49 is merged (PR #155, 442bf73): it changed storyboard_image_runner.py at :1820, :1998, :2358-2393 and :2461, which moved every runner anchor here from _record down by seventeen lines and none above it, and it added one test at test_video_storyboard_e2e.py:913, which moved every e2e anchor here by thirty-nine lines; it touched no other file this card cites. V56 is merged (810b27f) and changed web files only. A52 is merged (f79b410) and changed docs/CI.md and the workflow timeout scripts only. The content each anchor names is unchanged, the Measurements table maps every one of them to f79b410, and the builder's first deliverable is an anchor table found by content on the main it starts from, because four cards merge ahead of this one and three of them move the runner again. The queue's merge order is S54, S50, S51, S52, S55, S53. S54 is a draft in review and shares storyboard_image_runner.py: its product change is inside read_one_sheet at :899-905 plus the import at :22-24 that this card also needs, so after S54 that import is on main and this card adds none; it keeps its two-clause re-raise and leaves consolidation to this card, and this card explicitly leaves it, for the reason in Out of scope; the two share no test file. S50 is an approved card and shares the runner, test_video_storyboard_e2e.py and test_storyboard_batch_record.py: it edits the ordinary failure entry directly below each of S48's two stop clauses (:3288-3290 and :3607-3610) and adds a helper beside _release_claim (:3150-3167), so its lines sit next to the clauses that read the name this card widens without editing them. S51 is an approved card and shares the runner, test_video_storyboard_e2e.py and test_storyboard_batch_record.py: it replaces fail_claims (:3144-3147), a range this card does not touch, and writes down that a slot refusal does not join PRESS_REFUSALS, which this card keeps true. S52 is an approved card and is the real overlap: it removes _fail_claims (:3130-3141) and rewires the could-not-start clause at :3206-3210, which is the clause this card's frame path passes through on its way to the stop clause, to the guarded release; it changes _finalise's three writes (:2789-2812), whose named-attribute reads this card relies on to keep the new ImageResult field out of every row; and it changes the store stand-in at test_storyboard_candidate_failures.py:91 under the refused-batch test that this card keeps as a guard and adds two tests beside, and adds tests to test_video_storyboard_e2e.py. None of those changes what this card asks for, because the stop clause releases every claimed row with the refusal's sentence after whatever that clause wrote, but the builder reads that clause and that store as S52 left them and not as quoted here. S53 is an approved card that merges after this one and shares no file with it. So this card merges fifth and is re-tested on its merge commit over the four shared surfaces: the runner, test_video_storyboard_e2e.py, test_storyboard_candidate_failures.py and test_storyboard_batch_record.py. No open pull request touches any of this card's five product files: PR #137, PR #136 and PR #118 are services/scraper, db/migrations and apps/api/app/backend/tests/core. Unblocks the last claim in this area that was not true: after this card the two presses, the character boundary and the app answer a credential refusal with the same sentence and the same policy, which is that the unit of work stops and is told once, but not through the same mechanism: a synchronous route answers 503 with the refusal's detail through the handler at main.py:344-353, while a press runs in the background and, at storyboard_image_runner.py:3267-3280 and :3581-3605, records stopped, releases its rows and re-raises into the job gate's log. So a later reader checking what stops a unit of work finds one answer in one set. It also makes the set at :34 the place any further whole-unit refusal is added, which is what the next such find will need.
V57 · The browser specs are type-checked, and the pointer-target rule cannot fall out of a run · V · M · S2-W2 · test/v/v57-specs-type-checked-and-the-rule-held (surfaced while building V55, pre-existing on main, open items #162 and #163; carded 2026-09-22)
Context. Measured at main de99c60, which is S48 merged (PR #154), and re-measured by content at f79b410, which is A52 merged (PR #156) on top of V56 (810b27f) and S49 (442bf73): not one cited line moved, because none of the three merges touched e2e, the app's tsconfig files, apps/web/package.json or .github/workflows/ci.yml, and A52's edits to docs/CI.md and the floor file kept every line count; three anchors were wrong at both commits and are corrected here (the timeout rule is docs/CI.md:74 and not :72, the equality it is checked by is :78 and not :82, and e2e/README.md's Run it section is :173-217 and not :173-214), and the anchor table in Measurements maps every one. Two holes in the checking of one directory, neither of them a defect in behaviour. First, nothing runs a compiler over the browser specs. e2e/package.json:5-8 holds two scripts and neither is a check: test at :6 and test:gallery at :7, which are the two Playwright projects (e2e/playwright.config.ts:41-52, testIgnore at :44 and testMatch at :49). There is no e2e/tsconfig.json; the app's two projects reach nothing outside the app, because apps/web/tsconfig.app.json:38 includes src alone and apps/web/tsconfig.node.json:37 includes vite.config.ts and vitest.config.ts, and the solution file that references them carries no files of its own (apps/web/tsconfig.json:2-6). The lint step runs from apps/web (apps/web/package.json:10, eslint . --max-warnings 0), so it never reaches a sibling directory, and no workflow runs a compiler over e2e at all (grep -rn 'e2e' .github/workflows/ci.yml finds no type-check step). Playwright transpiles a spec and checks nothing about its types, so a spec with a type error runs. The item's second line range needs correcting: ci.yml:375-380 spans the middle of two steps and holds neither command whole. The web job is :289-392; the two steps that touch e2e are install the browser runner at :374-378, whose commands are npm ci at :377 and the browser install at :378, and axe-core over every component state at :379-383, whose command is npm run test:gallery at :383 with the base url at :381-382. e2e/package.json:5-8 is correct as the item writes it. The type error reaches main unseen, and here is the construct, measured rather than argued. e2e/ui-gallery.spec.ts:18-23 reduces an axe result to one line per rule, ` ${v.id} (${v.impact}): ... at :21. Write v.impacts instead of v.impact and the compiler refuses it, TS2551: Property 'impacts' does not exist on type 'Result'. Did you mean 'impact'?, quoted in Measurements from a run over a scratch copy. At run time the template prints undefined and every leg stays green, because the three legs that use the helper assert the list is empty (:31, :104-105, :139-140) and an empty list maps to an empty list. The defect surfaces only on the day a violation appears, which is the one day the report has to be readable. So the hole is real, and the fix is small: the whole of e2e/.ts already compiles clean under the app's own settings, exit 0, with three overrides and nothing else. What a spec directory cannot inherit is the part that usually breaks a plain extends, and it was measured both ways. Extending apps/web/tsconfig.app.json with no overrides fails at configuration: error TS2688: Cannot find type definition file for 'vite/client', from apps/web/tsconfig.app.json:20, and the compiler then reports nothing about any spec, so the naive form checks not one line. The specs need the opposite of the app's type set: they are Node code driving a browser, and they use process (e2e/playwright.config.ts:6), Buffer (winners.spec.ts:63), __dirname (status.spec.ts:47) and the node: modules (creative.spec.ts:1-3), so types must be ["node"], and @types/node is not in e2e/node_modules today. The app's lib is already right and must stay: the specs read layout off real elements inside a locator's evaluate (ui-gallery.spec.ts:100, :134), which needs the DOM libraries the app already lists at apps/web/tsconfig.app.json:18. The app's cache must not be inherited (:14-15): the inherited tsBuildInfoFile resolves against the file that declares it, so an extends that keeps incremental writes the spec program's state into apps/web/.tscache/tsconfig.app.tsbuildinfo, the app's own cache file, measured on a scratch copy of the tree; the web job restores and saves that one cache keyed on the app's own tsconfig files (ci.yml:322-329, the key at :327) and saves it before the e2e steps run (:355-360), so CI's saved copy would survive, but every local run of the new check would replace the app's cache with a different program's. The app's path alias is cleared too (:34-36), and the reason is run time, not the compiler: Playwright's own loader looks for a tsconfig.json beside each file it loads, follows extends and applies paths against the directory of the file that declares them (e2e/node_modules/playwright/lib/transform/transform.js:103-106, lib/third_party/tsconfig-loader.js:68-83, Playwright 1.58.1), so an inherited @/ would start resolving a spec's imports into apps/web/src the day the file lands, while paths: {} leaves the runner resolving exactly what it resolves today. A spec already reaches into the app, on purpose and by an explicit relative path: production-states.spec.ts:7 imports the route list from apps/web/src/shell/production-routes.ts, so that file is in the new check's program too and compiles clean under the spec settings; the check is clean with the alias inherited or cleared. Second, the pointer-target rule is in these runs by a tag and by nothing else. In axe-core 4.13.0 the rule is declared with enabled: false, impact: 'serious', selector: '', matches: 'widget-not-inline-matches', tags cat.sensory-and-visual-cues, wcag22aa and wcag258, and any: [{options: {minSize: 24}, id: 'target-size'}, {options: {minOffset: 24}, id: 'target-offset'}], quoted whole in Measurements. So it runs only because wcag22aa is in the tag list at e2e/ui-gallery.spec.ts:14, and it passes on either of two checks: a target 24 by 24, or a target with 24 of spacing offset. Seven specs build an axe run and all seven go by tag, none by rule name: ui-gallery.spec.ts:14 with its call at :19, auth-pages.spec.ts:34 at :48, status.spec.ts:40 at :124, admin.spec.ts:30 at :104, creative.spec.ts:48 at :221, production-states.spec.ts:65 at :223 and winners.spec.ts:50 at :183; auth-policy.spec.ts builds no axe run at all. The item asks whether a rule should be pinned by name beside the tag list. withRules cannot do it: withRules and withTags both assign this.option.runOnly (e2e/node_modules/@axe-core/playwright/dist/index.js:180-186 and :195-200) and each carries the sentence that it cannot be used with the other (:176, :191), so a rule list beside a tag list is a silent replacement of the whole filter by whichever call came last. A pin by name does exist, through the run options: axe-core reads a rule's own enabled before it reads the tag filter (e2e/node_modules/axe-core/axe.js:20569-20583, the rule's option at :20576-20577 ahead of the tag match at :20578-20579), so .options({ rules: { 'target-size': { enabled: true } } }) called before .withTags(TAGS) runs the rule whatever the list holds. This card does not add it, by a deliberate decision that the tag list stays the single source of which rules run, since ruleShouldRun lets a rule's own enabled override the tag match (axe-core/axe.js:20569-20583) and a second place that selects rules is a second place they can silently diverge; and a pin would not be a guard in any case, because it is lost the same quiet way: .options() replaces the builder's whole option object (dist/index.js:170-173), so an .options() call placed after withTags drops the tag list and every rule only the list turned on, and disableRules replaces the whole rules map (:209-214), so a later call to it drops the pin without a word. The honest guard is therefore not a second lock but a proof about the run: assert that the rule was evaluated, and assert that every spec in the directory still selects by tag. Both are cheap and both fail loudly on exactly the change the item names, whichever call made it. The item also asks whether a size-only reading is wanted anywhere. It is, in one place, and it is already there: V55 put a direct box assertion on the one control it grew (ui-gallery.spec.ts:160-177, at least 24 wide and at least 24 tall at :173-176). A tree-wide strict reading cannot be had from the rule at all, because the two checks are the rule's own any list and the builder exposes only a rule's enablement, so a strict sweep would be a measurement of boxes and not a tag, and it would report controls that WCAG 2.2 AA permits through spacing. This card adds no such sweep and says so in Out of scope. Last, the job. The web job declares timeout-minutes: 17 at ci.yml:294; the committed floor is 347 s from run 35584809207 (infra/scripts/workflow-timeouts.json, the web entry, and the row at docs/CI.md:94), and the rule derives 17 from it, twice 347 plus 300 seconds is 994, which is 16.6 minutes and rounds up to 17 (docs/CI.md:74). That floor is already behind a run the job really had: A52's record measured web at 375 s on run 35621510154, over the 12 runs on main at 0a09b90 since the job's last change of steps, above the committed 347 s (docs/ops/a52-acceptance.md:353), which would derive 18 and which A52 left unseeded on purpose. This card adds a step, so it changes the job's shape and owes a re-measurement (docs/CI.md:84), and since A52 the same paragraph also says what that re-measurement may do to the number it replaces, which deliverable 8 follows. Item #172 still stands on f79b410 as filed: its four stale maxima are smoke-staging, deploy-staging, notify and verify-tags, all jobs of the deploy workflow, and A52 changed none of their entries, only build-web's, which gained the first mark; the web job is not one of the four, and build-web is a different job in a different workflow with a similar name. web's own drift needs no card of its own, because this card re-measures web anyway and the 375 s run is of the job without this card's step. No path filter changes: e2e/* is already in the web job's filter with a comment saying why (ci.yml:95-102, the entry at :102`), so a change to a spec alone already runs this job and will run the new step.
Read first. e2e/package.json whole file (the two scripts at :5-8, the two dependencies at :9-12 and the sentence at :4 that says this package is not a workspace member). e2e/playwright.config.ts whole file (the base url it refuses to guess at :6-9, testDir at :12, the serial rule and its reason at :13-19, and the two projects at :41-52). e2e/ui-gallery.spec.ts whole file (the tag list at :14, the helper that reduces a result at :18-23, the three legs that assert an empty list at :31, :104-105 and :139-140, and V55's box assertions at :160-177). The six sibling specs at their tag lists and their analyse calls: auth-pages.spec.ts:34 and :48, status.spec.ts:40 and :124, admin.spec.ts:30 and :104, creative.spec.ts:48 and :221, production-states.spec.ts:65 and :223, winners.spec.ts:50 and :183; and auth-policy.spec.ts:1-40, which builds no axe run. e2e/README.md:160-162 (why this package installs on its own), :164-171 (the two suites and the two servers) and :173-217 (how each is run by hand, why the wait before the gallery run matters, and the closing paragraph at :216-217 after which the new block goes). e2e/node_modules/@axe-core/playwright/dist/index.d.ts:9-25 (the builder's surface, withRules and withTags beside each other) and dist/index.js:176-200 (what each of them writes, and the sentence on each that it cannot be used with the other). The target-size rule as axe-core 4.13.0 declares it, in e2e/node_modules/axe-core/axe.js (enabled: false, the three tags, the two checks of its any list and its matches). apps/web/tsconfig.app.json whole file (the cache at :14-15 and the comment above it, the target and libraries at :16-18, the app's own type set at :20, noEmit at :26, the strictness at :28-33, the alias at :34-36 and the include at :38). apps/web/tsconfig.node.json:37 and apps/web/tsconfig.json:2-6 (the other project and the solution that references both). apps/web/package.json:8-12 (the app's own scripts, and that typecheck is tsc -b) and :37, :46 (the two versions this card mirrors). .github/workflows/ci.yml:23 (the Node version both jobs take), :49-102 (the path filters, the e2e key at :79-94 and the web key at :95-102 with the comment that says why e2e/** is in it), and the web job whole, :289-392: the limit at :294, the install at :305, the compiler version read at :309-313, the cache restore and its key at :322-329, lint at :330-331, the app's type check at :337-338 with the comment above it, the two builds at :339-340 and :347-348, the unit tests at :341-342, the cache save and its comment at :349-360, the preview at :361-373, the browser install at :374-378 and the gallery run at :379-383. docs/CI.md:12 (the web row and every step it names), :38 (the lint step's ground), :44 (which project runs where), :48 (why the image's SPA stage sees apps/web alone), :74 (the two terms of the timeout rule), :78 (the check requires the declared value to equal the derived one), :82-86 as A52 left them (the floor file and its high_water mark at :82, the re-measurement sentence and A52's rule for a lower number at :84, and the shape digest that clears a mark at :86) and :94 (the web floor row). infra/scripts/workflow-timeouts.json (the web entry at :7 and the scraper entry directly below it at :8). infra/scripts/workflow-timeouts.py:95 (the four entry fields), :161-164 (derive, which takes a floor), :195-197 (a mark whose digest is not the job's current one is refused) and :352 (the floor is the larger of the maximum and the mark). docs/ops/a52-acceptance.md:322-365 (the post-anchor maxima A52 did not seed, web's at :353). e2e/production-states.spec.ts:7 (the one spec that imports app source). e2e/node_modules/@axe-core/playwright/dist/index.js:170-173 and :209-214 (options and disableRules, each replacing what it writes whole), and ruleShouldRun in e2e/node_modules/axe-core/axe.js:20569-20583. docs/ops/v55-acceptance.md (the box measurement, the note that a disabled control is not analysed, and the paragraph that files both of these items). Open items #162 and #163.
Deliverables. (1) e2e/tsconfig.json, four lines of settings and one include: it extends ../apps/web/tsconfig.app.json, so the specs are held to the same strictness as the app and nobody maintains a second dialect, and it overrides exactly three things. "types": ["node"] replaces the app's ["vite/client"] (apps/web/tsconfig.app.json:20), which is the one setting that makes a plain extends fail outright and which is wrong here anyway, because these files are Node code. "incremental": false turns off the app's cache (:14-15), because the inherited buildinfo path resolves against the file that declares it, so the spec check would write its own program's state over apps/web/.tscache/tsconfig.app.tsbuildinfo, the app's own cache file, on every local run; with incremental off the inherited tsBuildInfoFile is unused and the compiler accepts it, measured. "paths": {} drops the app's alias (:34-36), because Playwright reads this file too, follows its extends and applies paths at run time (playwright/lib/third_party/tsconfig-loader.js:68-83), so an inherited @/* would change how the runner resolves a spec's imports; cleared, the new file changes nothing the runner does, and a spec that needs the app's code keeps reaching it by a relative path, as production-states.spec.ts:7 does. Measured, the compiler's result is the same either way. Everything else is inherited on purpose, and the libraries in particular: the specs read layout off real elements inside a locator's evaluate (ui-gallery.spec.ts:100, :134) and need the DOM libraries the app already lists at :18. The include is ["*.ts"], which takes every TypeScript file the directory holds today, the eight specs and the config, follows production-states.spec.ts:7 into apps/web/src/shell/production-routes.ts, the one app file a spec imports, and leaves node_modules and test-results out by the compiler's own default; a helper added in a subdirectory is still checked, because a spec that imports it pulls it into the program. (2) e2e/package.json gains one script, "typecheck": "tsc --noEmit", named for what it does and named the same as the app's (apps/web/package.json:8) so the one word means the same thing in both trees, and two development dependencies, "typescript": "~5.9.3" and "@types/node": "^24.12.0", each pinned exactly as the app pins it (apps/web/package.json:46 and :37) so that the specs are judged by the compiler the app is judged by and against the Node major the runner uses (ci.yml:23). e2e/package-lock.json is regenerated by the install and committed, because this package installs on its own (e2e/README.md:160-162). (3) One step in the web job, between the browser install at ci.yml:378 and the gallery run at :379, with working-directory: e2e and run: npm run typecheck. It goes after the install because the check needs the type packages that step fetches, and before the gallery run because a spec that cannot compile should be refused before a browser and a preview server are put to work, which is both the cheaper failure and the one whose message names the file and the line. It goes in this job and in no other, because this job's filter already fires on every change under e2e (:102). (4) The comment above the cache save keeps saying something true: it now reads that the save sits right after the last step that writes this cache, rather than the last step that runs a compiler (:349-354), because after this card a later step runs a compiler over a different project that shares no cache with it. Nothing else in that block changes, and the cache key at :327 is untouched, because it hashes apps/web/tsconfig*.json and the new file is not under apps/web. (5) e2e/ui-gallery.spec.ts gains one test that the pointer-target rule was really evaluated on the gallery page: it analyses the page with the same tag list and asserts that a result with the id target-size is among the rules that were judged, that is among passes or violations rather than absent or inapplicable, with a failure message that names the tag list as the reason a rule can go missing. This is what the rule's own declaration makes necessary: it carries enabled: false, so it is in the run only while a tag that names it is in the list at :14. The condition is fixed, and the builder may not narrow or widen it after seeing a run: passes or violations proves two things at once, that the rule was selected and that the gallery draws a target it applies to, where inapplicable would prove only the first. That target exists: V55's red run shows the rule judging the picture control (docs/ops/v55-acceptance.md:150), and its green run shows the same control still on the page after it was grown, with the box test passing (:165-176), so the control is still a target the rule matches and now lands in passes. If a run of this card finds target-size in inapplicable, that is a finding to stop on, not a reason to relax the assertion. (6) The same spec gains one test that reads its six siblings off disk and refuses a change of list shape: every file in the directory whose name ends in .spec.ts and that builds an axe run must pass a tag list containing wcag22aa, and none of them may call withRules, .options( or disableRules(, each refused with a sentence of its own. withRules overwrites runOnly, the field withTags writes, so whichever came last is the whole filter (dist/index.js:180-186, :195-200, and the sentence on each at :176 and :191). .options() replaces the builder's entire option object (:170-173), so a call after withTags removes runOnly and with it the tag selection. disableRules() replaces only the rules map (:209-214) and leaves the tag selection in place, but it can name target-size, and axe-core reads a rule's own enabled ahead of the tag match (axe-core/axe.js:20576-20579), so it can switch the rule off inside a run that still selects by tag. No spec calls any of the three today, so a uniform prohibition costs nothing now, and a spec that one day needs one of them lifts the prohibition in the same pull request, where a reviewer sees it. The test needs no browser and no server; it is in this spec because the gallery project is the one that runs on every pull request. (7) e2e/README.md gains a short block at the end of Run it (after :217, the section's last line): npm run typecheck needs no server and no browser, which makes it the one command in this package that can be run on a plain checkout, and what it covers is every TypeScript file in the directory including the config. The Two suites, two servers section at :164-171 is not touched, because the two suites are unchanged. (8) The floor, in a commit of its own, after the commit that changes the job has completed pull-request runs of the new shape. This card changes the shape of the web job, so its floor is re-measured, and the number is the maximum web duration over every completed, successful pull-request run of the new shape available when the floor commit is written, never an average and never a run of the old shape. A52's rule requires a new measurement and does not say it is one run (docs/CI.md:84), so the card does not take the first green run alone. If only one such run exists and it is anomalously warm against the old shape's 347 s and 375 s, the builder requests a rerun of the web job before writing the floor, and the record lists every run read with its id and duration. Runs on the commits that record the floor and its limit are margin evidence and are never chased. That commit carries three things together, where a reviewer sees them beside each other: the web entry of infra/scripts/workflow-timeouts.json with the new maximum in seconds and the run id it was read from, the re-derived timeout-minutes at ci.yml:294, and the web row of the table at docs/CI.md:94. The commit shape follows A42 (cf01beb, the secrets floor, the limit and the row in one commit) and A46 (00a834e, the infra floor and the limit in one commit, after the shape change had landed as 9a56398). The arithmetic is known in advance and the record states it: the rule gives 17 for a floor from 331 s to 360 s, 16 or less for 330 s or under, and 18 from 361 s to 390 s, because twice 330 plus 300 is 960 seconds, exactly 16 minutes, and twice 360 plus 300 is 1020 seconds, exactly 17; the floor today is 347 s, the old shape has already run 375 s (docs/ops/a52-acceptance.md:353), and the check refuses any declared value that is not the one the rule derives (docs/CI.md:74, :78). A new-shape maximum above 347 s replaces it like any maximum, and no mark is written. One below it does not quietly replace it, because A52's rule says a lower re-measurement either moves the old number into the entry's high_water mark or deletes it, in the diff and with the reason in the record (docs/CI.md:84); here only deletion is open, because a mark carries the digest of the job it was measured on and the check refuses one whose digest is not the job's current one (docs/CI.md:86, infra/scripts/workflow-timeouts.py:195-197), and 347 s was measured on the job before this card's step. So the floor commit's diff shows 347 s leaving, the record says it went by the shape bound and not by a judgement about what made that run slow, and 347 s is deleted, never marked: the added step changes the job's digest, and a mark carrying the old digest is refused. A cold number worth a mark can only come from a run of the new shape. The web row of docs/CI.md:12 also gains the new step in its list of what the job runs. (9) The record docs/ops/v57-acceptance.md: the compiler's output over the directory as it stands, exit 0; its output over a copy of one spec carrying one deliberate type error, with the error text and the exit code, and the same copy accepted by the browser runner, which is the whole case for the step; the naive extends and the one error it fails with, so that the three overrides are on the record as measured and not as taste; the rule's declaration as axe-core 4.13.0 carries it, with its two checks; the two new tests with their output on this tree and against deliberately broken copies; the floor before and after with both run ids and the derivation line the check prints; and a plain statement of what the check still does not cover, which is anything the compiler cannot see, including whether a locator matches anything on a real page.
Interfaces. Exposes one command, npm run typecheck in e2e, which every later browser spec is judged by and which anybody can run on a plain checkout with no server, no browser and no stack. Exposes two guards that hold the shape of an accessibility run rather than its result: one that the pointer-target rule was evaluated on the gallery page, and one that every spec in the directory still selects its rules by tag. Consumes the app's compiler settings by reference (apps/web/tsconfig.app.json), so a later change to the app's strictness reaches the specs on the same day and neither tree can drift into its own dialect, with exactly three settings overridden and each one named in the file. Consumes the web job's existing install of this package (ci.yml:374-378) rather than adding an install of its own. Nothing about the two Playwright projects changes: playwright.config.ts is read by the new check and not edited, both projects keep their testMatch and testIgnore (:44, :49), and neither picks up the other's spec. No spec's behaviour changes, no tag list changes, no axe run is added to a page that did not have one, and the whole of e2e/*.ts compiles clean today, so no spec is edited to satisfy the new check. No path filter changes, because e2e/** is already in this job's filter (ci.yml:102). No api, no route, no page and no component is touched, and nothing under apps/web/src changes. Three behaviours change on purpose: a spec with a type error is refused before a browser starts, a run that has quietly lost the pointer-target rule fails and names the tag list, and a spec that switches from a tag list to a rule list fails and says why the two cannot be mixed.
Acceptance. npm run lint, npm run typecheck and npm run build clean in apps/web, unchanged by this card and run because the job runs them. The new command green from e2e: npm ci && npm run typecheck, exit 0 over the nine TypeScript files the directory holds and the one app file production-states.spec.ts:7 imports, with the output and the compiler's own file list outside node_modules (npx tsc --noEmit --listFilesOnly) quoted. The gallery project green: with a VITE_UI_GALLERY=1 build served on a preview port, E2E_BASE_URL=http://localhost:4173 npm run test:gallery, every test passing including the two new ones and V55's box assertions (ui-gallery.spec.ts:160-177). Two GUARD tests, both green on both sides, each labelled as a guard and each proved by a refusal rather than by a base failure. GUARD one: target-size is in passes or violations of the gallery page's run, and in neither inapplicable nor absent; it is green on the base because wcag22aa is in the list at :14 today, and it is proved to work by a run against a scratch copy of the spec with wcag22aa removed from that list, which fails and names it, with both outputs quoted. GUARD two: every spec in the directory that builds an axe run still selects by tag and none calls withRules, .options( or disableRules(; it is green on the base because all seven select by tag and none calls any of the three today, and it is proved by four runs against scratch copies, one with wcag22aa dropped from a sibling's list and one each with a withRules, an .options( and a disableRules( call added, each failing and naming the file and the call, with the outputs quoted. No test in this card is RED ON THE BASE, and the record says so in those words with the reason: both items are holes in the checking and not defects in behaviour, nothing on main is behaving wrongly, and a test written to fail on the base would have to break a spec to do it. What replaces a base failure for the type check is the same kind of refusal proof, and it is part of acceptance, not a remark: a copy of e2e/ui-gallery.spec.ts is made outside the tree with one property renamed, the new command is run over a configuration that includes that copy, and the compiler's refusal is quoted with its exit code; the same copy is then shown to be accepted by the browser runner, which is the measured statement that the check catches what main cannot. The workflow checks, because this card edits a workflow and a floor: actionlint over both workflows, python3 infra/scripts/test-workflow-timeouts.py green, and python3 infra/scripts/workflow-timeouts.py check . equal for every job, with the web derivation line quoted on the base, after the commit that adds the step and after the floor commit; the line ends with the job's shape digest, and the record shows the digest changing at the step and not at the floor commit, because timeout-minutes is not part of it. The floor commit itself is quoted whole in the record, with every new-shape pull-request run it read, each run's id and web duration, and the maximum it took. python3 infra/scripts/test-task-refs.py then python3 infra/scripts/task-refs.py check . clean, and no comment this card writes names a card, an item, a phase or a wave. bash infra/secret-scan/scan-tree.sh clean, which is the one command AGENTS.md mandates at AGENTS.md:39 and which runs both halves over one private copy of the set a commit can carry; the new e2e/package-lock.json is in that set. The CI web job green on the pull request, which is where the new step and the gallery project both run. Record docs/ops/v57-acceptance.md; open items #162 and #163 closed.
Out of scope. Adding the type check to the e2e job as well. It loses on the filter: the web job already runs on every change under e2e (ci.yml:102, with the comment at :98-101 saying why), so a second copy would catch nothing the first does not and would cost a second job's shape change and a second floor to re-measure. Making e2e a project reference of apps/web/tsconfig.json (:2-6). It loses on order and on scope: the app's type check runs at ci.yml:337-338, long before the step that installs this package at :374-378, so the specs' own packages are not on disk yet and the step named for the app's types would fail for a reason that has nothing to do with the app; it would also pull the specs into the cache at :322-329 and into the image's SPA stage, which copies apps/web alone (docs/CI.md:48). Editing any spec to satisfy the new check. There is nothing to edit: the whole directory compiles clean under the recommended settings, exit 0, so any change here would be a behaviour change wearing a type fix's clothes. Loosening the inherited strictness for the specs. The point of extending the app's settings is that one tree cannot drift into its own dialect, and a spec that needs an escape is a spec to rewrite. Pinning the pointer-target rule by name beside the tag list. withRules cannot do it, because both calls write the same field and the later one replaces the earlier (dist/index.js:180-186, :195-200, and the sentences at :176 and :191). The run options can (.options({ rules: { 'target-size': { enabled: true } } }) before withTags, read ahead of the tag filter at axe.js:20576-20577), and that pin is not taken, by a deliberate decision: the tag list stays the single source of which rules run (axe.js:20569-20583), because a rule's own enabled would override it and a second place that selects rules can silently diverge from the first. The pin would also be one misplaced .options() or one later disableRules away from the same silent loss (dist/index.js:170-173, :209-214), and GUARD two now refuses both calls, so adding a pin would mean lifting that prohibition on purpose. Running a second axe analyse per page with a rule list, which is the only way to have both. It costs a second full pass over every page for a rule the tag list already reaches, and the two lists would then have to be kept in step by hand, which is the drift the guards exist to catch. A strict 24 by 24 sweep over the tree. It cannot be had from the rule, because the two checks are the rule's own any list and the builder exposes only whether a rule is on, so a strict reading is a measurement of boxes; and it would report controls that the standard permits through spacing offset, so it is a different claim from the one the runs make today. The one place the strict reading is wanted already has it, on the one control that needed it (ui-gallery.spec.ts:173-176), and a tree-wide inventory of control sizes is its own card with its own measurement. Changing the tag list, enabling another rule, or touching the six specs that run in the e2e job. Each is a change to what the accessibility runs claim, and this card changes only whether the claim can be lost by accident. Re-measuring any other job's floor. The re-derivation rule binds a card to the job whose shape it changed (docs/CI.md:84), item #172's four stale maxima are jobs of the deploy workflow and belong to their own re-measurement card, and the other nine ci.yml post-anchor maxima in A52's record (docs/ops/a52-acceptance.md:348-360) belong to theirs. Putting the check in a commit hook. Nothing in this repository hooks the specs, the gate belongs where the branch is judged, and a hook would be a second place to keep in step.
Waits on / Unblocks. Waits on A51 and A9 merging first, and is last in the merge order; it waits on them for order and not for code, because nothing it builds uses either, but both change files it changes and the build starts from the main they leave. Everything it reads is on main at f79b410: the specs, the two Playwright projects, the app's three tsconfig files, the web job and its filter, the floor file and the timeout check as A52 left them, and V55's gallery section and box assertions. A52 is merged (PR #156, f79b410): it rewrote docs/CI.md at :82, :84 and :86 and the deploy table, gave the floor file's entries a fourth field, high_water (infra/scripts/workflow-timeouts.py:95), made derive take a floor rather than a maximum (:161-164, the floor at :352), and gave build-web the first mark; it kept every line count, so no anchor here moved, and its rule for a lower re-measurement is what deliverable 8 now follows. S49 (442bf73) and V56 (810b27f) are merged and share no file with this card. The two it waits on meet it on files and not on lines; V57 starts, or rebases, on the main both leave and re-runs its checks there, on the merge commit, rather than trusting its own head. A51 is built and not merged, on chore/a/a51-every-unread-class-is-a-decision, rebased on f79b410: it edits .github/workflows/ci.yml below the web job (hunks from :555 in the infra job and from :1170 in the secrets job) and docs/CI.md at the infra and secrets rows (:14-26, line count kept), and it no longer touches the floor file, so it moves none of this card's anchors. A9 is pull request #118, open as a draft and not based on f79b410 (its branch forks from fbb92cd, the scraper's merge), and touches all three of this card's shared files, measured from its branch: ci.yml, where it adds eleven lines to the spike_a4 filter key, which sits below the web key, so the filter anchors at :95-102 stay and every anchor in the web job moves down eleven lines once it lands, and where it edits the scraper job's limit and steps; docs/CI.md at the scraper and spike-a4 rows and the scraper floor row, line count kept; and infra/scripts/workflow-timeouts.json at the scraper entry, the line directly below the web entry this card rewrites, so a textual conflict in that file is likely and V57 rebases it by hand. Its branch still carries web at 386 s and scraper at 205 s as context where main holds 347 s and 210 s, a second reason to rebase rather than trust a copy. Whichever of these lands after another re-runs the timeout check whole, because that check derives every job of both workflows from one file, and V57 does so last. Every anchor here was measured at de99c60 and confirmed at f79b410, and A9 will move the ci.yml ones again, which is why the builder's first deliverable is an anchor table found by content on the main it starts from. The other two open pull requests, #136 and #137, touch none of this card's files, measured from their own file lists. Item #172 and this card share the floor file and not a line: its four entries are deploy jobs, and whichever lands second rebases the file. Unblocks every later browser spec, which is now written against a compiler that reads it, and every later accessibility run, which can no longer lose a rule by a change of list shape without a test saying so by name.
V52 · The price the character panel shows is the price the session's ledger records · V · S · S2-W2 · fix/v/v52-one-price-for-a-note (surfaced while building S39, open item #121; carded 2026-09-20)
Context. This card is a decision before it is a change, and it stops at a gate: the owner settles what a note under Edit character costs the operator, and nothing in the panel or the runner moves until that answer is written down. Measured at cd8022a, the two sides disagree. The panel's copy says the note is free: apps/web/src/components/video-storyboard/CharacterPanel.tsx:1381, "Free unless the picture has to change", under the tweak and rework box, and again at :1399, where an outcome that drew nothing reads "Nothing was redrawn, <reason>. Free." The runner records the same note as a billed text call: apps/api/app/backend/services/storyboard_image_runner.py:2043, svc.record_spend(session_id, "character_instruct", result.usage), written straight after the model call at :2042. That stage is a declared one, not a stray string: apps/api/app/backend/services/storyboard_service.py:4062-4072 heads SPEND_STAGES with the sentence "Every stage that bills a TEXT model call" and lists character_instruct among the eleven, and record_spend at :4074-4097 inserts a row into storyboard_spend with the model, both token counts and the cost. The row reaches the operator's own screen: session_text_cost at :4123 answers a per-stage breakdown, and apps/web/src/pages/video-storyboard/VideoStoryboardDelivery.tsx:523-533 prints the session's text cost, its call count and its unpriced count with the breakdown in the hint. The item's two line numbers both moved on the way to cd8022a: the panel's sentence is at :1381 and not :1377, and the recording is at :2043 and not :2032; the comment that prices the note at "a fraction of a penny" is still at :1389, still inside a comment, and is still shown to nobody. Three things the item did not say, all of which point the same way. First, the runner contradicts itself inside one function: the skip branch at :2166-2169, which is the branch the panel's "Free" sentence at :1399 renders, carries the comment "NOTHING IS DRAWN AND NOTHING IS SPENT", while the character_instruct row for that same note was already written at :2043, before the branch is reached. Nothing is drawn there, and something was spent. Second, the sentence is not wrong for every character: _instruct_fields returns at :2026 for a character whose identity is not structured, before the model call and before the recording, so a legacy character's note really is free and takes the old raw-string path, and one sentence is shown to both kinds. Third, the panel already owns the honest words: :686 describes building a sheet as "a text call and a rendered page", and of the four user-visible free claims in the file only the two note sentences are about a path that calls a model at all, the other two at :519 and :682 being about choosing an already-read sheet, which calls nothing and is true as written. The single best option is therefore that the ledger is right and the copy is wrong: the note is a text call, it is cheap, and it is not free, so the two sentences say that in the words :686 already uses, and no recording changes. The alternative, making the screen true by dropping the recording, is named in Out of scope with why it loses.
Read first. apps/web/src/components/video-storyboard/CharacterPanel.tsx:1370-1405 (the tweak and rework box, its sentence, the comment that prices the note, and the outcome block), :30-45 (the two-control explanation at the head of the file), :470-525 and :676-690 (the other free claims and the "text call and a rendered page" wording). apps/web/src/components/video-storyboard/CharacterPanel.test.tsx (the shapes a panel test already renders). apps/api/app/backend/services/storyboard_image_runner.py:2005-2060 (_instruct_fields, its early return and its recording) and :2130-2175 (the caller, the skip branch and its comment). apps/api/app/backend/services/storyboard_service.py:4060-4140 (SPEND_STAGES, record_spend, record_spend_attempts, session_text_cost). apps/api/app/backend/tests/test_character_sheets_flow.py:2170-2180 (the test that pins the recorded stage). apps/web/src/pages/video-storyboard/VideoStoryboardDelivery.tsx:515-540 (where the stage reaches the operator). Open item #121.
Deliverables. (1) The gate, first and alone: a written answer from the owner to one question, in the record, before any source file is edited. The question is whether a note under Edit character is sold to the operator as free or as a named text call, with the measurement above attached: the runner bills it as a text call today, one test pins that, and the delivery screen already shows the bucket. The card's proposal is the second answer. Nothing below starts until that line exists. (2) On the proposed answer, the panel's two sentences say what the run does. :1381 stops claiming free for the note and names it as a text call whose render is the part that costs a picture, in the register :686 already uses. :1399 says that nothing was redrawn and gives the reason, without the word free. Neither sentence gains a number, because no price is shown anywhere else in the panel and this card does not introduce one. (3) The legacy case is not sold as a text call when it is not one, or the sentence is written so that it is true of both paths; the record says which was chosen and why, measured against the early return at storyboard_image_runner.py:2026. (4) The runner's skip-branch comment at :2166-2169 stops saying that nothing is spent, because the instruction's row is already written when it is reached. The comment explains in plain words that the words moved and no picture was drawn, and that the text call the instruction made is on the session's ledger. (5) A test in CharacterPanel.test.tsx renders the tweak and rework box and the outcome that drew nothing, and asserts the sentences do not claim free for the note. It fails on the base by finding the word. (6) A test on the api side, beside test_character_sheets_flow.py:2175, that a note on a structured character leaves exactly one character_instruct row, and that a note on an unstructured one leaves none, so the two paths the copy now describes are each pinned. (7) The record docs/ops/v52-acceptance.md: the owner's answer, the two sentences before and after, the two paths and their rows, and the count of free claims in the panel before and after with what each one is about.
Interfaces. Exposes nothing new. The character_instruct stage, SPEND_STAGES, record_spend, session_text_cost and the delivery screen's breakdown stay exactly as they are, so no api answer, no column and no stage name changes and no consumer is touched. Consumes CharacterPanel.tsx's own state and the outcome shape as S39 left them.
Acceptance. The owner's answer is quoted in the record before the first commit. npm run lint, npm run typecheck and npm run build clean in apps/web. npx vitest run src/components/video-storyboard/CharacterPanel.test.tsx green, and the new assertions fail on the base sources naming the sentence they caught at :1381 and :1399. From apps/api, in the virtual environment, the api tier's own command from AGENTS.md section "How to run checks" over the targeted file, with the deselect list that command builds: DESELECT=$(grep -vE '^\s*(#|$)' ci-known-failures.txt | sed 's/^/--deselect /' | tr '\n' ' ') then .venv/bin/python -m pytest app/backend/tests/test_character_sheets_flow.py -q -p no:cacheprovider $DESELECT, green, read from pytest's own exit code, with the structured and unstructured cases each failing on the base sources, the first because no such assertion exists and the second because the unstructured path has never been pinned. python3 infra/scripts/task-refs.py check . clean, and no comment this card writes names a card, an item, a phase or a wave. Record docs/ops/v52-acceptance.md; open item #121 closed.
Out of scope. Dropping the character_instruct recording so the screen's word becomes true. It loses on three measurements: the stage is declared as one that bills a text model call and the call is made before the skip branch is reached, so removing the row would make session_text_cost a lower bound that cannot say so, which is the failure its own unpriced field exists to prevent; the delivery screen would silently lose a bucket it already draws; and test_character_sheets_flow.py:2175 pins the row today, so the change would rewrite a passing test to assert an absence. Showing a price or a running total anywhere in the panel, which is a different design question and has no surface on the character screen. The two free claims at :519 and :682, which are about choosing an already-read sheet and are true. The refusal sentences S39 wrote, which name the control and not its price. Any change to the render's own cost, to _no_render_needed, or to what the tweak and rework paths draw.
Waits on / Unblocks. Waits on the owner's answer at deliverable (1) and on nothing else: S39 is merged, and no open card edits CharacterPanel.tsx, storyboard_image_runner.py's instruction path or SPEND_STAGES. Unblocks nothing, and is unblocked by nothing; it shares no file with the other lane V cards carded in this pass. If a later card teaches the panel to show what a session has spent, it takes this card's answer as the contract for what the note costs.
Built as (PR #151 → main f1b50ca, 2026-09-22). The owner's answer at the card's gate, written first as 7b47967: a note under Edit character is a named text-model call, and its recorded price is shown on the panel after the note completes; never typed, never estimated, no price before the press. Code 2 review rounds → the first returned five must-fixes and one wording, the second APPROVE with one wording. Four commits: 7b47967 the answer and the two sides measured on this base; 9d7c3c3 the change; ea07763 the fix round; 3cfa646 the wording. The refine outcome carries three fields built in one place, _text_spend: text_call_made, text_ledger_recorded and text_cost_usd, where the cost is read from the same usage object that record_spend writes to storyboard_spend.cost_usd, and only when the recorder said the row landed. record_spend answers a boolean now, True only after the cursor's context has exited and committed; a lost row is missing from the session's total and the panel says so, because session_text_cost counts rows that exist. A typed failure AFTER the paid text call, the trait-floor refusal inside _instruct_fields, a reference that fails to arrive, and the tweak and rework renders through _render_carrying_the_note, carries the same three facts as a private attribute on the SAME exception object (carry_text_spend, text_spend_carried), so _release_sheet_claim and the route's status selection see the exact type they saw before; a legacy identity makes no text call and carries nothing. _image_guard gains one keyword-only flag, with_note_cost=False; the refine route alone passes true and answers a dict detail {"message", "text_call_made", "text_ledger_recorded", "text_cost_usd"} at the same status, while every exception without the facts and every other route keep the string detail byte for byte, pinned by test_only_the_refine_route_answers_a_refusal_with_a_price across all four _image_guard routes. The five pre-call refusals (bad mode, empty note, no sheet, tweak with no picture, sheet in flight) are pinned as making no text call and no record_spend call. The panel drops both "Free" sentences and its "fraction of a penny" comment, prints the recorded price under the note through formatMeasuredUsd (four states: priced and recorded; recorded with no price; not recorded; legacy with no sentence), holds a local refineError with the refusal's price sentence in the note box, keeps the parent onError(message) alert, and clears on the next press, on success and on a character switch; formatMeasuredUsd prints the unknown glyph for a negative amount and $0.00 for -0. Measured: four api test files at the base with the branch's tests copied in, 31 failed of 364, each for its own reason; the panel and the formatter at the base, 30 failed of 57; on the branch 444 passed across the five touched api files, 745 across sixteen neighbours, the database test 5, the whole web suite 1,153 in 126 files; the failed-write case is proven twice against the real record_spend, alone and through a whole refinement whose cursor cannot be reached. Found and not fixed here: eleven doubles of record_spend in other api tests still answer None where the recorder answers a boolean; nothing asserts on the three fields behind them today, and the next card that reads a price through one must give its double the recorder's answer first. Record docs/ops/v52-acceptance.md; open item #121 closed.
Amended 2026-09-21 (the owner's answer at the gate; co-plan, one round). The amendment is the authority where it and the text above differ. The owner answered: a note under Edit character is a named text call WITH a price number on the panel. That is not the proposal above, which added no number, so Deliverables (2) and the sentence "Neither sentence gains a number" are replaced, and Interfaces no longer reads "Exposes nothing new": the refine outcome gains three fields. One rule binds the design: no number is typed or estimated. The price the panel shows is the price of the note that has just run, from the same usage the ledger row is written from, so the panel and the delivery screen cannot disagree. No number is shown before the press: a session average or a last value would describe a different call. Deliverables as amended. (1) The owner's decision of 2026-09-21 is the first line of docs/ops/v52-acceptance.md: an Edit character note is a named text-model call, and its actual recorded price is shown on the panel after that note completes, never a typed or estimated price. (2) record_spend (apps/api/app/backend/services/storyboard_service.py) returns whether its storyboard_spend row committed, and keeps its non-raising behaviour for every caller that ignores the return; the returned state covers the commit, because a successful get_cursor() exit includes it. (3) Every refine outcome carries text_call_made (boolean), text_ledger_recorded (boolean) and text_cost_usd (number or null), assembled where _outcome builds the dict (storyboard_image_runner.py, near :1973-1988), with the cost read from the same result.usage that is passed to record_spend (near :2060-2063). The legacy early return (an unstructured identity, near :2026) makes no model call, so its outcome says text_call_made=false and carries no price; a null cost is NOT that signal, because a null cost is also the valid case of a gateway that reported none. (4) RefineOutcome and the refine client type (apps/web/src/lib/videoStoryboardApi.ts, near :978-990 and :1502-1505) gain the three fields; every api test double and panel fixture that builds or reads the outcome follows (test_character_sheets_flow.py near :2089-2144, test_character_claim_before_spend.py near :214-225, CharacterPanel.test.tsx). (5) One shared exported formatter, formatMeasuredUsd, used by the delivery screen's text total (VideoStoryboardDelivery.tsx:523-533, which today formats inline with two decimals and would print a sub-cent note as $0.00) and by the panel's note price; it keeps enough precision to tell a non-zero sub-cent call from zero, up to the ledger's six decimals, and keeps two decimals for ordinary totals. (6) The helper sentence near CharacterPanel.tsx:1381 says that a note is a text call and that its actual price appears after it runs and on the delivery screen; it shows no number and no session statistic. (7) The outcome block says, for a structured priced call, the text call and its formatted recorded price; for a recorded row with no price, that the gateway reported none and the delivery screen counts it as unpriced; for a row that could not be written, that the call was made and its ledger entry could not be recorded (it does NOT say the delivery screen counts it, because that count reads only rows that exist); for a legacy outcome, nothing about a text call. No note sentence says "free". (8) When a page is redrawn the sentence keeps the register of :686, "a text call and a rendered page", and states the note's text-call price apart from the render, without inventing a dollar amount for the render. (9) The panel's comment near :1385-1389 ("a fraction of a penny") and the runner's skip-branch comment (near :2206-2211, which says nothing is spent) are replaced by what is true, in plain words, with no task reference. (10) Api tests: a structured priced call returns exactly the recorded usage cost with a recorded ledger state; a null cost returns the unpriced state; a failed spend write returns the unrecorded state and does not fail the refinement; a legacy refinement reports no text call. Each fails on the base for its own missing behaviour. (11) Panel interaction tests: the helper, a priced outcome with no render, a priced outcome with a render, an unpriced outcome, an unrecorded outcome and a legacy outcome; none says "free"; the priced case fails on the base because the fields and the amount do not exist. (12) The record carries the response-state matrix, the formatter's precision examples, and the equivalence of the panel's source and the delivery screen's. No browser e2e tier is added: the repository has none for character refinement. Interfaces as amended. Exposes the three outcome fields, record_spend's boolean return and formatMeasuredUsd. Unchanged: the character_instruct stage, SPEND_STAGES, the storyboard_spend columns, session_text_cost's answer and the delivery screen's numbers (its formatting helper moves, its figures do not).
V53 · The localize pipeline writes only what it means to keep · V · S · S2-W2 · chore/v/v53-pipeline-writes-what-it-keeps (surfaced while building A35, open item #122; carded 2026-09-20)
Context. Two blocks in the localize pipeline were added to answer a question on a real run, each says to take it out once that run confirms the answer, and both still run on every session. Measured at cd8022a, at the item's own two lines, which did not move. apps/api/app/pipeline/localize_video.py:1514-1522 opens the first: its comment says stderr is captured even on success "to directly confirm this swap actually eliminates the DTS warning on the next real run", and asks the reader to "drop back to the plain run() helper once that's confirmed". The block prints the command, calls run_tool(concat_cmd, capture_output=True, text=True), raises on a non-zero return with the last 2000 characters of stderr, and then prints the last 3000 characters of stderr on success. Compared against apps/api/app/pipeline/utils/media_ffmpeg.py:108-115, the helper the comment names, the first four of those five statements are the helper's own body, character for character; the only difference is the success print. So the remedy the comment asks for is the removal of one print and a call to run, and the source scan that would otherwise be a risk is safe either way, because run itself reaches the process through run_tool and apps/api/app/backend/tests/test_localize_ffmpeg_bound.py pins exactly that. apps/api/app/pipeline/localize_video.py:3636-3650 opens the second: its comment says the reassembled video is persisted so that "a confirmed real frame-collapse can be inspected directly", that both leading theories are refuted, and that it is "safe to remove once the bug is found". The block uploads the whole reassembled mp4 to f"{ctx._r2_session_prefix()}/debug/video_reassembled.mp4" at :3645, mints a presigned link that expires after 3600 seconds at :3647, and prints that link into the run log at :3648. Four things the item did not say. The two blocks are not the same kind of thing: the first writes nothing but log, the second writes an object. The debug/ suffix is the only key under a session prefix that docs/decisions/ADR-010-r2-key-prefix.md does not name, and nothing reads it back: rg -n '/debug/' apps/api --glob '*.py' returns one line, the write itself, so no route, no audit and no cleanup path knows the object exists. The repository has no diagnostics switch to hang either block on: rg -n 'DIAGNOSTICS|diagnostics_enabled' apps/api --glob '*.py' returns nothing, so "make it a feature" means inventing a switch, a name and a retention rule for a diagnostic nobody has asked to keep. And the two are not the whole family: rg -n 'TEMP DEBUG' apps/api/app/pipeline returns six markers in four files, at localize_video.py:1514, :2630, :2813 and :3636, utils/claude_client.py:242 and utils/onscreen_ocr.py:141; the other four only count and time, write no file and upload nothing, and one of them, at claude_client.py:256, is explicitly marked as permanent and not part of the block above it. The single best option is that the two writing blocks go. The detector the second block was added beside stays: _ffprobe_frame_stats on the reassembled video at :3632 and the final probe it names are permanent, and they are what says a collapse happened; the upload only supplies bytes for a person to look at, at the price of a full video in the session's prefix and a live link in the log. The alternative, turning them into a named feature with a switch and a retention rule, is in Out of scope with why it loses.
Read first. apps/api/app/pipeline/localize_video.py:1495-1530 (the concat, the block and what follows it) and :3620-3655 (the frame-stats probe, the block and the mux that follows). apps/api/app/pipeline/utils/media_ffmpeg.py:92-116 (run_tool and run, and why the second is the first plus a message). apps/api/app/backend/tests/test_localize_ffmpeg_bound.py (the source scan that refuses a bare subprocess call anywhere in the package, and what it reads). apps/api/app/pipeline/context.py:305-350 (_r2_session_prefix and the keys the pipeline really writes). docs/decisions/ADR-010-r2-key-prefix.md (the named key set, and that debug/ is not in it). apps/api/app/backend/tests/test_r2_prefix.py:100-120 (how a prefix is pinned in a test). docs/ops/a35-acceptance.md:251 (where the two blocks were recorded and left). The other four TEMP DEBUG markers, to see that they write nothing: localize_video.py:2630, :2813, utils/claude_client.py:242-258, utils/onscreen_ocr.py:141-148. Open item #122.
Deliverables. (1) The concat at localize_video.py:1514-1522 runs through media_ffmpeg.run, which is what the block's own comment asks for, and the success-path stderr print goes. The failure path keeps the message it has today, which run already raises with the command and the last 2000 characters of stderr, so nothing a failed run told an operator is lost. (2) The debug upload at :3636-3650 goes whole, with its key, its presigned link and its log line. The frame-stats probe above it and the final probe below it stay untouched, because they are what detect the condition the upload was added to illustrate. (3) A test that the pipeline's output set carries no debug/ key, in a new file apps/api/app/backend/tests/test_localize_output_set.py, which holds both scans this card adds: a source scan in the shape test_localize_ffmpeg_bound.py already uses, that reads apps/api/app/pipeline and fails on a key expression under a session prefix that is not one of the suffixes ADR-010-r2-key-prefix.md names. It names localize_video.py:3645 on the base sources and passes at zero on the branch. (4) A second scan in the same file, test_localize_output_set.py, that no ffmpeg invocation in localize_video.py captures output on a successful run in order to print it, which is the shape the first block had: the scan reads the module and fails on a call that both passes capture_output=True and prints a stderr slice outside a failure branch. It names :1514 on the base and passes at zero on the branch. (5) The four remaining TEMP DEBUG markers are read and left alone, and the record says, one by one, what each of them writes and why leaving it is right; a marker that turns out to write a file or an object is taken out by this card too, and the record says so. (6) The record docs/ops/v53-acceptance.md: the two blocks before and after, the six markers with their classification, the proof that run and the removed block make the same ffmpeg call, and the output-set scan's counts on both trees.
Interfaces. Exposes nothing. Consumes media_ffmpeg.run and run_tool unchanged, ADR-010-r2-key-prefix.md's key set as the list the new scan reads, and the frame-stats probes unchanged. No route, no stored key, no audit field and no database column changes, because nothing ever read the object this card stops writing.
Acceptance. From apps/api, in the virtual environment, the command in AGENTS.md section "How to run checks" in its targeted form, over the test files this card touches and the new scans, with the deselect list that command builds: DESELECT=$(grep -vE '^\s*(#|$)' ci-known-failures.txt | sed 's/^/--deselect /' | tr '\n' ' ') then .venv/bin/python -m pytest app/backend/tests/test_localize_ffmpeg_bound.py app/backend/tests/test_r2_prefix.py app/backend/tests/test_localize_output_set.py -q -p no:cacheprovider $DESELECT, green, read from pytest's own exit code, and CI's api job green for the whole suite. Each new scan fails on the base sources naming its line, :3645 for the key scan and :1514 for the capture scan, and passes at zero hits on the branch; a scratch copy that writes one more key under a session prefix outside the named set is caught by the first, which proves it reads the tree and not a list. A run of the concat path proves the ffmpeg command is unchanged: the command list built on the branch is asserted equal to the one the removed block built, so the swap is a change of helper and not of arguments. python3 infra/scripts/task-refs.py check . clean and gitleaks detect --no-git --source . --config infra/secret-scan/gitleaks.toml --redact clean. No comment this card writes names a card, an item, a phase or a wave. Record docs/ops/v53-acceptance.md; open item #122 closed.
Out of scope. Keeping either block behind a switch with a name and a retention rule. It loses on measurement: the repository has no diagnostics switch to join, so it would be new configuration surface, a new documented key outside ADR-010's set and a new sweep to delete what it writes, all for a diagnostic whose own comments say it is waiting on a run that never came; and the detector that names a frame collapse is permanent and stays, so nothing about the open bug becomes harder to see. The four TEMP DEBUG markers that only count and time, which write nothing and are left with their comments as A35 left them. The frame-collapse bug itself, which is not this card's and has no measurement here. Any change to the mux, to the time map, or to what the pipeline writes on purpose. Widening the new key scan to other packages, which have their own prefixes and their own cards.
Waits on / Unblocks. Nothing: A35 is merged and its record files this item, and no open card edits localize_video.py or media_ffmpeg.py. It shares no file with the other lane V cards carded in this pass. Unblocks any later card that asserts a session's whole output set, because from here the set is what the key decision names and a scan holds it there.
Built as (PR #148 → main 5b1347b, 2026-09-21). Block one of apps/api/app/pipeline/localize_video.py (the video concat) is now one call of media_ffmpeg.run: the helper's body is the first four statements of the removed block (print the command, run_tool(cmd, capture_output=True, text=True), on a non-zero return raise RuntimeError with the command and the last 2000 characters of stderr), so the swap removes one print of the last 3000 characters of stderr on a healthy run and nothing else; a failed concat raises the same type with the same message. Block two (the upload to <session prefix>/debug/video_reassembled.mp4, its presigned link and its log line) went whole, and the frame-stats probe above it and the final probe below it are untouched. A recorded run of the concat asserts the exact ffmpeg argument list against a literal frozen from the base, on the base and on the branch, so no ffmpeg runs and the change of helper is proven not to be a change of arguments. The two scans are apps/api/app/backend/tests/test_localize_output_set.py. Scan one reads every module of apps/api/app/pipeline/, finds every key built under _r2_session_prefix() (written inline or through a prefix bound once to a name) and fails on a suffix outside three frozen families: /output/dubbed. (pipeline/context.py:320), /audit.md (context.py:334) and /images/ (context.py:344, expand.py:857, expansion.py:336); a second assertion requires each family's text to occur in docs/decisions/ADR-010-r2-key-prefix.md, which this card amended to name the families a session writes, so the test and the decision cannot drift apart; a scratch copy of a module with one more key is caught through the same function, so the scan reads the tree and not a list. Scan two reads localize_video.py, registers every call that captures a child process's output and follows each captured stream to where it ends; it fails on a stream that is written out on a success path. Both scans are default-deny, and that is what the review was about. The two source edits were approved in round 1; five more rounds, each on scan two alone, closed one path per round where the scan allowed what it did not understand: a flow of the prefix it could not read; a capture bound any way other than to one plain name; three capture shapes that landed in no bucket (now the terminal classes discarded and returncode-only, with two ledgers and the asserted arithmetic that every registered capture is accounted for by exactly one class); frombuffer treated as a number when it is an array (derived-number is now a scalar by construction only); a capture switch that is not a known literal, a ** expansion that could carry one, captures by name (check_output, getoutput, getstatusoutput, communicate), and an unknown call crossed at the numeric level (now a frozen set of three transformations, astype, reshape, from_numpy, with .T and literal arithmetic as named steps). The last round's own fall-through audit (35 rows in the record, one for every path through the registration and the follower that does not refuse) found and fixed a further one: a use that stands above its binding, as in a loop that prints the previous turn's output. The real decode chain at localize_video.py:693-694 is classed parsed, and the record claims only that: named transformations ending in a return, and nothing about what the caller does with the array. The four remaining TEMP DEBUG markers were read and left, one by one in the record: localize_video.py:2622 and :2805 (:2630 and :2813 on the base), pipeline/utils/claude_client.py:242, pipeline/utils/onscreen_ocr.py:141; each only counts or times in memory and writes no file and no object. Measured: the new file on the base exits 1 (6 failed, 120 passed; scan one names the debug key, scan two names the stderr print, each with its line); on the branch 126 cases pass, 167 with the two neighbouring files, and the seven test files that import the touched modules pass; real tree 2 captures registered, 2 accounted, 0 written out, 0 unreadable; every self-check was proven to fail on the scanner before it. Review: co-plan one round; six review rounds; approved with no open point. Tested head e7d4dec; PR run 35651683421, merge run 35652605529 and deploy run 35652605490 green. Record: docs/ops/v53-acceptance.md. Closed open item #122. Found and not fixed: nothing new; the record notes for a later reader that the per-zone caption refinement writes check images into the run's own temporary directory (localize_video.py:2826), which nothing uploads.
V54 · The web suite pays for a browser only where a browser is used · V · M · S2-W3 · test/v/v54-environment-where-the-dom-is (surfaced while building V51, open item #134; carded 2026-09-20)
Context. The suite gives every file the same browser-like environment and the same setup file, and a fifth of the files never touch a browser. Measured at main 78b90301: apps/web/vitest.config.ts:15-19 sets environment: 'jsdom' at :16, setupFiles: ['src/test/setup.ts'] at :17, css: false at :18 and an include list of src/**/*.test.{ts,tsx} and eslint-rules/**/*.test.js at :19, with no per-file or per-directory override anywhere in the file. That include resolves to 127 files: 102 .test.tsx and 24 .test.ts under apps/web/src, and one .test.js, eslint-rules/design-system.test.js. The installed Vitest is 4.1.11 (apps/web/package-lock.json:7663). Vitest 4 has no environmentMatchGlobs; its installed types (apps/web/node_modules/vitest/dist/chunks/reporters.d.DtoKVV2s.d.ts:2859, :3597-3616) give test.projects, a list of inline project configurations, and type each entry as TestProjectInlineConfiguration = UserWorkspaceConfig & { extends } (:3603-3615), so an entry is { extends: true, test: { name, environment, include, exclude } }: name, environment, include and exclude sit inside the entry's test, and extends: true sits beside it and inherits the root configuration, which carries the plugins, the @ alias, setupFiles, css and testTimeout (testTimeout is not in NonProjectOptions, :3572, so a project keeps it). So the mechanism is two projects, not a glob map. A file is node-safe when it passes in Vitest's node environment, where the globals jsdom adds are absent, both while its modules load and while its tests run; the classification is by that observed run and not by reading the import graph, because a browser global inside a function body the tests never call costs nothing, and one reached at load or in a test fails whatever the imports suggest. CI runs Node 24 (.github/workflows/ci.yml:23), which itself defines navigator, fetch, URL and Event, so absent means the globals jsdom adds and Node does not have. Read statically, three .test.ts files reach such a global in a test or a hook: src/lib/useVisibleInterval.test.ts:1 and :9 (a rendered hook and document.dispatchEvent), src/lib/api.ingest.test.ts:38 and :89 (localStorage.clear() and window.history.pushState), and src/lib/api.auth.test.ts:37 (localStorage.clear() in beforeEach). Five more import src/lib/api.ts, whose localStorage (:8, :12, :21) and window.location (:448-461, :1422) uses all sit inside function bodies; api.ts holds no browser global at module scope (its top-level bindings are :3, :55, :101, :397-398, :439, :731, :825 and :1867-1908), and the functions those five tests call, describeError at :511-517 among them, reach none. Every one of the 102 .test.tsx files imports @testing-library/react. So the static estimate of the node-safe set is 22 files: the 21 .test.ts files other than those three, and the rule test under eslint-rules. That count is an estimate. The builder decides the final set by running each file, and the record carries the run. Two files that read as browser files are not: src/test/console-mutes.test.ts names window.console only inside the source strings it scans (:54, :161), and src/test/scroll-regions.test.ts reads files and never a document. src/test/one-markdown-renderer.test.ts is a third source sweep of the same kind (:3 imports typescript, :1009 calls ts.createSourceFile). What the environment costs was measured at cd8022a, and these figures are that tree's, not this one's: on a four core box at a one minute load average of 1.31, npx vitest run src/lib/format.test.ts reported 2.46 s with 1.43 s of environment, and the same file with --environment node reported 0.921 s with 0 ms of environment and every test passing, setup file included. format.test.ts has more tests on 78b90301 than it had then, so the branch takes its own figures. The other half of the item is one file: src/test/select-controlled.test.ts:141 and :223 each call ts.createProgram, the first over the application's own file list and the second over a fixture, and the comment at :135-136 says building the program is the whole cost of the file. At cd8022a it reported 17.10 s alone, of which 11.09 s was tests and 3.45 s the environment it never uses. The item's other figures, the 85.58 s of environment in a CI run, the 987.70 s under a deliberate load, the 50.15 s for the programs and the 1,236 s of per-file walls against a 1,013 s run, were taken on the branch of a card that was parked, and docs/ops/v51-acceptance.md is not on main, so this card does not restate them as measured. The shape both halves share is that the configuration decides once for everything. The answer this card commits to is two named projects in the one configuration file: the browser-like project stays the default for anything not named, because a new component test that silently landed in the plain environment would fail in a way nobody would read as a configuration mistake, and the plain project names the files a run proved node-safe. Every test file belongs to exactly one project, and a test proves it. The compiler-backed sweep joins the plain project if its run agrees and stays inside the unit run; moving it beside the run as its own check is named in Out of scope with why it lost.
Read first. apps/web/vitest.config.ts (the whole file, including the comment at :20-40 that derives the per-test budget, which this card does not move). apps/web/node_modules/vitest/dist/chunks/reporters.d.DtoKVV2s.d.ts:2732-2860 (InlineConfig, name at :2736 and projects at :2859) and :3572-3616 (NonProjectOptions, ProjectConfig, TestProjectInlineConfiguration and its extends), the installed 4.1.11 types this card is written against. apps/web/src/test/setup.ts (the whole file: what the setup does at :1-4, :27 and :44-63, and which parts of it need a document). apps/web/src/test/select-controlled.test.ts:120-160 and :200-235 (the two programs, and the comment at :135-136). apps/web/src/test/console-errors.ts and src/test/console-mutes.test.ts (the guard, and the file that scans source rather than rendering it). apps/web/src/lib/useVisibleInterval.test.ts, src/lib/api.ingest.test.ts and src/lib/api.auth.test.ts (the three non-component files that reach a browser global in a test or a hook). apps/web/src/lib/api.ts:1-25 and :439-462 (where its browser globals sit). apps/web/package.json:6-13 (the scripts; "test": "vitest run" at :12). docs/CI.md:45 (the sentence that says the suite runs in jsdom, which this card amends), docs/CI.md:87 (the rule that a card which changes a job re-measures it) and docs/CI.md:97 (the web row of the floor table: 368 s, run 35913523452, 18 minutes). infra/scripts/workflow-timeouts.py:1-95 (the derivation and the floor rule) and the web entry of infra/scripts/workflow-timeouts.json:6. .github/workflows/ci.yml:325-437 (the web job, timeout-minutes: 18 at :330, the unit tests step at :378-379). docs/ops/v57-acceptance.md, sections "The timeout check" and "The floor", as the pattern for re-measuring the web floor from the first green run of a new shape. apps/web/src/README.md:30. Open items #134 and #112.
Deliverables. (1) vitest.config.ts declares test.projects with two inline entries, each of the shape { extends: true, test: { name, environment, include, exclude } }: a browser entry whose test carries name, environment: 'jsdom', today's include and an exclude of the node list, and a node entry whose test carries name, environment: 'node' and an include that is an explicit list of the files a run proved node-safe; the browser entry's exclude keeps it the default for every file not named. One sentence beside the list says what it is for and that a file joins it only when its run under the node project passes. The per-test budget, setupFiles and css stay where they are and reach both projects through extends: true. (2) The node-safe set is decided by running each file, one at a time, under the node environment: npx vitest run --environment node <file> for each of the 127 files, one worker, so the classification is a series of targeted runs and never a full-suite run on the box. A file goes in the node project if and only if that run passes, with nothing failing while its modules load or while its tests run. The record carries, per file, a green transcript for each file in the node project and a red transcript for each file kept in the browser project, with the first failing line that names the missing global; src/lib/api.auth.test.ts is kept in the browser project by its red run (localStorage at :37), with api.ingest.test.ts and useVisibleInterval.test.ts beside it. The static estimate is 22 node-safe files and 105 browser files, and the record says where the runs differ from it and why. (3) A partition test in the node project, in the shape of the source sweeps already under src/test/, that fails when a test file under today's include belongs to neither project, when one belongs to both, and when a path in the node list names no file, naming each file; it proves the union of the two projects is today's include and that each file is run once. Its red proofs are three scratch mutations of the configuration, one per sentence, each failing with that sentence. Going forward the set holds itself in both directions without a source sweep: a file in the node project that starts reaching a browser global fails in its own run with a ReferenceError naming the global, and a node-safe file left in the browser project costs its environment and nothing else. (4) The setup file is proved to work in both projects, or it is split so that the part that needs a document runs only where there is one; the record says which parts of src/test/setup.ts are environment-free and which are not, proved by the node project's green run. (5) src/test/select-controlled.test.ts joins the node project if its run passes and stays inside the unit run, and the record carries its wall and environment line before and after, each from a targeted run of that one file on the same box at a recorded load. (6) The whole suite's wall and the reporter's environment total are read before and after from CI, not from the box: the web job's unit tests step (ci.yml:378-379) on the latest green main run at or after 78b90301, and the same step on the pull request's first green run, each with its run id, the step's duration and the Vitest summary line from its log. A wall that does not move is reported as a wall that did not move, and the card is not re-planned around a number it wanted. (7) The web job's floor is re-measured from the pull request's first green run of the new shape, in the pattern of docs/ops/v57-acceptance.md: that run's web duration and run id replace "web": {"max_seconds": 368, "run_id": "35913523452"} in infra/scripts/workflow-timeouts.json:6, the timeout-minutes the rule derives from it replaces 18 at ci.yml:330, and the web row at docs/CI.md:97 carries the new maximum, run, term and minutes. This card changes the suite's effective workload and not the timeout checker's shape digest, which is computed from the workflow alone (infra/scripts/workflow-timeouts.py:58-67), and no step of the web job changes. So 368 s measured the suite before the split, and a high-water mark holding it would survive a change the digest cannot see. A lower maximum therefore deletes 368 s in the diff rather than marking it; the rule allows either, and deletion is the preferred one here. The record says so in one sentence. Runs on the commit that records the floor are margin evidence and are not chased. (8) No test file's assertions change, no budget moves, the console guard and the act environment are untouched, and the pool, the worker count, fileParallelism and isolate stay at their defaults in both projects. (9) docs/CI.md:45 and apps/web/src/README.md:30 say that the suite runs as two projects, a browser project by default and a node project for the files a run proved need no browser, and that a test holds each file to exactly one of them; the record is docs/ops/v54-acceptance.md.
Interfaces. Exposes the two projects and the partition test, which every later web test file meets: it lands in the browser project unless it is named in the node list, and a named file that reaches a browser global fails in its own run. Consumes vitest.config.ts's budgets, src/test/setup.ts's configuration, the console guard and the act environment unchanged. npm test keeps its name and its exit codes, and the union of the two projects' files equals today's include plus the one new partition test. No test file's public shape changes and no CI job gains or loses a step; the web job's timeout-minutes and floor entry change only by the re-derivation in Deliverable 7.
Acceptance. Builders run only targeted tests, red-on-base proofs and fast static checks locally; the full suite runs only in GitHub CI. Locally: the per-file classification runs of Deliverable 2, one file at a time, with every transcript in the record; the partition test green on the branch and each of its three scratch mutations red with its own sentence; the partition test red on the base configuration, which has no projects, naming the missing projects; npx vitest run src/test/select-controlled.test.ts before and after, with its wall and its environment line; npm run lint and npm run typecheck clean. python3 infra/scripts/test-workflow-timeouts.py then python3 infra/scripts/workflow-timeouts.py check . green on the floor commit, with the pinned reader on the path as the infra job supplies it, and the web derivation line quoted before and after in the record. In CI: the web job green on the pull request, which runs npm test over 128 files (the 127 of 78b90301 and the partition test) with every test passing, npm run build clean, and the unit tests step's duration and Vitest summary quoted against the base run of Deliverable 6. vitest list --filesOnly --json output on the branch shows every one of the 128 files once, with its project, and is in the record. python3 infra/scripts/task-refs.py check . clean. No comment this card writes names a card, an item, a phase or a wave. Record docs/ops/v54-acceptance.md; open item #134 closed.
Out of scope. Moving the compiler-backed sweep out of the unit run into its own command and its own CI step. It lost on measurement at cd8022a: it cost 11.09 s of a run the records put near 265 s, so a second command and a second place to read a failure buys about four per cent of the wall, and its failure is already reported by file name in the run a builder is watching. Choosing the environment with a per-file @vitest-environment docblock instead of named projects, which scatters the decision across the node-safe files and gives no place to state the rule. Classifying files by reading their import graph, which this card replaces with the observed run. Making any test faster, changing any test's assertions, or touching the per-test budgets, which were derived from measurement twice. The pool, the worker count, fileParallelism and isolate, all of which stay at their defaults. The cause of the uneven per-file stretch under contention, open item #112, which stays parked. The drift in vitest.config.ts's comment about how many files set their own budget (:39), which belongs to whichever card next edits that comment.
Waits on / Unblocks. Merges after A47, which edits the same three files, ci.yml, docs/CI.md and infra/scripts/workflow-timeouts.json: this card rebases on A47's merge, then measures its web floor from its own first green run of the new shape, so the floor commit is written against A47's table and not beside it. It waits on nothing else. Full suites run only in GitHub CI (owner rule, 2026-09-23), so box-load contention is not on any path this card takes; open item #112 stays parked, for investigation only if CI shows the uneven stretch. After the rebase it re-runs python3 infra/scripts/test-workflow-timeouts.py and python3 infra/scripts/workflow-timeouts.py check .. Unblocks every later web card whose record quotes a whole-suite wall, because from here a file's cost is closer to the work it does, and the node list says which files those are.
V55 · The control that opens a picture full size is big enough to hit · V · S · S2-W2 · fix/v/v55-a-control-every-pointer-can-hit (surfaced while building V48, open item #135; carded 2026-09-20)
Context. One component draws the "open this bigger" control on every picture in the storyboard, and it is smaller than the standard the repository's own accessibility runs enforce. Measured at cd8022a: apps/web/src/components/video-storyboard/ImageOverlayButton.tsx sets the control's size on two lines, :59, whose class string is rounded bg-background/85 p-0.5 disabled:opacity-50, and :67, <Icon className="h-3 w-3" />; the item's :60-66 is the block around them, and those two are the lines that decide the number. The arithmetic gives the axe figure exactly: apps/web/src/index.css:107-109 pins html { font-size: 21px }, so h-3 w-3 is 15.75 px and p-0.5 adds 2.625 px on each side, which is 21 by 21 px, and the control carries no minimum of its own. The standard is not borrowed for this card; it is already what the suite runs. e2e/ui-gallery.spec.ts:14 sets const TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'] and :19 analyses the whole gallery page with them, and six more specs carry the same list, creative.spec.ts:48, winners.spec.ts:50, admin.spec.ts:30, auth-pages.spec.ts:34, production-states.spec.ts:65 and status.spec.ts:40. target-size is the WCAG 2.2 AA rule in that set, it asks for 24 by 24, and docs/ops/v48-acceptance.md:226 and :242-246 record it as serious on 15 of the 16 tiles of the storyboard's version strip at 1280 by 720, at all three aspect ratios, with :354-360 naming the component and saying the rule is on main wherever the control is drawn. Three things the item did not say. The control is drawn more widely than the item's list: rg -n 'ImageOverlayButton' apps/web/src gives eight render sites in five files, pages/video-storyboard/NewVideoSession.tsx:878, components/video-storyboard/FrameSlots.tsx:360 and :494, CharacterPanel.tsx:231, :330, :338 and :350, and RenderHistory.tsx:265, while the component's own docstring at :13-17 still says five. The tile the item measures is set at FrameSlots.tsx:449, w-20 shrink-0, which is 105 px at the pinned root, not at :430, which is the block above it. And no run in CI sees the defect today: the control is on no gallery section, so rg -n 'ImageOverlayButton' apps/web/src/shell/pages/DevGallery.tsx returns nothing, and the only record of it is the one-off page run V48 made by hand. The design decision this card takes is to grow the control and leave the tile and the corner alone, and to express the new size in rem rather than in the pinned pixel root, so min-h-6 min-w-6 is 24 px at a 16 px root and 31.5 px at the app's 21 px root and the rule is met whatever the root becomes. The icon stays at h-3 w-3, so the picture is covered by more transparent background and not by more ink. The two alternatives are named in Out of scope with why each lost.
Read first. apps/web/src/components/video-storyboard/ImageOverlayButton.tsx (the whole file: the corner contract at :8-11, the beside-not-inside rule at :19-25, the name rule at :32-36, the off-state at :38-41, and the two lines that set the size at :59 and :67). apps/web/src/components/video-storyboard/ImageOverlayButton.test.tsx (the whole file: the tile shape it renders, the name assertion, the keyboard order and the hint on focus). apps/web/src/components/ui/control-hint.tsx and components/ui/tooltip.tsx (the two wrappers the control sits inside, and which element carries the corner while the button is off). apps/web/src/components/video-storyboard/FrameSlots.tsx:340-370, :440-500 (the tile, its width and the two render sites) and CharacterPanel.tsx:225-235, :320-355 (the four sites there). apps/web/src/index.css:100-113 (the pinned root font size). apps/web/src/shell/pages/DevGallery.tsx:120-140 (the section helper every gallery entry uses). e2e/ui-gallery.spec.ts (the whole file: the tags, the analyse call and how a section is driven). docs/ops/v48-acceptance.md:220-250 and :350-362 (the measured boxes, the rule, and what a browser settled that the source did not). apps/web/vitest.config.ts:15-19 (css: false, and the sentence that the unit tests assert on roles, names and attributes and never on computed styles, which is why the proof for this card is a browser run). Open item #135.
Deliverables. (1) ImageOverlayButton carries a minimum size of its own, in rem, so that every one of its eight render sites meets 24 by 24 CSS pixels at any root font size from 16 px upward, with the icon left at h-3 w-3 and the corner, the naming, the tooltip and the off-state behaviour unchanged. (2) The control's contents stay centred inside the larger box, so the icon sits where it sits today rather than drifting to a corner of its own padding. (3) The component's docstring gains one sentence saying why the control carries a minimum and that the figure is the pointer-target standard the accessibility runs already enforce, in plain words, and its count of copies is corrected to what the tree holds. (4) A unit test in ImageOverlayButton.test.tsx that the control declares its minimum, which fails on the base sources; the record says plainly that this asserts the declaration and not the rendered box, because the unit suite processes no CSS. (5) A gallery section for the control, on a tile of the storyboard's own width, in both its on and off states, so the rule is covered by a run that happens on every pull request rather than by a one-off page run. (6) e2e/ui-gallery.spec.ts drives that section, so its existing whole-page analysis covers the control on every pull request; the spec needs no new tag, because wcag22aa is already in its list. (7) The record docs/ops/v55-acceptance.md: the measured size before and after at both root font sizes, the eight render sites with a note on what each tile now looks like, the two gallery runs named in Steps with their node counts, and a re-run of V48's storyboard-strip measurement at 1280 by 720 showing the 15 reports gone and the strip's tab stops and scrolling unchanged.
Steps. The order matters, because main carries no gallery section for this control and so the gallery run cannot report anything on the base tree. First the section and the spec's drive of it land, with the component untouched. Second, that run is taken against the unchanged component and quoted: it is the base failure, on the same surface that will guard the rule afterwards, and it names the nodes it found. Third, the component takes its minimum and the same run is taken again and quoted clean. V48's recorded strip measurement stands beside those two as the proof that the defect was on main before this card existed, and it is not used as the base failure on its own: it shows the defect but says nothing about whether the new guard would catch it.
Interfaces. Exposes a control that meets the pointer-target standard everywhere it is drawn, which every later storyboard tile inherits by using the component rather than copying it. Consumes ControlHint, Tooltip and the tile layouts unchanged: no prop is added, no caller changes, and the corner, the label and the disabled behaviour stay exactly as they are, so no call site has to be edited.
Acceptance. npm run lint, npm run typecheck and npm run build clean in apps/web. npx vitest run src/components/video-storyboard/ImageOverlayButton.test.tsx src/components/video-storyboard/FrameSlots.test.tsx src/components/video-storyboard/RenderHistory.test.tsx green, with the new assertion failing on the base sources. The gallery project's spec, e2e/ui-gallery.spec.ts, run twice in the order Steps sets out: with the new section against the unchanged component it reports target-size and names the node count, and with the grown component it is green, both outputs quoted. The CI web job green on the pull request, which is where that project runs. V48's storyboard-strip measurement repeated at 1280 by 720 at all three aspect ratios: target-size gone from all three, the 32 tab stops per strip unchanged, and the strip still scrolling, so nothing this card grew changed what V48 proved. python3 infra/scripts/task-refs.py check . clean, and no comment this card writes names a card, an item, a phase or a wave. Record docs/ops/v55-acceptance.md; open item #135 closed.
Out of scope. Growing the tile instead of the control. It lost on V48's own measurement: the strip's caps are 693, 924 and 1218 px against a focused pane of 623 px, so the cap never binds at 1280 by 720 and a wider tile changes how many tiles are visible, how far the strip scrolls and what V48's inventory recorded, which is a re-measurement of another card's proof for no accessibility gain. Moving the control off the picture. It lost against the component's own contract at :8-11, that the top-right corner is where "open this bigger" goes on every picture because it is the one control that appears on all of them and has to be findable without looking; moving it would also break the beside-not-inside rule at :19-25 that keeps a click on it away from the control that picks. Any other axe rule, any other control's size, and a sweep of the tree for controls below the standard, which is a larger measurement and its own card. Changing what the control opens, or any lightbox behaviour.
Waits on / Unblocks. Nothing: V48 is merged and records this item, and no open card edits ImageOverlayButton.tsx, the gallery page or e2e/ui-gallery.spec.ts. It shares no file with the other lane V cards carded in this pass. Unblocks nothing directly, and gives every later storyboard tile a control that already meets the standard, so the rule cannot come back through a new caller.
Built as (PR #149 → main e212ea1, 2026-09-22). Code 1 review round → APPROVE with no must-fix and two wording points (the spec's comment on where the painted size comes from; the count of render sites once the gallery draws the control), written as a third commit and confirmed. Three commits in the card's Steps order: 3a5b1c1 the gallery section (a PictureTile helper on src/shell/pages/DevGallery.tsx draws the control through its real props on a w-20 tile, on and off, with no prop added) and the spec's one explicit test in e2e/ui-gallery.spec.ts (the section by its heading, the two named tiles, the real button locator enabled on one and disabled on the other, and the ON control's boundingBox() at least 24 by 24), with ImageOverlayButton.tsx untouched, so the gallery run against that commit is RED by design: target-size on exactly 1 node from both axe tests, and the box assertion at 21 against 24; 3ee7393 the minimum, min-h-6 min-w-6 with inline-flex items-center justify-center on the button's class, the icon left at h-3 w-3, the unit test that asserts the declaration (the unit suite processes no CSS, so the rendered box is the spec's assertion), the docstring's sentence on why the control carries a minimum, and the record; 18bd219 the two wordings. Measured: the built CSS carries min-h-6 and min-w-6 as calc(var(--spacing) * 6) with --spacing: .25rem, so 1.5 rem, which is 24 by 24 at a 16 px root and 31.5 by 31.5 at the app's 21 px root (index.css:109); the browser read 21 by 21 on the base build and 31.5 by 31.5 on the branch build; the gallery run on the head is 7 of 7 with 0 nodes. The eight product render sites (four in CharacterPanel.tsx, two in FrameSlots.tsx, one each in RenderHistory.tsx and NewVideoSession.tsx) keep the control in the picture's corner, because the corner is carried by the wrapper in control-hint.tsx and tooltip.tsx and not by the button; on the smallest tile, the 105 px strip tile, the control covers 30 percent of the width where it covered 20. V48's strip re-measurement at 1280 by 720 at the three aspect ratios, base and branch: the strip's content stays 1859 px in a 621 px box with 32 tab stops on every row, the strip and slot tiles unchanged; axe reports target-size on 15 nodes per ratio on the base and none on the branch. The record says, from the saved accessibility tree, that axe does not analyse the disabled control: the rule matches focusable widgets alone. Found and not fixed here, filed as open items: nothing type-checks the browser specs (e2e/package.json:5-8, no e2e/tsconfig.json, ci.yml:375-380); target-size is enabled: false in axe-core 4.13.0 and reaches these runs only through the tag list (e2e/ui-gallery.spec.ts:14), and the rule passes on any of size or offset, so a tree-wide sweep would under-report. Record docs/ops/v55-acceptance.md; open item #135 closed.
V56 · One markdown renderer serves every page that renders markdown · V · S · S2-W2 · fix/v/v56-one-markdown-renderer (surfaced while building V48, open item #136; carded 2026-09-20)
Context. Two pages render markdown through two declarations of the same component, and at cd8022a the two are the same code rather than two behaviours that have to be reconciled. The shared one is apps/web/src/components/ui/markdown-content.tsx: renderInline at :5-58 and the exported MarkdownContent at :66-233, in a file of 233 lines. The copy is in apps/web/src/pages/products/SessionView.tsx: MarkdownContent at :2232-2352 and renderInline at :2354-2385, in a file of 2,385 lines. Both line ranges moved from the ones the item recorded, :58-219 and :2233-2320, because V48 gave both copies the required name prop and the docstring that explains it. The sameness is measured, not assumed. With comments and whitespace stripped, the two component bodies are 2,596 and 2,589 characters and the difference is the export keyword, one semicolon after an i++, and parentheses around two arrow parameters; every class string, every regular expression, both aria-label templates, the three heading levels, the rule, the list and the paragraph branch are identical. The two renderInline functions differ only in an explicit : React.ReactNode return annotation and in where the JSX is parenthesised, with the same two regular expressions, the same class strings and the same nearest-match-first ordering rule. So the item's requirement that a shared renderer keep the session page's own inline-formatting behaviour is met by keeping the shared renderer exactly as it is: there is no behaviour there to carry over. Two more measurements the item did not say. SessionView.tsx imports nothing from @/components/ui/markdown-content, so its four call sites all reach the local declaration: :1404 with the name "the analysis", :1418 with "the cost report", :2060 inside ConceptsContent at :2047 and :2104 inside ReviewContent at :2093, which the page renders at :1405 and :1406 with the names "the concepts step" and "the review step". And apps/web/src/pages/intelligence/BriefViewer.tsx already imports the shared renderer at :15 and calls it at :175, so the item's instruction to re-point it is already true and nothing there changes. The shared renderer also has what the copy has not: its own test file, src/components/ui/markdown-content.test.tsx, and a gallery section at src/shell/pages/DevGallery.tsx:294, which the gallery's whole-page accessibility analysis covers on every pull request. The fold is therefore a deletion and an import, and the risk it carries is a silent regression in the four named documents rather than a merge of two dialects.
Read first. apps/web/src/components/ui/markdown-content.tsx (the whole file: renderInline, the docstring that says why the name is required, the two scroll regions and their numbering). apps/web/src/components/ui/markdown-content.test.tsx (the whole file: what is already pinned). apps/web/src/pages/products/SessionView.tsx:1395-1425 (the four panels and their names), :2040-2110 (ConceptsContent and ReviewContent, and what each does around the renderer) and :2232-2385 (the copy and its inline renderer). apps/web/src/pages/products/SessionView.test.tsx (the shapes a session test already renders). apps/web/src/pages/intelligence/BriefViewer.tsx:15 and :175, and BriefViewer.test.tsx. apps/web/src/shell/pages/DevGallery.tsx:285-300 (the gallery section and the document it renders). apps/web/src/components/ui/scroll-region.tsx (the region both copies push into, and how it takes its name). docs/ops/v48-acceptance.md:362-368 (the paragraph that files this item, and the note on per-document region numbering). Open item #136.
Deliverables. (1) SessionView.tsx declares no renderer of its own: the local MarkdownContent at :2232-2352 and the local renderInline at :2354-2385 go, and the page imports the shared component. (2) The four call sites keep their names exactly, "the analysis", "the cost report", "the concepts step" and "the review step", so the scroll regions a keyboard reaches are named as they are today and no document's regions are renumbered. (3) ConceptsContent and ReviewContent keep whatever they do around the renderer; only the renderer they reach changes. (4) A source sweep in the web suite, in the shape of the ones already there, that fails when any file under src other than components/ui/markdown-content.tsx declares a component or helper that renders markdown, by file and line, so the third copy cannot land quietly. It names SessionView.tsx:2232 and :2354 on the base sources and passes at zero on the branch. (5) The shared test file gains the cases the session page depended on and the copy's own tests never covered, each written against the shared renderer and each labelled in the record as preservation coverage rather than a base failure, because the behaviour is already on main in both copies: the three heading levels, the rule, the list, a pipe table with a separator row, a fenced block, and inline bold and code including the case where both appear and the nearer one wins. (6) A test in SessionView.test.tsx that each of the four panels renders its document through the shared renderer and that its region carries the panel's name, failing on the base sources for the reason that the page rendered its own copy. (7) The record docs/ops/v56-acceptance.md: the two declarations before and after, the normalised character counts that show they were the same code, the four call sites and their names before and after, the sweep's counts on both trees, and the rendered output of one document of each kind compared between the base and the branch.
Interfaces. Exposes one renderer for the whole web app, which every later page that shows markdown consumes rather than copies, and the sweep that holds that to one. Consumes MarkdownContent's current prop shape, content and the required name, unchanged, and ScrollRegion unchanged. No api call, no route, no stored document and no page's layout changes, and BriefViewer.tsx and DevGallery.tsx are not edited, because both already import the shared component.
Acceptance. npm run lint, npm run typecheck and npm run build clean in apps/web. npx vitest run src/components/ui/markdown-content.test.tsx src/pages/products/SessionView.test.tsx src/pages/intelligence/BriefViewer.test.tsx src/pages/products/a11y.test.tsx green. The two kinds of new test are labelled as such in the record, because only one of them can fail on the base. The session-page assertions of deliverable (6) are a base failure and are quoted failing on the base sources, for the reason that the page rendered its own declaration. The renderer cases of deliverable (5) are preservation coverage: the behaviour they pin already exists on main, in both copies, so they pass on the base by construction and no base failure is claimed for them; what is quoted instead is each case failing against a scratch renderer with that one branch removed, which shows the case reads the behaviour it names. The sweep passes at zero on the branch and, run against the base sources, names SessionView.tsx:2232 and :2354; a scratch copy of the renderer in a third file fails it. npm test in apps/web green over the whole suite, because the file count and the shared component both move. The gallery project's spec, e2e/ui-gallery.spec.ts, green, which is the accessibility run the session page's markdown now sits behind. python3 infra/scripts/task-refs.py check . clean, and no comment this card writes names a card, an item, a phase or a wave. Record docs/ops/v56-acceptance.md; open item #136 closed.
Out of scope. Changing what the renderer supports. The measured difference between the two copies is an export, a semicolon and two pairs of parentheses, so this card folds and does not improve: no new markdown syntax, no numbered lists, no links, no nested emphasis, and no change to the class strings or the region names, each of which would be a visible change to four documents on a card whose whole claim is that nothing visible moves. Replacing the hand-written renderer with a library, which is a larger decision about dependencies and about what the scroll regions do, and is its own card. BriefViewer.tsx and DevGallery.tsx, which already import the shared component and need no edit. Any other duplicated component in the tree, which is a sweep and not this card.
Waits on / Unblocks. Nothing: V48 is merged, it gave both copies the same required name prop, and no open card edits markdown-content.tsx, SessionView.tsx or the shared test file. It shares no file with the other lane V cards carded in this pass. Unblocks every later page that renders markdown, which now has one component to use and a sweep that refuses a second, and puts the session page's documents behind the gallery accessibility run that already covers the shared renderer.
A50 · The migration drill waits for the server it will use · A · S · S2-W2 · fix/a/a50-drill-waits-for-the-final-server (surfaced on main by A34's merge-commit run on 2026-09-21, open item #139; carded 2026-09-21)
Context. The production migration drill in miniature, infra/migrate/production/test.sh, starts two Postgres containers from the official image and waits for each with a bare select 1 over the container's unix socket: wait_pg at :45 for the old database and the loop at :113 for the new one, both docker exec … psql … -Atc 'select 1' >/dev/null 2>&1. The official entrypoint starts a temporary server to run its init scripts, stops it, and only then starts the one that stays; both answer that query, so a wait can be satisfied by a server about to go away and the next statement meets FATAL: the database system is shutting down. That is what run 35548215512 on 117e72d did at 00:38:04 UTC: the infra job's drill step failed with psql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed: FATAL: the database system is shutting down, exit 2, and the five previous main runs in which infra ran were green (35506013143, 35505380176, 35504808791, 35504121747, 35501986996). The change that run carried touched nothing under infra/migrate/. The repository already holds the rule for this: infra/backup/pg-ready.sh waits for the final server by its TCP listener (pg_isready -h 127.0.0.1), which the temporary server never opens because the entrypoint starts it with listen_addresses=''; its header records a restore drill that failed on exactly this and passed on re-run; restore.sh and infra/backup/test-restore.sh source it and infra/backup/test-pg-ready.sh proves the discrimination by holding the image inside its init phase, in the same infra job (.github/workflows/ci.yml:567-568). The migration drill never adopted it. Open item #139 holds the measurement.
Read first. infra/migrate/production/test.sh:40-50 and :108-116 (the two waits and the statements that follow them); infra/backup/pg-ready.sh whole (wait_for_final_postgres_tcp, its bound, its two exit codes and the sentence it prints); infra/backup/test-pg-ready.sh and infra/backup/test-restore.sh:20-55 (how a drill sources the helper and what its output looks like); infra/backup/restore.sh:30-40; .github/workflows/ci.yml:559-568 (the drill step and the pg-ready step beside it); open item #139.
Deliverables. (1) infra/migrate/production/test.sh sources infra/backup/pg-ready.sh and waits for each of its two containers through wait_for_final_postgres_tcp, with the helper's default bound; the two select 1 loops go, and the helper's exit codes end the drill with its own sentence instead of a later statement's. (2) The helper's header names the migration drill as its third caller, so a change to the helper is read against both drills. (3) docs/ops/a50-acceptance.md: the failure quoted with its run id and attempt, the five green runs that precede it, the drill's output with the two readiness sentences, and the run ids of the pull request's own infra job.
Interfaces. Exposes nothing new. Consumes wait_for_final_postgres_tcp as pg-ready.sh defines it. Unchanged: every check the drill makes, the images it runs, the infra job's steps and their order, the restore drill.
Acceptance. bash infra/backup/test-pg-ready.sh green locally. bash infra/migrate/production/test.sh green locally once, with SKYNET_API_IMAGE naming a local api image, its output carrying the helper's readiness sentence once per container and no select 1 wait left in the file (grep -c "select 1'" infra/migrate/production/test.sh returns 0 for the wait loops; the drill's own checks keep their statements). shellcheck infra/migrate/production/test.sh clean. On the base the same drill passes or fails by timing and cannot be made to fail on demand from outside the container, so the base proof is the measured failure itself, quoted in the record with its run id and attempt, and the helper's own test is the proof of the discrimination. The pull request's infra job green on one run, no retry. Record docs/ops/a50-acceptance.md; open item #139 closed.
Out of scope. The deploy workflow pushing a new latest api image while the same commit's CI pulls it: the drill's 59 s pull in the failing run was that, and it is timing and not cause; if it ever fails a run it gets its own item. Pinning the drill's api image by digest. The restore drill, which already waits correctly. Any change to what the drill checks.
Waits on / Unblocks. Nothing / every merge after 117e72d: under rule 5 a flake on main is fixed at its cause before the next merge, so this card merges first.
Built as (PR #135 → main 0662f76, 2026-09-21). The production migration drill in miniature no longer waits for its two Postgres containers with a bare select 1, which the official image's temporary init server answers as readily as the final one. infra/migrate/production/test.sh sources infra/backup/pg-ready.sh and waits for each container through wait_for_final_postgres_tcp, the rule the restore drill already followed: only the final server opens the TCP listener pg_isready probes, so the first acceptance is the boundary and no arbitrary sleep or consecutive-success heuristic is needed; the helper's own sentence and exit code end the drill when a server never comes up, and the helper's header names the migration drill as its third caller. The failure that surfaced it is quoted in the record: main's run 35548215512 on 117e72d, attempt 1, psql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed: FATAL: the database system is shutting down, after five earlier main runs with infra green and a change that touched nothing under infra/migrate/; its one diagnostic rerun passed, which classified the race and proved nothing. Locally the drill ran once, 63 PASS and 0 FAIL in 70 s, printing final PostgreSQL TCP listener ready once per container at 3 s and 4 s; the pull request's own infra job ran it against real containers once, green in 9 m 38 s on run 35549446301 with no retry; shellcheck is clean in the single-file form the card names, with a scoped SC1091 note on the one source line because the helper is resolved at run time and CI lints every infra script in one call. Review, one round on the readiness change, approved; two record and lint points fixed on the way. Record docs/ops/a50-acceptance.md; open item #139 closed. What stays outside: the deploy workflow pushing a fresh latest api image while the same commit's CI pulls it, which made the failing run's pull take 59 s and was timing rather than cause.
A51 · Every class the secret scan does not read is a written decision, and a first push cannot break its range · A · L · S2-W2 · chore/a/a51-every-unread-class-is-a-decision (surfaced while building A41, pre-existing on main, open items #147 and #149; carded 2026-09-22)
Context. Measured at main 4966cc2, which is A42 merged. The mandated scan is one command, bash infra/secret-scan/scan-tree.sh (AGENTS.md:39, docs/CI.md:152), and it has two halves over one private copy of the committable set (scan-tree.sh:103, :167-170, :172-174). Both halves pass over whole classes of file before a byte is read, and neither says so. The file half is gitleaks with useDefault = true (infra/secret-scan/gitleaks.toml:6-7), which brings the stock global path allowlist of the pinned 8.30.1 (ci.yml:25). Read out of the pinned binary itself, that allowlist holds 25 path patterns, and four of them match this repository: gitleaks\.toml, (?i)\.(?:bmp|gif|jpe?g|png|svg|tiff?)$, (?i)\.(?:docx?|xlsx?|pdf|bin|socket|vsidx|v2|suo|wsuo|.dll|pdb|exe|gltf)$ and (?:^|/)(?:deno\.lock|npm-shrinkwrap\.json|package-lock\.json|pnpm-lock\.yaml|yarn\.lock)$. Over the 2,888 tracked names at 4966cc2 they skip 130 paths: 72 PNG, 9 SVG, 9 JPG, 30 PDF, 1 XLSX, 8 lock files and the scan's own configuration. Every number the item carries holds at this commit, the 72 included, and the count only reads as 71 to a tool that takes git's quoted output, because one of the PNGs carries a narrow no-break space in its name. The archive half closes part of it and declares the rest. A41 opens the 54 skill bundles and the one workbook by their bytes, and a PDF is recognised by %PDF- and declared, never opened (infra/secret-scan/archives.py:47-54, scan-archives.py:446-451). Everything else is the hole: container_format knows ten formats (archives.py:50-77), PNG, JPEG and SVG are not among them, so a plain file is classified None and dropped with no count at all (scan-archives.py:447-448). So 100 of the 130 are read by nothing and counted by nothing, and the 30 PDFs are counted as a number with no reason and no inventory beside it: the reader holds the reason in why_opaque (archives.py:195-200) and scan-archives.py calls it nowhere, so the census line says "containers recognised and not opened 30 (pdf 30)" and stops there (scan-archives.py:411-416). A second unread set the item does not name is measured here: outside the allowlist, 60 tracked blobs hold a NUL in their first 1,024 bytes and the file half skips them as binary. Fifty-four are A41's bundles. The other six are one favicon, one video, three audio fixtures and, worth its own sentence, a tracked Python bytecode file at services/agent-runtime/dropshipping/automations/npt_tool/__pycache__/trend_watcher.cpython-314.pyc, which is committable content of exactly the shape ADR-022 wrote a rule about (:127-133); this card declares it so the coverage is complete, and taking it out of the tree is open item #164, because a tracked bytecode file is a defect of its own and it leaves by the mirror's upstream refresh route and not by this card. In bytes: the tracked tree is 195.4 MB, the allowlist skips 76.7 MB of it, the binary rule skips 28.6 MB, and the file half reads 90.0 MB. The PDFs are the large part of the question and the weakest case for reading. All 30 are under services/agent-runtime/, a root that is still a read-only mirror until its merge slice (AGENTS.md:22), they are 67.1 MB, and A34 already measured what an extractor gets from them (docs/ops/a34-acceptance.md:241-291). The standard-library route inflates 2,029 FlateDecode streams and matches nothing. A pinned third-party extractor reads no text at all from 22 of the 30, and those 22 are exactly the 20 screen captures and the two page exports: they are pictures of pages with no text layer, so an extractor cannot fail better, and only optical recognition would read them, which is a model and not a pin. The 8 it does read are two design exports, three vendor documents, one reference sheet and the two documents this house wrote, and they yield 231,916 characters between them. The other half of the card is the range step. ci.yml:1175-1179 builds RANGE from github.event.before on a push and hands it to gitleaks at :1181; :1200-1204 builds the same string again for the archive step at :1206, and the comment at :1185 says the second copy exists so the first keeps its shape. On the first push of a branch github.event.before is forty zeros, which names no commit, and gitleaks fails on git's own error. The archive runner treats that case (scan-archives.py:79-81 and :226-234, an all-zero base means every commit the head reaches) and the gitleaks step does not. The case cannot happen today: the workflow runs on pull requests, where the base is github.event.pull_request.base.sha, and on pushes to main alone (ci.yml:9-12), and a push to a branch that exists carries a real base. It becomes reachable the day one line of the trigger list gains branch pushes, and then every new branch's first push fails in a way that reads as a broken scanner.
Read first. AGENTS.md:39 (the one mandated command), :45 (rule 1, no secrets ever) and :51 (rule 7, and infra/scripts/task-refs-allow.json as the house's shape for a reviewed list of what a checker does not read, each entry with its reason). docs/CI.md:23 (the secrets row, and the sentence that says the 54 bundles are skipped as binary and the workbook by the stock path allowlist), :84 (the rule that a card changing a job's shape re-measures its floor), :105 (the floor row, 71 s) and :152 (the mandated scan written again). .github/workflows/ci.yml:9-12 (the triggers, and why an all-zero base is unreachable), :22-26 (the pins), :1160-1163 (the job and its 8 minutes), :1173-1181 (the commit-range gitleaks step, with RANGE at :1175-1179 and the command at :1181), :1182-1193 (the comment that states the two copies and why), :1198-1206 (the archive range step and its own copy of RANGE), :1207-1208 (the mandated scan) and :1209-1212 (the two behavioural tests). infra/secret-scan/gitleaks.toml:1-3 (what the fingerprints are, first 8 characters and a length and never a value), :6-7 ([extend] useDefault = true) and :8-19 (the rule against a paths clause, with its reason, which is the argument this card leans on and must not break). infra/secret-scan/scan-tree.sh:1-23 (what the set is and why), :103 (the set itself), :112-151 (the copy, name by name), :167-170 (the file half) and :172-194 (the archive half and the exit codes). infra/secret-scan/scan-archives.py:11-16 (the four modes), :60-81 (the reader path, the report name, the blob modes and ZERO_BASE), :226-234 (range_commits, which is the treatment the workflow lacks), :269-288 (the directory mode, the one mode that has to say what it will not read), :388-416 (the Census and its line), :432-451 (classification, the declaration and the silent drop of a plain file), :467-493 (the counters, the derived member path and the map that reads a finding back to its place, which this card labels by provenance) and :499-532 (scan_members, one gitleaks run over the member directory with this repository's config). infra/secret-scan/archives.py:14-22 (the reader's surface), :47-77 (the ten declared formats, the PDF entry and its stated reason), :100-101 (MEMBER_ROOT and why a derived path carries no member byte), :195-200 (why_opaque, which nothing prints today), :203-211 (container_format) and :257-269 (derived_path). docs/decisions/ADR-022-what-the-secret-scan-reads.md:83-141 (the seven decisions this card extends and does not reopen), :143-158 (the refused routes, and the written argument against a global path allowlist) and :160-166 (what the committable set costs, which is the paragraph this card's declarations sit beside). docs/ops/a34-acceptance.md:241-258 (the two readings of the 30 PDFs and the case for declaring them) and :260-291 (the per-file table, which is the inventory this card turns into a committed one). docs/ops/a41-acceptance.md:46-74 (the census over the tree), :100-102 (the all-zero base, named and left) and :377-392 (open item #147 as it was filed). infra/scripts/workflow-timeouts.json (the secrets entry, 71 s from run 35668752496). Open items #147 and #149.
Deliverables. (1) The decision, written into docs/decisions/ADR-022-what-the-secret-scan-reads.md as new numbered decisions under the existing seven, one per class, each with what it is and why: SVG is read, lock files are read, PNG and JPG are declared unread, PDF is declared unread, the scan's own configuration is declared unread, and the six binary blobs that are neither an archive nor a picture are declared unread by the same rule. The words it settles, in this shape: a class is read when its bytes are text a rule could match, and it is declared when reading it would need a dependency this repository will not pin or would read nothing that exists. The PDF decision is written as a bounded risk that a reviewer accepts, and never as a class that is safe: it accepts that a key pasted into a picture of a page is not found by this scan, it is bounded by an inventory that names every one of the 30 files so that an addition or a removal fails the check, and it names the two triggers that reopen it, being a deterministic reader this repository can pin that materially covers the text-bearing files, or a repository policy that raster or optically recognised content is scanned. The evidence under it is A34's own measurement (docs/ops/a34-acceptance.md:250-258). (2) The declaration is a committed list and not a number, modelled on infra/scripts/task-refs-allow.json as AGENTS.md:51 describes it: infra/secret-scan/declared-unread.json holds every path the scan does not read, with its class and the reason for that class, and scan-archives.py fails the run when a path it declares is not in the list, and when a path in the list is not in the set. So a new PDF, a new picture or a new binary fixture is a line a reviewer approves, and never a count that moves on its own. The list is what a census cannot be: a census reports what the run met, and a list enforces it, so a declared path the list does not hold and a listed path the tree no longer holds each fail the run. (3) The two text classes are read without touching a stock rule, by materialising them. A regular file whose path the stock allowlist skips and whose bytes are text is written into the private directory at a derived path (archives.py:257-269), which carries no byte of the original name and no extension at all, so no rule that reads an extension can skip it; it is read in the one member run that already exists (scan-archives.py:499-532) and reported at its path in this repository through the same map (:493, read back at :523-531). This is materialised allowlisted text and not an archive member, and the map has to say so: today every entry is a real member and carries a source, a member name and an index, so the card gives each entry an explicit provenance label, and a materialised file's entry names its repository path and invents no member name and no index. The card asserts the boundary rather than assuming it: nothing written under the private directory carries an original path, a member name or an extension, and every finding reads back to the source path it came from. Before any of it is built, the card settles the one question the measurement could not settle from the box: whether the pinned scanner's [extend] block can remove a path from the stock global allowlist is UNVERIFIED here, because the binary's own help documents precedence and not how an extended configuration is merged, so the card's first step reads the pinned upstream source for the answer and records it. If a removal key exists, the ADR weighs it against this route and says why it chose what it chose; the materialising route stands on its own reasons either way, because it leaves every stock rule and every stock allowlist exactly as they are, and nothing it does can hide a finding anywhere else. Whether a file is text is decided by its bytes, the way every other decision in this reader is made, and not by its name. (4) The census says what it declared and why. scan-archives.py prints the reason why_opaque already holds (archives.py:195-200), and the census line gains the declared classes the card adds, so the run's own output is the answer to the question the item asks. (5) ci.yml:1175-1179 treats an all-zero base before gitleaks sees it: when the base is forty zeros the step sets RANGE to the head alone and to no synthetic base of any kind, which is exactly the archive runner's rule, every commit the head reaches, git rev-list <head> (scan-archives.py:226-234), so the two halves of the range read the same commits. The second copy at :1200-1204 is left exactly as it is, because the runner behind it already treats the case, and the comment at :1182-1193 is corrected where it now says less than the truth. (6) The behavioural tests. infra/secret-scan/test-scan-archives.py gains the cases for the new classes and for the declared list. A new case set drives the range shell itself: the test parses ci.yml through the shared reader (infra/scripts/workflows.py), takes the secrets job's two range steps, substitutes the event expressions and nothing else, and runs the extracted run body exactly as written under bash with a stub on the path in place of the scanner, reading back the RANGE the stub was handed. What that proves is the script's construction of the range and not the scanner's own history semantics, and the card says so where the test is written. (7) docs/CI.md:23 says which classes the job reads and which it declares, and docs/ops/a51-acceptance.md carries the per-class table, the byte counts, the PDF inventory, the answer the first step read out of the pinned source, and the two range transcripts.
Interfaces. Exposes infra/secret-scan/declared-unread.json as the reviewed list of what the mandated scan does not read, one new failure of scan-archives.py for a declaration the list does not hold, and a provenance label on every entry of the report map, which is either an archive member or materialised allowlisted text. Consumes the pinned scanner, infra/secret-scan/gitleaks.toml and infra/secret-scan/archives.py as they are. Unchanged on purpose: every rule and every allowlist of the house configuration, useDefault = true, the committable-set contract of scan-tree.sh and its exit codes, the three git modes of scan-archives.py, the pre-commit hook, and the second range step. Three behaviours change. The one member run reads about 1.1 MB more text per scan, materialised from the two allowlisted classes, being the 9 SVG files at 30,702 bytes and the 8 lock files at 1,060,259 bytes. A finding inside one of those is reported at its path in this repository and never at a derived path. And a PDF, a picture or a binary fixture added to the tree fails the scan until it is written into the declared list, which is the point.
Acceptance. The gate is answered first: the card's first step reads the pinned scanner's own upstream source for whether an extended configuration can remove a path from the stock global allowlist, and that answer and the per-class answer are in ADR-022 and in the record before any other file is edited. python3 infra/secret-scan/test-scan-archives.py green, each new case named by what it proves and each labelled: an SVG carrying a canary built at run time is found and named at its repository path, RED ON THE BASE, where the base reports nothing; a lock file carrying the same canary in a resolved URL is found and named at its repository path, RED ON THE BASE; every materialised file's entry in the report map carries the provenance label and the source path, and carries no member name and no index, RED ON THE BASE; nothing written under the private directory carries an original path, a member name or an extension, GUARD for the archive members whose derived paths already hold that property and RED ON THE BASE for the materialised files, which do not exist there today; a PNG and a JPG carrying the same bytes are not read and are counted as declared, RED ON THE BASE, because the base counts them in nothing at all; a PDF is still declared and still not opened, and the census now carries its reason, GUARD for the declaration and RED ON THE BASE for the reason; the list holds all 30 PDFs, and a declared path the list does not hold and a listed path the set no longer holds each fail the run with their own sentence, RED ON THE BASE; an archive member is still found at its derived path with its member named, GUARD, because the map this card extends must not move; the scan's own configuration is declared and not read, GUARD. bash infra/secret-scan/test-scan-tree.sh green with every case of A42 unmoved, GUARD, and one added: both halves still always run once the copy is taken whole when the added reading finds something. The range cases, every one of them a unit test over the step's own script: an all-zero base makes the step hand the scanner the head alone, RED ON THE BASE, where the base hands it a range whose base is forty zeros and the step fails on git's own error; a real base on a push builds before..sha exactly as it does today, GUARD; a pull request builds base.sha..head.sha exactly as it does today, GUARD; and the archive step's own range is byte-identical to the base's, GUARD. THE TEST THAT COULD NOT BE WRITTEN, named here so it is not looked for: no live run proves the all-zero case, because the triggers cannot produce one (ci.yml:9-12), which is why every range case is a unit test over the step's own script with the scanner stubbed, and what those cases prove is the construction of the range and not the scanner's history semantics. The record carries the before and after of the census line over the whole tree, the declared list in full, the per-class byte counts, the 30 PDFs with their sizes and classes, and the answer the first step read out of the pinned source. shellcheck clean over every shell file the card touches. bash infra/secret-scan/scan-tree.sh clean, which is the mandated scan as AGENTS.md:39 names it. python3 infra/scripts/test-task-refs.py then python3 infra/scripts/task-refs.py check . green, so no comment the card adds carries a key. The card edits ci.yml, so the checks that read that file run beside the change and are green before the push, with the pinned reader on the path as the infra job supplies it: python3 infra/scripts/test-workflow-timeouts.py, python3 infra/scripts/workflow-timeouts.py check ., python3 infra/scripts/test-workflows.py, python3 infra/scripts/test-image-frontend-gate.py then python3 infra/scripts/image-frontend-gate.py check .. actionlint -shellcheck=$(command -v shellcheck) .github/workflows/ci.yml clean. The whole ci aggregate green on the pull request, and because the change edits ci.yml every job runs. THE FLOOR. The secrets job's floor is 71 s from run 35668752496 (infra/scripts/workflow-timeouts.json, docs/CI.md:105), and this card changes the job's shape, so by the rule at docs/CI.md:84 the floor is re-measured from the first green run of this pull request's shape-changing commit, and the floor, its run id and the re-derived timeout-minutes go into infra/scripts/workflow-timeouts.json, ci.yml and docs/CI.md:105 in ONE commit after that run; later runs are margin evidence and are not chased. What the reading adds to the job is 1,090,961 bytes of text through the member run, against the 90.0 MB the file half reads today, and what the declared list adds is one pass over the names; the card states that and lets the measurement say the number. Record docs/ops/a51-acceptance.md; open items #147 and #149 closed.
Out of scope. Reading the 30 PDFs, which loses today on A34's own measurement (docs/ops/a34-acceptance.md:250-258): the standard-library route matches nothing in 2,029 streams, and the third-party route needs a system package pinned on every builder's box for a command AGENTS.md:39 asks a person to run by hand, moves its line breaks with its version, and reads no text at all from 22 of the 30 because those 22 have no text layer to read. Not reading them is a bounded risk this card writes down and a reviewer accepts, bounded by the inventory and reopened by either trigger the ADR names, and it is not a statement that the class holds nothing. A41's archive members, which are read already and are closed (docs/ops/a41-acceptance.md:46-74). The pre-commit hook, which is an index scan by ADR-022's decision 3 (:107-112) and asks a different question on purpose. The dev-stack env files and every other ignored path, which ADR-022's decision 5 puts outside the mandated scan (:122-125) and which this card does not reopen. Any change to the committable-set contract, the wrapper's exit codes or its copy, all of which A42 settled and this card rests on. useDefault = false with a copy of the stock rules, which loses because the copy is roughly two thousand lines that move with every release of a binary this house pins in one place (ci.yml:25), and a stale copy silently loses new rules. A second scanner run over these files with the house rules alone, which loses for the same reason from the other side: the house file holds fingerprints and not the stock rule set, so that run would miss exactly the findings the stock rules exist to catch, which is most of what a lock file could ever carry. Any paths clause in infra/secret-scan/gitleaks.toml, which the config's own written rule refuses (:8-19) and which could not do the work in any case: a paths clause is an allowlist, an allowlist adds exclusions and never subtracts one, so no house list, however narrow, un-skips a path the stock list skips while the defaults stay enabled. Moving the second range step's logic or merging the two copies, which loses because the runner behind it already treats the case and a shared script would add a file, a test and a path-filter question to close a case that cannot occur. Taking the tracked bytecode file out of the tree, which is open item #164 and its own decision: a committed __pycache__ file is a defect whatever the scan does with it, and it leaves by the mirror's upstream refresh route, so this card declares it for complete coverage and removes nothing. The tracked video and the audio fixtures, which are material the tree keeps on purpose and are declared here for the same reason. Reading pictures with optical recognition, which is a model rather than a pin and is outside every rule ADR-022 writes.
Waits on / Unblocks. Waits on nothing. Everything it reads is on main at 4966cc2: the wrapper, the runner, the reader, the configuration, the job and the floor file. It shares three files with A9, the one open pull request on this surface (#118): .github/workflows/ci.yml, docs/CI.md and infra/scripts/workflow-timeouts.json. Neither touches the other's lines: A9's workflow hunks stand at :173-177, :381-405 and :1106-1124, the changes filter, the scraper job and the spike-a4 region, and this card edits the secrets job at :1160-1212 alone. All three files are still shared surface, so whichever of the two merges second is re-tested on the merge commit rather than on its own head. The other cards in build share nothing with it, measured against main: S46 changes six files and none is under infra/secret-scan/, .github/ or docs/CI.md; V56 changes six files and none of them either; S47 is cut from 4966cc2 with nothing on it yet. Unblocks any later card that wants to say which files the house reads: the declared list is the one place to add a class, and the reason beside it is what a reviewer reads instead of a stock default nobody chose.
A52 · A measured maximum leaves a floor by decision, not by falling out of a window · A · M · S2-W2 · chore/a/a52-a-floor-leaves-by-decision (surfaced while building A48, open item #151; carded 2026-09-22)
Context. Measured at main 0a09b90. The timeout rule has two halves and only one of them is code. The check derives each job's limit from a committed floor file and requires the declared value to equal it (infra/scripts/workflow-timeouts.py:26-30, derive at :98-101, the equality at :242-245), and it never reads a run history at all, which its own docstring states (:34-36) and A37's scope confirms. So the window is not in the check: the number 30 appears nowhere in infra/scripts/workflow-timeouts.py, and the only place the rule is written is prose, docs/CI.md:86, which says the maxima were measured over the last 30 completed runs of each workflow on main, counting only jobs that executed and were neither skipped nor cancelled, from each job's own start and completion timestamps. How that measurement is taken is in a record and not in a script: docs/ops/a37-acceptance.md:25 names the two commands, a run list bounded to the last 30 runs of one workflow on main and then one read of each run's jobs, so the window counts runs and not attempts, a run still in progress at the cut is left out, and the whole measurement needs the network and reads no cached file. There is no committed measurement script, and ls infra/scripts holds none. The second bound is the job's own shape: docs/CI.md:86 says a job's sample is bounded below by the last commit on main that changed that job's own steps, because a run of a job of another shape says nothing about this one, and the method that found those commits is docs/ops/a48-acceptance.md:685-689, a walk of the first-parent history of each workflow file comparing each job's steps, strategy, runs-on, services and container with its first parent's. That bound is also prose: the check cannot apply it, because the job that runs it checks out at the default depth of one commit (.github/workflows/ci.yml:510, and the only fetch-depth: 0 in that file is the secrets job at :1167), so no git history is on the box the check runs on. What an entry holds today is three fields and no more: max_seconds, run_id and an optional stand_in_for (workflow-timeouts.py:67), with a positive whole number of seconds required at :140-143, a non-empty run id at :144-149, the stand-in rules at :151-169, and any other key refused outright at :137-138 on the ground that a mistyped field would otherwise pass as a missing one. A new field for a longest-seen time sits beside those three and is refused by that same rule until the check reads it. The three numbers the item cites are confirmed exactly, and their explanation is not the one the item gives. The floor file's own history holds them: e1da5c4 created the file with build-web 344 s on run 35343454234, e2e 679 s on run 35346969507 and deploy-staging 207 s on run 35346969460, and 644a25c replaced all three with 105 s on run 35373768252, 514 s on run 35498445351 and 59 s on run 35510105388. Both sets can still be read there and in docs/ops/a48-acceptance.md:721-746, whose table carries every old maximum with its run beside the new one, and every one of the six runs is still readable by its id. Only one of the three left because of the window. The e2e run's head 5696d6d is not a descendant of b005937, the commit that changed that job's steps so it builds its images from the cache the deploy already warms, so the job that took 679 s no longer exists and its number was dropped by the shape bound and not by the window; the shape bound is also why the replacement is lower, since that commit's whole purpose was to make the job faster. The deploy-staging run's head is the same 5696d6d and is not a descendant of 4c9da68 either, and that commit's only change to .github/workflows/deploy.yml is comment text, inside that job's own remote script and in the file's header, so a job whose work did not change lost fourteen days of its sample to a comment sweep. Only build-web is the case the item describes: its run 35343454234 has head b9f63de, which is a descendant of 2283de7, the last commit that changed that job's steps, so the job that took 344 s is the job that runs today. Over all 86 completed runs of that workflow on main since 2283de7 that job's maximum is 344 s and its next two are 142 s and 112 s, and the slow run spent 315 s of its 344 in the image build step alone, which is the shape of a cold image cache and recurs whenever a merge busts the layer cache. Its floor now says 105 s and the limit derived from it is 9 (deploy.yml:91), while the limit derived from 344 s is 17, which is exactly what that job carried before the floors were re-measured (docs/ops/a48-acceptance.md:776). Nothing is unsafe today, as the item says and as docs/ops/a48-acceptance.md:696-703 measured: all three historical maxima fit the limits the new floors derive. What is wrong is the record, not the arithmetic. A number that was measured, committed and reviewed can be replaced by a smaller one with nothing in the diff to say that anything was lost, no run id to go back to and no sentence saying whether the slow run was a shape this job really has or a one-off. The cost of being wrong is asymmetric and the rule already says so (workflow-timeouts.py:8-12): a limit set too low cancels a run the pipeline legitimately permits and a red run on main freezes every merge, while a generous limit costs only runner minutes on a genuine hang. The rolling maximum is the one term that can only fall, and it falls silently.
Read first. infra/scripts/workflow-timeouts.py whole file, and in particular :26-43 (the docstring's statement of the rule, of the floor file and of the sentence that it never reads a run history), :56-57 (the bytecode rule set before the sibling import, which a new import obeys), :59-74 (the constants, the entry's field set, the ceiling and the table of step budgets), :77-79 (whole, the helper every number in the file is read with, and its guard against a boolean being read as 1), :98-101 (derive, the two terms and which one governs), :104-170 (read_floor, every finding it makes against the file, the unknown-field refusal at :137-138, the order the three fields are read in at :140-150 and the stand-in pass at :151-169), :173-186 (read_limit and the ceiling refusal at :184-185) and :218-260 (check, the derivation line at :239-245 and the self-reference at :247-257). infra/scripts/workflow-timeouts.json whole file, all 24 entries, the three fields they carry and the one stand-in at line 27. infra/scripts/test-workflow-timeouts.py whole file, and in particular :61-107 (the mutations, written against the shape of a line and not its text), :110-128 (the numbers the cases read back off the branch so a re-measured job moves a case instead of disarming it), :130-232 (the 24 cases), :237-242 (the four sentences checked against the numbers they now describe) and :245-261 (the two report calls, the run, and the ran-count guard at :257). infra/scripts/checker_testkit.py:92-120, which is where a case's expected sentence is judged and where every refusal is re-run against a checker that reads nothing, and which is why an assertion that a mutated copy still passes is made with report and not put in the case list. infra/scripts/workflows.py whole file, the reader both checks import and the one place a job's mapping comes from. docs/CI.md:74-122, the whole statement of the rule, and in particular :82 (what the floor file holds and what keeps it honest), :84 (the re-derivation rule, in bold, which is the only thing that re-measures anything today), :86 (the window, the shape bound and the one entry that does not come from main), :88-117 (both tables, with build-web at :111) and :118 (the stand-in's ground). docs/ops/a48-acceptance.md:678-756 (the floors as they were re-measured, the method, the three falls, the text this item was filed from at :709-719, and the table of old and new maxima with each job's last change of steps) and :757-783 (the derived limits before and after). docs/ops/a37-acceptance.md:25 (how the window is read, and with which commands). .github/workflows/ci.yml:125-165 (the infra path filter and the four entries that name this check, its test, the floor file and the shared reader), :500-512 (the infra job, its limit, its pinned reader and its checkout) and :540-557 (the four steps that run the two workflow checks). .github/workflows/deploy.yml:87-91 (the build-web job and the limit this card moves). A37's card, for what the rule promised and what it deliberately left out; A48's card and its Built-as note, which is where this item came from; A46's card, for the reader both checks import and for the rule that a card adding a step to a job re-measures that job. Open item #151.
Deliverables. (1) The rule, written once in docs/CI.md and once in the check's own docstring: a job's floor is the larger of two numbers, the maximum over the last 30 completed runs on main as it is today, and the longest run of that job anybody has written down since its last change of steps. The second number is a mark, and the mark exists to stop one thing, a number that was measured and committed being replaced by a smaller one with nothing in the diff to say what was lost. So the mark is not a new measurement anybody has to remember to take. It is a rule about what a re-measurement may do to a number the file already holds: a re-measurement that finds a lower maximum does not overwrite the old one, it either moves the old number into the mark with its run id and one sentence saying what made that run slow, or it deletes it and the deletion is a line in the diff that a reviewer reads. That is also the whole of how the rule tells a legitimate rare shape from a one-off, and the card says plainly that nothing automatic can make that call. A cold image cache, a cold dependency cache and a cold runner are shapes the job really has and they are kept; a stuck runner, a step that was retried and a box that was starved are not shapes of the job and they are deleted, with the reason in the record. A person decides once, at the one moment the number would otherwise be lost, and writes the reason next to it. What bounds the mark is four things and the card states each with its reason: it is cleared by a change of the job's steps, because a run of a job of another shape says nothing about this one; it must be strictly above the entry's own max_seconds, because a mark at or below the rolling maximum is a number the file already holds and would sit unread until it went stale, so when the rolling maximum catches up the mark goes in that commit; it must carry a run id and a reason, because a number nobody can argue with is a number nobody will ever remove; and a mark that would derive a limit above the ceiling (workflow-timeouts.py:66) is refused with its own sentence naming the mark, so a mark can never buy the six-hour default by the back door. A mark never expires on its own. A later re-measurement keeps it or deletes it, with its reason, and no rule drops it after a number of quiet runs: an automatic expiry is the same silent loss this card exists to stop, and a real change to the job's caching moves its digest and clears the mark anyway. (2) The field, its shape and its whole validation. The entry gains one optional key, high_water, an object of exactly four keys, seconds, run_id, shape and why, so the file stays one line per job and readable by hand, as in "build-web": {"max_seconds": 105, "run_id": "35373768252", "high_water": {"seconds": 344, "run_id": "35343454234", "shape": "<digest>", "why": "a cold image layer cache on the image build step"}}. high_water joins ENTRY_FIELDS (:67) so the entry's own closed field set keeps admitting it, and the mark is read after the entry's three fields and before the stand-in pass, so a refused mark refuses the whole entry and no limit is ever derived from a half-read one. Every condition below is a refusal with its own sentence, each naming the workflow, the job and what it found, in the shape read_floor already uses at :133-149: high_water is not an object; high_water carries a key this check does not read, named, on the same ground as :137-138; high_water has no seconds, so there is no number to hold the floor up; no run_id, so nobody can go back to the run it was read from; no shape, so nothing says which job the number was measured on; no why, so nobody can argue with it and nobody will ever remove it; seconds is not a positive whole number of seconds, read with the same whole helper max_seconds uses (:77-79), whose guard against a boolean is why true is not read as 1; run_id is not a non-empty string; why is not a non-empty string; seconds is not strictly above the entry's own max_seconds, naming both numbers; the entry is a stand-in and carries a mark of its own; and the mark's derived limit passes the ceiling. A job with no mark carries no key, derives exactly as it does today, and that is what a fresh job with no history gets: no mark, because a mark is a number somebody watched fall out and decided to keep, and a job that has never been re-measured has lost nothing yet. (3) The anchor that clears it, defined in full and computed by the check rather than written down. The check cannot read git history (ci.yml:510), so the anchor is not a commit id: shape is a digest of the job as it stands, taken from the job mapping the shared reader returns (infra/scripts/workflows.py), and it is defined by four things. What is selected: the keys container, runs-on, services, steps and strategy, each only when the job has it, and nothing else; these are the five the shape bound was measured with (docs/ops/a48-acceptance.md:687-689), and timeout-minutes is excluded by name because it moves with the floor and a digest that held it would clear every mark in the commit that wrote it. How it is serialised: json.dumps(selected, sort_keys=True, separators=(",", ":"), ensure_ascii=False), which sorts every mapping's keys at every depth and keeps every list in the order it has, so re-ordering a job's own keys does not move the digest while re-ordering, adding or removing a step does. What is hashed and how it is written: hashlib.sha256 over the UTF-8 bytes of that text, written as the first twelve characters of the lowercase hexadecimal digest, which is short enough to sit in a one-line entry and far more than enough to tell 24 jobs apart. How it is validated, three sentences and not one: a mark with no shape is refused as a number that says nothing about which job it was measured on; a shape that is not twelve lowercase hexadecimal characters is refused as malformed, naming the value, so a truncated or hand-typed digest never silently matches nothing; and a shape that is well formed but is not this job's current digest is refused as stale, naming both digests and the job, so the mark is re-measured against the new shape or deleted in that commit. The digest is taken over the job's content as the reader returns it, and comments inside a run: or a remote script block are part of that content. That is the decision, and its cost is stated: a comment sweep inside a step clears a mark. Clearing a mark loses a number that the next slow run re-establishes, while keeping one across a change nobody recognised holds a floor up with evidence from a job that no longer exists, and any rule that tried to decide which text inside a step can change a duration would be a judgement nobody can make reliably. The evidence is in the tree: 4c9da68 changed nothing in deploy.yml but comment text and it cut fourteen days off one job's sample. (4) The derivation reads the larger of the two. derive (:98-101) takes the floor rather than the maximum, the floor is max(max_seconds, high_water.seconds), and the line the check prints (:239-245) says which of the two governed and, when the mark governed, names the mark's run so the log stays the table nobody re-types. Every job's line gains its shape digest at the end, which is where a card that writes a mark reads the value it has to put in the file, so nobody computes a digest by hand. The stand-in's ground does not change: it stays the highest sampled max_seconds of its workflow and is never a mark, in the rule's prose at docs/CI.md:118 and in the table, because a mark is evidence about one job that ran and not a measurement another job can borrow. (5) The seeding, and it is exactly one entry. The card applies the new rule to what the file's own history already dropped: build-web gets a mark of 344 s from run 35343454234 with the cold image cache as its reason, so its floor becomes 344 s and deploy.yml:91 moves from 9 to 17, which the check then requires. e2e's 679 s and deploy-staging's 207 s get no mark and the record says why for each, naming the commit that changed the job's steps after that run, b005937 and 4c9da68. No other number moves. No max_seconds and no run_id in the file is touched and no fresh wider measurement is taken here: the only value this card seeds is the documented one that was committed and then fell out of its sample, and a floor that today's window would raise belongs to a re-measurement card, where it is one reviewed diff with its own runs behind it. (6) docs/CI.md: the floor sentence at :82 gains the mark, its four fields and what each one is for; the re-derivation rule at :84 gains the sentence that a re-measurement which finds a lower maximum either keeps the old number as a mark with its reason or deletes it, and that either way it is visible in the diff; :86 says that the window and the shape bound are what the mark survives and what clears it, and that the digest is what the check compares; :118 says that a stand-in takes the highest sampled maximum and never a mark; the deploy.yml table gains a column for the mark, empty for every job but one, with build-web's row carrying 344 s and 17 minutes. (7) The record docs/ops/a52-acceptance.md: the measurement below, the three cited numbers with the reason each one fell, the 86-run measurement of the deploy workflow since 2283de7, the seeded mark with its run and its step breakdown, the derivation table before and after, and the other post-anchor maxima this pass measured and did not act on.
Interfaces. Exposes high_water as the one place a floor keeps a number the rolling window will drop, the shape digest as the anchor that clears it and as a value printed on every derivation line, and the check's statement of which of the two numbers governed, which every later card that re-measures a job reads. Consumes the floor file, the check's own derivation and ceiling, the shared workflow reader and the infra job and its path filter, all of which are on main at 0a09b90. Unchanged on purpose: every existing finding and its wording; the two terms of the rule and the five-minute overhead (:62); the ceiling of 45 (:66); the table of step budgets (:69-74); the stand-in rules and the two fields they compare (:151-169), so a source that carries a mark is still repeatable by its borrower and a borrower still takes a sampled maximum; the floor file's other 23 entries, every max_seconds and every run_id in the file; and the sentence that the check reads no run history, which stays true because the mark is a committed number and not a query.
Acceptance. python3 infra/scripts/test-workflow-timeouts.py green. The case list goes from 24 to 44, the test gains three assertions in the shape of the two report calls at :249-252, and the ran-count guard at :257 becomes wanted = 5 + len(DESCRIBED) + 2 * len(CASES), which is 106 against the base's 54, so a case that stopped short is still visible in the last line. The twenty new cases, each one broken copy, each refused by its own sentence, each RED ON THE BASE, where the base refuses most of them only as an entry carrying a field it does not read, which is a different sentence: a mark that is not an object; a mark with a fifth key; a mark with no seconds; with no run_id; with no shape; with no why; seconds of 0; seconds of true, which the helper's boolean guard must refuse rather than read as 1; an empty run_id; an empty why; a shape that is not twelve lowercase hexadecimal characters; seconds equal to the entry's max_seconds; a stand-in entry carrying a mark; a governing mark with the declared limit left at the value the rolling maximum alone derives, which must name the derived number; a mark whose derived limit passes the ceiling, refused by the mark's own sentence and not by the ceiling's finding about the declared value; and five that pin the clearing boundary, one for each key inside the digest, where the copy's job gains a step, gains or changes strategy, changes runs-on, gains a services map and gains a container, and each must be refused as a stale shape naming both digests. Three assertions that a mutated copy still passes, which cannot sit in the case list because a checker that reads nothing would satisfy them, all RED ON THE BASE: re-ordering the job's own top-level keys leaves the printed digest the same string and the run green; moving build-web's timeout-minutes to 18 with the mark's seconds moved to 375 to match leaves that job's digest the same string and the mark accepted, which is why a floor move cannot clear a mark; and, the unmarked-derivation guard, stripping the mark from the floor file and putting deploy.yml:91 back to 9 leaves the check green and its output equal to the intact run's for all 23 jobs that carry no mark, so nothing about a job without a mark derives differently than it does today, GUARD. The 24 cases that were on the base run unchanged in what they assert, the stand-in pair and the four budget cases among them, proven by the ran-count guard against the new total, GUARD. Every one of the twenty new refusals is re-run against the checker that reads nothing and must fail there, which checker_testkit.py:117-120 does for every case in the list, GUARD. python3 infra/scripts/workflow-timeouts.py check . green on the branch with the seeded mark and the moved limit, and exiting 1 with the named job in three recorded mutations, each transcript in the record: the mark removed while deploy.yml keeps 17, which must fail because the derived number falls to 9; the mark kept while the limit is put back to 9, which must fail for the same reason from the other side; and one step of that job edited so its digest moves while the mark stands, which must fail naming both digests. python3 infra/scripts/test-workflows.py green, the reader unchanged. The card edits a workflow file, so the other check that reads it runs beside this one and both are green before the push, with the pinned reader on the path as the infra job supplies it: python3 infra/scripts/test-image-frontend-gate.py then python3 infra/scripts/image-frontend-gate.py check .. actionlint -shellcheck=$(command -v shellcheck) .github/workflows/ci.yml .github/workflows/deploy.yml clean, and shellcheck clean over the infra scripts. python3 infra/scripts/test-task-refs.py then python3 infra/scripts/task-refs.py check . green, so no comment the card adds carries a key. bash infra/secret-scan/scan-tree.sh clean, which is the mandated scan as AGENTS.md:39 names it. The infra job green on the pull request, and the record names which jobs the path filters selected, because this card edits deploy.yml and the scripts under infra/scripts and not ci.yml, so the aggregate does not run every job. The deploy run on the merge commit green, with build-web's own duration recorded beside its new limit so the record shows the margin that job actually had. No comment the card adds names a card, an item or a phase. Record docs/ops/a52-acceptance.md; open item #151 closed.
Out of scope. Re-measuring any job's floor, which stays what it is: the rolling maxima belong to the re-derivation rule (docs/CI.md:84) and to the floor commit an orchestrator writes from a pull request's first green run, and a card that moved them here would hide its own change inside a table of numbers nobody could attribute. This pass measured four post-anchor maxima above their committed entries while it was confirming the item's numbers, and it seeds none of them: a fresh floor change is its own re-measurement card, and the record states them so that card has them. Making the check read a run history, which A37 refused for a reason that still holds, that a check which queries the network is a check that can be red because a service is slow; the mark is a committed number and the check still reads only committed files. Any automatic expiry of a mark, for the reason in the rule: a mark that disappears after a number of quiet runs is the silent loss this card exists to stop, and a real caching change moves the digest. Stripping comments out of a step before the digest is taken, which would be a parser deciding which text inside a step can change a duration, a judgement nobody can make reliably, in exchange for a false clear that costs one number. Changing the stand-in's ground, which stays the highest sampled maximum of its workflow and never a mark, because a mark is a claim about a rare shape of the job that ran and nothing says the job that has never run has that shape. The step budgets A48 derived and the three remote command budgets they come from, which are a deploy card's work and need evidence from deploy runs. The workflow reader A46 built, which this card imports and does not touch, so a change to the grammar or to the pinned reader stays one file. The ceiling of 45, the five-minute overhead, the doubling in term (a) and the sum in term (b), none of which this card re-argues. Anything about job timeouts that is not the floor rule: step-level limits, the aggregate, retries, and making any job faster.
Waits on / Unblocks. Waits on nothing. Everything it reads is on main at 0a09b90: the check, its test, the floor file, the shared reader, both workflows and the infra job. It meets A51, which is in build, on three files and on no line. A51 edits .github/workflows/ci.yml at the secrets job, docs/CI.md at the secrets row and at :23, and the secrets entry of infra/scripts/workflow-timeouts.json when it writes that job's re-measured floor; this card edits .github/workflows/deploy.yml at build-web and not ci.yml at all, docs/CI.md at the floor-rule paragraphs :82-86, at the stand-in sentence :118 and at the build-web row :111, and the build-web entry of the floor file plus that file's field set. Two of those files are still shared surface, so whichever of the two merges second is re-tested on the merge commit rather than on its own head, and the second one also re-runs the timeout check, because A51's new secrets entry is read by the check this card changes. The one open pull request on this surface is #118, which touches .github/workflows/ci.yml, docs/CI.md and infra/scripts/workflow-timeouts.json; the same rule applies to it. The other two open pull requests, #136 and #137, touch none of this card's files, measured by their own file lists. Unblocks every later re-measurement, which now has somewhere to put a number it would otherwise drop and a sentence it has to write to drop one; and the next card that changes a job's steps, which finds the clear rule enforced by the check instead of remembered from a document.
A53 · A worker knows whether its queue writes landed, and tells a duplicate by its code · A · M · S2-W3 · fix/a/a53-a-worker-knows-its-writes-landed (surfaced while building A9's L-C1, open items #140 and #144; carded 2026-09-23)
Context. Measured at main 67beb194. A9's L-C1 made the analysis and transcription workers read the adapter's { data, error } envelope on every queue write, with one rule: the claim throws when it did not land (services/scraper/lib/analysis/worker.cjs:204-213, lib/transcription/worker.cjs:185-193), and the stale sweep and the release degrade with a logged line and a reason comment (lib/analysis/worker.cjs:182-190 and :222-230). Two workers still drop the envelope. The teardown worker ignores it on three writes: the stale sweep at lib/teardown/worker.cjs:250-255, the claim at :269-272 and the release of unlaunched rows at :314-317. When the claim fails, the tick still works the rows at :275-308 while they read pending, so a second engine (for example two containers during a deploy overlap) can claim and pay for the same rows. When the release fails, the rows stay processing until the 60-minute sweep (:48). When the sweep fails, nothing says so. The same file has a fourth defect that meets the release: a lane throw that is not a config error is re-thrown at :302-305, Promise.all at :308 rejects while the other lanes still run, the release at :312-318 never runs, and isRunning is cleared at :326 while lanes still hold rows. The throw comes from loadContext (:91, :107) and from the media load at :113. The ingest worker drops the envelope on all five mi_fetch_queue writes (lib/market-intel/ingest-worker.cjs:874-876, :886-890, :938-968, :996-1008, :1016-1021). Its claim is the mi_claim_work function and already throws (:821-832), so the item's claim premise is dead for this worker. What stays true: each failed write leaves the row processing, the lease hands it back after 900 s (:93) with one more attempt burned (db/migrations/unified/meta_ads/001_baseline.sql:1082), a finished thread is fetched again, the not_before and last_error it wrote are lost, and the queue run's counters and verdict (:1085-1090, :1092-1110) report work the queue does not hold. Last, one branch decides a database error by its text: lib/teardown/worker.cjs:374 tests the message for duplicate, while the envelope carries the SQLSTATE (packages/db/shape.cjs:32-46) and every other unique check in the engine reads 23505 (server.js:1721, lib/research/runner.cjs:166, lib/integration/jobs.cjs:21 and :102).
Read first. services/scraper/lib/teardown/worker.cjs whole file, in particular :36-52 (constants and isRunning), :73-79 (updateRow, which already logs its error), :85-113 (the context and media loads that throw), :230-328 (runTick) and :360-390 (enqueue, the duplicate branch at :372-385). lib/analysis/worker.cjs:176-232, the pattern this card copies. lib/market-intel/ingest-worker.cjs:815-1030 (drainQueue), :1085-1110 (verdict and counters), :1360-1375 (the run finish and the partial/database mirror at :1369) and :1519-1520 (_test). packages/db/shape.cjs:28-46. test/teardown-worker.test.js and test/market-intel-worker.test.mjs for their fake databases. docs/ops/a9-acceptance.md:1594-1622 (the ignored-error table) and :1836, :1913 (the findings that filed these items). Session-handoff lessons 12 and 18 on the parity CSV.
Deliverables. (1) Teardown claim: read the envelope at :269-272; on an error, throw teardown claim update failed: <message> with the analysis comment's reason ("a claim that did not land leaves the rows pending"), before any model call. (2) Teardown sweep and release: read the envelope; on an error, one console.error line and a "Deliberate degradation" comment that says why the tick goes on (the sweep runs again in five minutes; the rows return in 60). (3) Teardown lanes: a non-config throw is kept like configError (first one wins), it stops new launches, every lane settles, the unlaunched rows are released, and then the tick re-throws. isRunning is cleared only after that, so the finally at :326 stays the one place. (4) Teardown duplicate: a module constant UNIQUE_VIOLATION = '23505' and the test error.code === UNIQUE_VIOLATION at :374; the re-read and the created: false answer stay as they are. (5) Ingest: each of the five writes reads its error, logs one line naming the write and the row id, and adds one to counters.queue_writes_failed; the drain goes on. queueVerdict returns { outcome: 'partial', errorClass: 'database' } when that count is above 0 and the verdict is otherwise ok, the same rule :1369 applies to a failed sweep or scope read. queueVerdict is already in _test on the base; the tests use that export. The finish at :938-968 stays inside its try but its error is read before the catch at :969, so a failed finish is not counted as a fetch failure. (6) The claim comment at :268 says the ids the read returned are "this workspace's own", but the read at :257-264 has no workspace predicate. The comment is corrected to say what the read does; the behaviour stays with open item #143 and its read-scoping card. (7) Parity CSV regenerated with its own tool (npm run inventory in the spike), never merged by hand, as the last step of every commit that touches services/scraper. A regeneration that moves only a line number or a digest is mechanical and needs only the inventory checks below. A regeneration that changes a case, a verdict or a behavioural evidence cell is a semantic change, and that cell's parity leg runs and its result goes in the record. (8) The record docs/ops/a53-acceptance.md.
Interfaces. Exposes the teardown claim update failed sentence to the three callers of runTick, which already catch and log every throw (server.js:1201, :1221, :1266-1273) or answer 500 (:1255); none of them changes. Exposes queue_writes_failed as one more counter in the queue run's record, which sanitizeCounters already accepts (lib/market-intel/collection-run-audit.cjs:144-155). Consumes the envelope shape (packages/db/shape.cjs) and mi_claim_work unchanged. Unchanged on purpose: every constant (:36, :42, :43, :48; ingest :93-97), the claim read at :257-264 and its missing workspace predicate, the status values, the updateRow logging at :73-79, every existing counter and verdict branch.
Acceptance. cd services/scraper && npm ci --no-audit --no-fund && npm run test:unit, then cd admin && npm ci --no-audit --no-fund && npm test, as the scraper job runs them (.github/workflows/ci.yml:449-456). New tests in test/teardown-worker.test.js with a fake database that fails one named write: the claim fails, so runTick rejects with the claim sentence and the model is never called (RED ON THE BASE); the sweep fails, so one log line names it (RED ON THE BASE) and the tick still works its rows (GUARD); the release fails, so one log line names it (RED ON THE BASE); one lane throws a non-config error, so runTick rejects only after the other lanes settle, the unlaunched rows are pending and a second tick is not refused as running before that (RED ON THE BASE); an insert fails with code 23505 and a message without the word, so enqueue re-reads and returns created: false (RED ON THE BASE); an insert fails with code 23503 and a message that holds the word, so enqueue throws (RED ON THE BASE); a 23505 with no raced row throws (GUARD). New tests in test/market-intel-worker.test.mjs: each of the five writes failing in turn adds one to queue_writes_failed and the drain reaches the next row (RED ON THE BASE, five cases); queueVerdict with a failed write and an otherwise ok queue gives partial/database (RED ON THE BASE); with no failed write every existing verdict is the same (GUARD). The base totals 728 pass (test:unit), 14 in teardown and 57 in ingest; the record states the new totals. Parity: npm ci, npm run order:check and npm run test:ci in the spike as the spike-a4 job runs them (ci.yml:1182), after the regeneration, plus the parity leg of every cell whose case, verdict or behavioural evidence changed (none when the diff of the CSV is line or digest movement only; the record shows that diff). python3 infra/scripts/test-task-refs.py then python3 infra/scripts/task-refs.py check ., and bash infra/secret-scan/scan-tree.sh (AGENTS.md:38-39). No job's steps change, so no timeout check. The record docs/ops/a53-acceptance.md holds each RED run on the base and its green run. Open items #140 and #144 closed.
Out of scope. classifyError at lib/market-intel/collection-run-audit.cjs:127, which also reads driver wording to pick the class database: it is open item #181, a card after this one, because it receives errors that 57 wrappers in lib/market-intel rebuilt as new Error(message) and so lost their code, and fixing it means carrying the code through all 57. The isMissingTable fallback at collection-run-audit.cjs:88-93, because its text is the adapter's own refusal sentence (packages/db/allowlist.cjs:244, :303) and not driver wording. The missing workspace predicate on the teardown claim read (:257-264) and on the duplicate re-read (:375-380), which is open item #143 and its read-scoping card: it changes which rows a tick sees, a different question, and this card corrects only the comment. An attempts cap inside mi_claim_work, which is a migration and a policy choice. The Meta error match at scraper.js:1127, which reads an API error and not a database one.
Waits on / Unblocks. Waits on nothing; everything it reads is on main at 67beb194. It shares one file with #136 (A9b) and #137 (A9a): docs/spikes/a4-parity.csv, a generated file. It shares a surface with #136: that card's write guard walks the engine's caller chains under lib (test/write-guard*), and this card changes the writes in two of those chains. Merge order: A54 first, then #137 and #136 (each regenerates the CSV on its rebase after A54), then this card, then A55. Whichever of two cards on a shared surface merges second is re-tested on the merge commit, and the CSV is regenerated there, not merged by hand. Unblocks nothing named.
A54 · The quick-scrape child starts, and its exit code tells the truth · A · S · S2-W3 · fix/a/a54-a-child-exit-tells-the-truth (surfaced while building A9's L-C1, open items #141 and #182; carded 2026-09-23)
Context. Measured at main 67beb194. The item says the quick-scrape child exits 0 when it refuses to start, because services/scraper/scraper.js:2863 is main().catch(console.error). That is still true in the code, but on main it is hidden by a larger defect: the child does not load at all. scraper.js:17 imports CHILD_POOL_SIZE, openDataPath, requireStorageProvider by name from lib/data-path.cjs, and that file now exports { ...dataPath, CHILD_POOL_SIZE, ENGINE_POOL_SIZE } (lib/data-path.cjs:27-31, from A9 commit e4d9cac2; at 279171cf it was an explicit list). Node's CommonJS export scan does not see names inside a spread, so under Node 24 (the image's v24.13.0, from services/scraper/Dockerfile:67; also on the host's 24.20.0), node scraper.js stops with SyntaxError: Named export 'openDataPath' not found, exit 1 and no stdout, for every environment and even with no --url. The same failure stops download-ads.js:22 and migrations/export-data.mjs:9. server.js:39 and test/data-path.test.mjs:4 use the default import and are not hit. CI missed it because no test loads any of the three ESM entry points: the unit suite (728 pass) never imports scraper.js, and the import gate declares download-ads.js and migrations/ checkout-only (test/import-gate.test.mjs:66-78) and scans their text instead of loading them, so their ESM linkage is never exercised. Every quick scrape that server.js spawns (:764-781) fails on main; the parent reads code === 0 && result.success (:857) and stores the last 1000 characters of stderr as the job's error (:925). Once the load is fixed, the item's premise comes back: each refusal in main (requireStorageProvider at :2609, openDataPath at :2610 refusing an unset DATABASE_URL or an unknown WORKSPACE_SLUG, an unreachable database) rejects main, console.error prints it, and the process exits 0 with no summary; the parent then marks the job failed only because result.success is missing, and logs code=0 (:833). The same lie sits one level up. start.sh:75 runs wait -n $SCRAPER_PID $ADMIN_PID 2>/dev/null || true, so EXIT_CODE=$? at :76 is always 0 and the container exits 0 (:81) when the engine dies; the A9 record measured this (docs/ops/a9-acceptance.md:3756-3765), and it is open item #182, folded into this card because it asks the same question one level up. A container that exits 0 under restart: unless-stopped (services/scraper/docker-compose.yml:24) is restarted, but a stack that reads the exit status, which A2d builds, is told the engine stopped cleanly.
Read first. services/scraper/lib/data-path.cjs whole file and packages/db/data-path.cjs:157-166 (the shared module's eight exports). scraper.js:1-20 and :2598-2621 (main) and :2863. The sibling shape download-ads.js:305-307 and migrations/export-data.mjs:108-110, and tools/crawl-brand.cjs:35. server.js:764-781, :832-860 and :920-940. start.sh whole file (81 lines). Dockerfile:100-159. test/import-gate.test.mjs:66-78 and :3216-3231. docs/ops/a9-acceptance.md:1898 (the finding that filed #141), :3575-3600 (the built-image scan) and :3756-3765.
Deliverables. (1) lib/data-path.cjs exports every name explicitly: destructure the eight names from the shared module and list them in shorthand with the two pool sizes, so the CommonJS export scan sees each one. The ESM callers (scraper.js:17, download-ads.js:22, migrations/export-data.mjs:9) keep their named imports unchanged. A comment says why the list is written out. (2) scraper.js:2863 becomes main().catch((err) => { console.error(err.message); process.exitCode = 1; }): the code is set after stderr is written and the process ends when its work drains, so a piped stderr is not cut off by an immediate exit. The same shape replaces process.exit(1) in each sibling catch this card touches; it touches no other. (3) start.sh reads the wait -n status without || true: set +e, wait -n "$SCRAPER_PID" "$ADMIN_PID", EXIT_CODE=$?, set -e, and if the status is 0 it still exits non-zero, because either process ending is a failure of a long-running service. start.sh reads APP_DIR="${APP_DIR:-/app}" for its two cd lines so a test can run it. (4) The tests below. (5) Parity CSV regenerated with its own tool as the last step. (6) The record docs/ops/a54-acceptance.md.
Interfaces. Exposes a non-zero exit and the refusal sentence on stderr to the one reader of the child's code, server.js:857, whose job error then carries the sentence (:925); the parent does not change. Exposes a non-zero container exit to the stack, which A2d reads. Consumes the shared data path unchanged. Unchanged on purpose: every export name and value of lib/data-path.cjs, the order of the checks in main, the summary on stdout, the npm run scrape script (package.json:8), the image's CMD (Dockerfile:159).
Acceptance. cd services/scraper && npm ci --no-audit --no-fund && npm run test:unit, then cd admin && npm ci --no-audit --no-fund && npm test (.github/workflows/ci.yml:449-456). New tests, of two kinds. (a) A contract test: an .mjs file with a static named import of all eight shared exports and both pool sizes from lib/data-path.cjs (RED ON THE BASE: the test file fails to load), and a check that those ten names equal the shared module's export names plus the two pool sizes (GUARD, so a new shared export cannot be dropped silently). (b) Child-process tests that load each ESM entry point under the running Node, so this class of failure cannot come back: node scraper.js --url x with an empty environment exits 1 with the DATABASE_URL sentence on stderr, not a SyntaxError, and ends by itself within the test's timeout (RED ON THE BASE); the same with a set DATABASE_URL and an unknown storage provider exits 1 with the provider sentence (RED ON THE BASE); node download-ads.js and node migrations/export-data.mjs with an empty environment exit 1 with their own sentence and not a SyntaxError (RED ON THE BASE). A start.sh test: with APP_DIR pointing at a fixture whose fake node exits 3, the script exits non-zero (RED ON THE BASE, which exits 0). One local heavy proof, run alone: docker build -f services/scraper/Dockerfile services/scraper as AGENTS.md names it, the built-image scan as the A9 record runs it (a9-acceptance.md:3575-3600), and docker run with no DATABASE_URL, which exits non-zero (the base's measured exit is 0, a9-acceptance.md:3756-3765). Parity: npm ci, npm run order:check, npm run test:ci in the spike (ci.yml:1182). python3 infra/scripts/test-task-refs.py then python3 infra/scripts/task-refs.py check ., and bash infra/secret-scan/scan-tree.sh (AGENTS.md:38-39). No job's steps change, so no timeout check. The record docs/ops/a54-acceptance.md. Open items #141 and #182 closed.
Out of scope. A CI job that builds the image, which the A9 record names (a9-acceptance.md:3751) and which is a job, a floor and a budget of its own. download-media.js:234, which also ends with main().catch(console.error) but is not an ESM caller of the data path, opens no database, is not in the image (Dockerfile:105-123) and has no parent that reads its code. Shipping download-ads.js and migrations/ in the image, which #142 asked and which is already decided the other way (test/import-gate.test.mjs:66-78, :3216-3231, docs/CI.md:13). A retry or a restart policy in start.sh, which is the stack's job in A2d. The stale sentence in docs/ops/s1b-acceptance.md:74 that calls export-data.mjs a ready-made tool, because a record is not rewritten after it is closed.
Waits on / Unblocks. Waits on nothing; everything it reads is on main at 67beb194. It shares docs/spikes/a4-parity.csv (generated) with #136 (A9b) and #137 (A9a). It shares a surface with #136: that card's write guard scans scraper.js, download-ads.js, migrations/export-data.mjs and lib (test/write-guard* corpus), and this card edits the first and the data-path file every one of them imports. This card merges FIRST, ahead of #137 and #136, because it fixes a live failure of every quick scrape on main; those two rebase onto it and regenerate the CSV on their rebase after it. Merge order: this card, #137, #136, A53, A55. Whichever of two cards on a shared surface merges second is re-tested on the merge commit, and the CSV is regenerated there with its own tool, never merged by hand. Unblocks A2d, which runs the scraper as a stack service and needs a true exit status.
A55 · The legacy admin is gated by a type check CI runs, and no longer by a lint that reads nothing · A · S · S2-W3 · chore/a/a55-a-admin-typecheck-gates (surfaced while building A9's L-C2, open item #145; carded 2026-09-23)
Context. Measured at main 67beb194. The item holds. services/scraper/admin/package.json:9 is "lint": "next lint"; the tree has no ESLint configuration and no ESLint dependency (package.json:12-35, no .eslintrc* in services/scraper/admin), so npm run lint prints next 14.1's first-run prompt ("How would you like to configure ESLint?") and exits 0 without reading a file. No CI job runs it: the scraper job (.github/workflows/ci.yml:433-456) runs npm ci and npm run test:unit in services/scraper, then npm ci and npm test in services/scraper/admin, and nothing else. The item says the type check gates the tree meanwhile, but no CI job runs that either: tsc --noEmit appears in no step, and the only type check of the admin is the one next build runs inside the image build (services/scraper/Dockerfile:48), which no CI job runs (docs/ops/a9-acceptance.md:3751). So a type error in the admin reaches main unseen and fails the next deploy. npx tsc --noEmit passes on main today (exit 0, 5.78 s, over every .ts and .tsx file, admin/tsconfig.json:29-30). The three disable comments are admin/lib/db.ts:43 (no-var), admin/app/research/keywords/page.tsx:56 (react-hooks/exhaustive-deps) and admin/app/intel/gold/page.tsx:239 (@next/next/no-img-element). A trial with the toolchain next lint would read (eslint 8.57.1 and eslint-config-next 14.1.0, next/core-web-vitals) found 2 errors (react/no-unescaped-entities at admin/components/TagSelector.tsx:149:26 and :149:59) and 6 warnings (@next/next/no-img-element at AdDetailModal.tsx:70, AdsGrid.tsx:141 and :301, RankTable.tsx:210, RisingNowCards.tsx:77, WorkingLatelyCards.tsx:63). Adding that toolchain means ESLint 8, which is past its end of life, because next 14.1's next lint reads only the old configuration form, and the admin stays on next 14.1.0 until A19 retires it (open item #160). The recommended contract is therefore to gate the tree with the check that has value and costs no new toolchain, and to remove the script that only looks like a check.
Read first. services/scraper/admin/package.json, admin/tsconfig.json, .github/workflows/ci.yml:104-106 (the scraper filter, which holds ci.yml itself) and :433-456, docs/CI.md:13 (the scraper row) and :84-100 (the re-measure rule and the floor table, scraper at :98), infra/scripts/workflow-timeouts.py:190-193 (derive) and infra/scripts/workflow-timeouts.json:8, Dockerfile:40-50. docs/ops/a9-acceptance.md:2372-2377 and :2405-2410 (the L-C2 lint finding). Open item #160 and the A19 card.
Deliverables. (1) admin/package.json: remove "lint": "next lint"; add "typecheck": "tsc --noEmit". (2) ci.yml scraper job: one step npm run typecheck with working-directory: services/scraper/admin, after the admin's npm ci and before its npm test, so a type error fails a step named for it. (3) docs/CI.md:13: the scraper row names the new step, and one sentence records the decision: the legacy admin has no lint, because the only toolchain its framework reads is past its end of life and the tree is retired by A19; the type check is its gate. (4) The three disable comments stay, each with no change, because A19 deletes the tree and no lint reads them. (5) The floor: the job's steps change, so the job is measured again from this pull request's first green run and the floor commit is written by the orchestrator, as docs/CI.md states; the declared timeout-minutes: 13 (ci.yml:438) stays unless the new floor derives another value, and that floor commit is required before the merge. (6) The record docs/ops/a55-acceptance.md with the trial counts above, the base's lint output and exit 0, and the new step's time.
Interfaces. Exposes one blocking step in the scraper job. Consumes the admin's tsconfig.json and its installed typescript (package.json devDependency ^5.3.3) unchanged. Unchanged on purpose: the scraper job's filter, every other step, the admin's build, test and dev scripts, the image build, the three comments.
Acceptance. The scraper job's commands as ci.yml runs them after the change: npm ci --no-audit --no-fund and npm run test:unit in services/scraper, then npm ci --no-audit --no-fund, npm run typecheck and npm test in services/scraper/admin, all green. Tests: a scratch copy of one admin file with a type error makes npm run typecheck exit non-zero (RED ON THE BASE, where no step runs it; the record shows the run and removes the copy); npm run typecheck on the tree exits 0 (GUARD); npm run lint in the admin is refused as a missing script (RED ON THE BASE, where it exits 0). python3 infra/scripts/test-workflows.py, python3 infra/scripts/test-workflow-timeouts.py and python3 infra/scripts/workflow-timeouts.py check ., and actionlint on ci.yml as the infra job runs it. python3 infra/scripts/test-task-refs.py then python3 infra/scripts/task-refs.py check ., and bash infra/secret-scan/scan-tree.sh (AGENTS.md:38-39). No file under services/scraper other than the admin manifest changes. The builder runs the spike's npm run inventory:check: if the inventory reads admin/package.json, the CSV is regenerated with its own tool and this card joins the CSV's shared surface; if not, the CSV is not touched and the card makes no shared-surface change. The first green run's floor commit is on the branch before the merge. The record docs/ops/a55-acceptance.md. Open item #145 closed.
Out of scope. Adding ESLint and fixing the 2 errors and 6 warnings, for the end-of-life reason above; the count is recorded so the choice can be read later. Removing the three disable comments, because no lint reads them and A19 deletes the files. Upgrading next to reach the flat configuration, which open item #160 holds back until A19. Adding the image build to CI, which is its own job and floor (a9-acceptance.md:3751). A type check of the engine's JavaScript, which is not TypeScript and has no configuration.
Waits on / Unblocks. Waits on #137 (A9a). #137 edits services/scraper/admin/package.json on the line after "lint" (it adds "test:unit": "vitest run") and edits admin/app/research/keywords/page.tsx and admin/app/intel/gold/page.tsx, which hold two of the three comments, and about a hundred admin files the new step type-checks. So #137 merges first, this card rebases onto it, and whichever merges second is re-tested on the merge commit; the new step must be green on #137's tree, not only on today's. It shares no file with #136 (A9b), which edits only services/scraper/package.json, not the admin's. Merge order: A54, #137, #136, A53, then this card. Unblocks nothing named.
A56 · The start script's test waits for the stopped admin to be gone before it calls it left behind · A · S · S2-W3 · test/a/a56-a-start-sh-test-waits-for-the-stop (surfaced on A9a's pull-request run 35930087590, open item #185; carded 2026-09-23)
Context. Measured at main f6cea751. A54 added services/scraper/test/start-sh.test.mjs. Its case "the fix: the service still running when the other ends is stopped, not left behind" (:150-154) runs start.sh with an engine stand-in that exits 3 and an admin stand-in that runs sleep 300, then asserts at once that the admin PID no longer answers process.kill(pid, 0) (stopIfRunning, :110-121). start.sh:89 sends the stop with kill "$SCRAPER_PID" "$ADMIN_PID" and then exits at once (:90-97); it does not wait for the stopped process to end, and the process is its child, so after the script exits it is an orphan that the machine's init must reap. The test reads the PID file and checks the PID inside the close handler of the script (:94-104), which can run before the signal is acted on or before the orphan is reaped: a zombie still answers signal 0. On A9a's pull-request run 35930087590 (head b55830a0, which does not touch start.sh or the test) the case failed with "the admin stand-in was still running after start.sh exited" after 5018 ms, which is the script's own two sleeps, so the script itself ran normally. On this box the same case passed 15 of 15 runs on the same code. It passed on A54's pull-request and merge runs. So the defect is in the test's timing, not in start.sh: the test checks a stop that has been sent but has not yet completed.
Read first. services/scraper/test/start-sh.test.mjs whole file (161 lines), especially :39 (EXIT_WITHIN_MS), :75-106 (runStart), :110-121 (stopIfRunning) and :150-154. services/scraper/start.sh:80-97. docs/ops/a54-acceptance.md for what the case was written to prove. The failed log: gh run view 35930087590 --log-failed (the scraper job).
Deliverables. (1) The check becomes a bounded wait: after start.sh exits, the test polls the admin PID until it is gone or is a zombie (its state in /proc/<pid>/stat is Z, which means it has ended and only waits to be reaped), for at most a named grace period of 2 s, polling every 20 ms. A PID still running and not a zombie at the end of the grace period fails the case with the same message as today, and the stand-in is then stopped with SIGKILL, so nothing is left on the machine. On a machine without /proc, the wait falls back to signal 0 alone. A comment says why the wait exists: the script sends the stop and exits without waiting, so the stop can be in flight when the script's exit is seen. (2) start.sh does not change: a script that exits before its stopped child ends is correct for a container, whose runtime ends every process in the container when the main process ends. (3) The record docs/ops/a56-acceptance.md: the failed run, the local 15 of 15 passes, the cause, the fix, the proofs, "open item #185 closed".
Interfaces. Test-only. No product file changes. The case keeps its name, so the record of A54 still points at it.
Acceptance. Targeted test locally: cd services/scraper && node --test test/start-sh.test.mjs, and the case alone in a loop of 50 runs, all pass. New proofs: (a) a test of the new predicate: the old immediate probe can observe a child that is signalled but not yet reaped, which a deterministic stand-in that ends 200 ms after the stop shows; the new bounded probe accepts a PID that is absent or a zombie (/proc/<pid>/stat), with kill(pid, 0) only as the fallback. The predicate did not exist on the base, so this is not labelled RED ON THE BASE. (b) GUARD: a stand-in that ignores SIGTERM (trap '' TERM; exec sleep 300 is not enough, because exec resets the trap, so use a bash loop that traps TERM) is still called left behind after the grace period, and it is killed. This proves the wait did not weaken the check. (c) GUARD: a zombie counts as gone. Then the scraper's CI job runs the full test:unit on the push (owner rule: full suites only in CI). python3 infra/scripts/test-task-refs.py then python3 infra/scripts/task-refs.py check .; the mandated scan from AGENTS.md; gitleaks git --redact with the repo config over origin/main..HEAD. docs/spikes/a4-parity.csv is regenerated with its tool only if the inventory reads this test file; the builder says whether it does.
Out of scope. A wait for the killed child inside start.sh, which would change a correct script to fit a test. A retry of the case, which would hide the race rather than end it (rule 5: no retry-until-green). The other three cases of the file, which check exit codes and do not depend on the moment a stopped process ends.
Waits on / Unblocks. Waits on nothing; everything it reads is on main at f6cea751. It unblocks every open pull request whose scraper job runs the full test:unit: #137 (A9a), #136 (A9b), A53. Rule 5 puts it first: it merges before #137, and the pull requests after it re-run on the new main. It shares no file with #137, #136 or A53, except the generated parity CSV if the inventory reads this test.
A57 · A database error keeps its code, and the run ledger classes it by what it is, not by its words · A · M · S2-W3 · fix/a/a57-a-database-error-keeps-its-code (surfaced while carding A53, open item #181; carded 2026-09-24)
Context. Measured at main fd299d3c (A53 merged). classifyError (services/scraper/lib/market-intel/collection-run-audit.cjs:112-137) picks the class database with one text test at :127: a table name before failed, Failed to (store|load|link), load failed, or the driver words permission denied, violates, duplicate key, PGRST. The comment at :124-126 puts that test before the timeout test, so that a statement timeout inside a wrapper is a database failure. The adapter's envelope keeps the SQLSTATE and the constraint (services/scraper/packages/db/shape.cjs:17-21, toError at :32-46). Each wrapper then drops them, because it builds new Error(<what> failed: ${error.message}). The item counts 57 wrappers in lib/market-intel. The tree has 60, and it had 60 at 67beb194 too: amazon-worker.cjs 9 (:83, :98, :112, :133, :153, :181, :213, :289, :347), trustpilot-worker.cjs 9 (:79, :99, :116, :137, :162, :192, :240, :315, :373), keyword-retagger.cjs 9 (:87, :118, :144, :172, :199, :244, :319, :404, :441), youtube-worker.cjs 11 (:119, :140, :155, :163, :187, :226, :236, :245, :261, :279, :302), ingest-worker.cjs 16 (:170, :209, :259, :302, :359, :658, :682, :696, :844, :1067, :1219, :1243, :1271, :1306, :1465, :1518), collection-run-audit.cjs 4 (:196, :226, :269, :289) and exports.cjs 2 (:123, :253). Two more wrappers outside lib/market-intel reach classifyError. lib/app-settings.cjs:42 (settings read failed: …) is thrown by getSetting, which each tick's config calls before its run opens (for example trustpilot-worker.cjs:49-55 and :320-330, where audit.recordRun records the error). lib/deep-crawl/session.cjs:31 (Failed to load residential settings: …) is thrown through residentialReadyStrict (:93-101) at amazon-worker.cjs:301, trustpilot-worker.cjs:327 and ingest-worker.cjs:1165. So 62 wrappers in 9 files feed the classifier. The text test gets real cases wrong on this tree. settings read failed: … matches no branch, so a settings read that fails with a driver message such as Connection terminated unexpectedly is recorded as unknown, although the comment at trustpilot-worker.cjs:326 calls it a database error. purge failed: … (ingest-worker.cjs:1465) and count failed: … (youtube-worker.cjs:302) also match no branch. A driver message that holds quota (SQLSTATE 53100, disk quota) is recorded as quota_exhausted, because :118 runs before :127. And a plain error whose text holds duplicate key is recorded as database whatever made it. The item's fix, "classify by the code", cannot work alone. The envelope's code is null or a Node error name (ECONNREFUSED) when the connection fails (packages/db/db.cjs:320-325 and :596-601 put any exec throw in the envelope). A five-letter HTTP error name such as EPIPE has the same shape as a SQLSTATE. So the class comes from where the error came from, which is the envelope, and the code travels with it for every branch that needs it. That is the A53 rule, applied one level up: decide a database error by what it is, never by its words.
Read first. services/scraper/lib/market-intel/collection-run-audit.cjs whole file, in particular :20-24 (what a row may hold), :80-93 (isMissingTable, which stays), :106-137 (classifyError), :157-169 (recordAuditFailure) and :171-300 (the four wrappers of its own). services/scraper/packages/db/shape.cjs:1-48 and packages/db/db.cjs:315-330, :590-603. lib/app-settings.cjs:30-45 and lib/deep-crawl/session.cjs:27-35, :85-101. Each of the 62 wrapper lines above, and the one consumer of the class outside the ledger, ingest-worker.cjs:1032 (last_failure_class). test/market-intel-collection-audit.test.mjs:145-155 (the no-leak test, which builds a plain error at :149), :157-170 (the class table, :167 is a text case), :490-516 (:511 rebuilds the error from its message) and :655-680 (the settings read). docs/ops/a53-acceptance.md sections "The parity CSV" and "Found and not fixed here", and open item #184, which explains why a line move drops a verdict.
Deliverables. (1) A new module services/scraper/lib/db-error.cjs with one class and one function. class DatabaseError extends Error has name = 'DatabaseError', code and constraint copied from the envelope error (each ?? null), and a message that is byte for byte the message the wrapper builds today. databaseError(what, error) returns new DatabaseError(${what}: ${error.message}, error), where what is the wrapper's current prefix without the colon, so every log line, every last_error and every test that matches a message stays as it is. The fields are copied and not put in cause, so a log that prints the whole error does not also print the envelope's details, which can hold row values. A comment says why the class exists: the envelope is the only witness that a failure came from the database, and its code alone cannot say so. (2) Each of the 62 wrappers becomes throw databaseError('<prefix>', error) on its own line, one line for one line. The two claim sites that pass an error without a throw (amazon-worker.cjs:112, trustpilot-worker.cjs:116) pass databaseError('mi_scopes claim failed', claimErr). Each of the 9 files gets one require line: in the five workers it goes right after the audit require (amazon-worker.cjs:22, trustpilot-worker.cjs:21, keyword-retagger.cjs:37, youtube-worker.cjs:29, ingest-worker.cjs:25), and in the other four after the last existing require or after 'use strict'. (3) classifyError: the first test after the null check is if (err instanceof DatabaseError) return 'database';, so no word in a driver message can move a database error to another class. The text test at :127 and its comment at :124-126 are deleted. Every other branch stays in its order with its words. (4) A test module test/db-error.test.mjs, and new cases in test/market-intel-collection-audit.test.mjs, listed under Acceptance. The three existing tests that build a database error from text (:149, :167, :511) build it with databaseError or pass the reported error object itself. A test's fake database returns the envelope the adapter returns; a test is not kept green by keeping text. (5) Parity CSV regenerated with its own tool (npm run inventory in spikes/intelligence-data-path), never merged by hand, as the last step of every commit that touches services/scraper. The require lines move every row below them by one line. Five rows carry continuation_lines: amazon-worker.cjs:480, trustpilot-worker.cjs:486, keyword-retagger.cjs:106, youtube-worker.cjs:296 and ingest-worker.cjs:1259, all PASS on the base. On the base the row evidence hashes those lines (open item #184). A59 removes them from the row evidence, and this card waits on A59. After A59, the require lines move these rows as layout only. The layout moves carry every verdict, case and follow-up unchanged. But tools/classify.cjs is a run-digest input (the parity tests require it), so the literal moves below put all 371 measured port rows at TBD with STALE.run. The card ends with one restamp commit, bin/closed-run.sh from the clean source commit, and A59's comparison script run against it shows every row equal and the header different only in generator_digest, run_digest and ladder.source, which counts the new module (206 to 207). Every other row must move by line only. The spike's line literals that name a moved line move with it: tools/classify.cjs:126 and :136 (app-settings.cjs:39 and :30), tools/classify.cjs:166 (ingest-worker.cjs:163), test/parity-manifest.test.cjs:1923 (ingest-worker.cjs:835) and the comment at test/parity/cases/features.cjs:79-80. The historical anchors in test/inventory.test.cjs:519-534 and the engineGoverned list at :563-566 name old lines on purpose and stay; the builder checks that no row lands on a site that list says is empty. (6) The record docs/ops/a57-acceptance.md. Added at review, because the literal moves touch the evidence of two annex rows: (7) the settings-fallback note and its FOLLOW_UPS text say that a failed settings read throws, and the precedence applies only after a read that succeeds and finds the row missing or empty; the research inner-embed comment names today's reads. (8) A row EXPLAINED at generation also hashes its disposition's FOLLOW_UPS text into row_evidence, and carryForward returns the fresh row, with its generated follow_up, when that evidence changed; the CSV header's evidence line says so. The comparison then shows row_evidence changed on the 26 generated EXPLAINED rows and follow_up on the settings-fallback row alone.
Interfaces. Exposes DatabaseError and databaseError from lib/db-error.cjs to the 9 files. Exposes to the ledger (mi_collection_runs.error_class, read by an outside party through llm_reporting) the same set of classes and sentences (ERROR_MESSAGES, :40-59), with a truer choice between them: a failed settings read, purge or count is database and no longer unknown, and a driver message that holds quota, 429, rate limit, not configured or timeout is database and no longer another class. ingest-worker.cjs:1032 gets the same answer through the same function. Consumes the envelope (packages/db/shape.cjs:32-46) unchanged. Unchanged on purpose: every error message, mi_scopes.last_error and mi_fetch_queue.last_error, isMissingTable and its text test (the adapter's own refusal sentence, which has no code, :83-86), every other classifyError branch and its order, the ledger's no-raw-text rule, packages/db.
Acceptance. Targeted tests locally: cd services/scraper && npm ci --no-audit --no-fund, then node --test test/db-error.test.mjs test/market-intel-collection-audit.test.mjs test/market-intel-worker.test.mjs test/market-intel-exports.test.mjs test/app-settings.test.js. The full npm run test:unit and the admin's npm test run in CI on the push, as the scraper job runs them (.github/workflows/ci.yml:445-462), and not locally (owner rule: full suites only in CI). New tests. RED ON THE BASE: a trustpilot tick whose single-key settings read returns { code: null, message: 'Connection terminated unexpectedly' } records its tick run with error_class database (the base gives unknown); the ingest purge failing with that envelope records database (base unknown); a wrapped 53100 whose message holds quota is database (base quota_exhausted); a wrapped 57014 statement timeout is database; a plain new Error('duplicate key value violates unique constraint') that no wrapper built is not database (base database); a DatabaseError whose message holds 429 or not configured is database (base source_rate_limited and not_configured); a static scan of the 9 files finds no new Error( that interpolates an envelope's .message (base 62). GUARD: databaseError keeps the message byte for byte and copies code and constraint, and a null envelope code stays null; an HTTP error with code: 'EPIPE' is not database; a DatabaseError whose message holds Deadline is still database; every row of the class table at :157-170 other than :167 gives the class it gives on the base; the no-leak test at :145-155 still finds no secret in the ledger row. The record states the base total (756 pass in test:unit at A53's head) and the new total from the CI run. Parity: npm ci, npm run inventory:check, npm run order:check, node --test test/inventory.test.cjs test/parity-manifest.test.cjs test/evidence.test.cjs in the spike after the regeneration, a check that every verdict, case evidence and follow-up in the regenerated CSV is equal to the base (only line cells move; 0 TBD), and grep -rnE '(amazon-worker|trustpilot-worker|keyword-retagger|youtube-worker|ingest-worker|collection-run-audit|exports|app-settings|session)\.cjs:[0-9]+' test tools in the spike, with each hit read against the tree. The spike's test:ci runs in the spike-a4 job. python3 infra/scripts/test-task-refs.py then python3 infra/scripts/task-refs.py check ., and bash infra/secret-scan/scan-tree.sh (AGENTS.md:38-39); gitleaks git --redact with the repo config over origin/main..HEAD as the last step. No job's steps change, so no timeout check. The record docs/ops/a57-acceptance.md holds each RED run on the base and its green run, the count of 62 wrappers by file, and the CSV comparison that shows every verdict carried unchanged. Open item #181 closed.
Out of scope. The five wrappers in lib/integration/jobs.cjs (:103, :110, :124, :160, :182): server.js calls them outside any collection run (:1810-1990), so they never reach classifyError, and :102 already reads 23505 before it wraps. Wrappers in other engine directories (teardown, analysis, research), which no run ledger classifies. An AdapterRefusal thrown by the adapter (packages/db/guards.cjs:15-21): it is a code defect, not a database failure, and it stays unknown, which sends the reader to the server log. A new ledger class or a code column in the ledger, which changes what the outside party reads. The row evidence that hashes absolute lines, which is open item #184 and card A59, on which this card waits.
Waits on / Unblocks. Waits on A59, which makes a line-only move keep its verdict; without A59, this card's require lines would drop five verdicts to TBD. Everything else this card reads is on main at fd299d3c. It shares the generated docs/spikes/a4-parity.csv with every open card that touches services/scraper. Whichever of two such cards merges second is re-tested on the merge commit, and the CSV is regenerated there with its own tool, never merged by hand. It shares ingest-worker.cjs and test/market-intel-worker.test.mjs with no open card. Unblocks nothing named.
A58 · A quick scrape that failed exits non-zero after it writes its summary · A · S · S2-W3 · fix/a/a58-a-failed-quick-scrape-exits-non-zero (surfaced while building A54, open item #183; carded 2026-09-24)
Context. Measured at main fd299d3c. The item holds, and its line numbers still hold: the persist phase of services/scraper/scraper.js catches every write failure at :2816, sets summary.success = false at :2817 and the reason at :2818, writes brands.status = 'error' at :2826 (a second failure there is a logged warning, :2820-2837), and then prints the summary at :2854-2859 like a good run. main (:2598-2621) awaits scrapeAndStore at :2613 and never reads the summary, so the process ends with code 0. The item names only the database write, but the same lie has a second source. When the capture itself fails, scrape() returns success: false: a zero-ad capture at :2570-2576 (its own comment calls it "a FAILED scrape, not an empty success") and any thrown capture error at :2584-2586. The persist phase at :2652 is then skipped, the summary carries success: false and the error, and the process also exits 0. A54 set the rule that a refusal exits non-zero (:2862-2870, docs/ops/a54-acceptance.md, "What the fix does"), but that rule reaches only a rejected main. The one reader of the child's code is the engine. runScraperAsync spawns node scraper.js ... --summary (server.js:763-781), and its close handler (:832) logs code= (:833), parses the last JSON line of stdout (:836-855), and takes the good branch only on code === 0 && result.success (:857). Every other outcome goes to one failure branch (:915-969): the error text is result.error || stderr || Exit code: <code> (:925), one terminal write sets status: 'error' under the guard .in('status', ['pending', 'in_progress']) (:929-939), next_scrape_at moves on (:944-967), and the finally refreshes the brand metrics (:970-973). A failed run with a summary already goes to that branch today, because result.success is false. So a non-zero exit with the same summary gives the same branch, the same error text (from result.error, not stderr), the same single write and the same guard. It cannot write twice: the error handler at :976 fires only when the spawn or a kill fails, never for a non-zero exit, and the close handler runs once. The only visible change on the engine is the log line at :833, which says code=1 instead of code=0. scrapeAndStore and main are not exported, the module runs main() at load (:2867), and the persist phase needs a browser before it, so no test on the base can reach it; the unit job has no database (.github/workflows/ci.yml:438-456). The parity CSV anchors seven sites in this file (docs/spikes/a4-parity.csv: :618, :659, :2657, :2785, :2799, :2806, :2826); five are inside scrapeAndStore. No test or tool in services/scraper/test, spikes/intelligence-data-path/test or spikes/intelligence-data-path/tools holds a scraper.js:<n> literal of the current tree; tools/classify.cjs:93 holds scraper.js:744, which is a line at the pinned source_sha and does not move.
Read first. services/scraper/scraper.js:27-95 (parseArgs), :2507-2594 (scrape), :2598-2621 (main), :2625-2860 (scrapeAndStore) and :2862-2870 (the entry catch and its comment). server.js:723-973, in particular :832-857 and :915-973, and :976-990 (the error handler). services/scraper/test/entry-points.test.mjs whole file (the child-process harness this card copies). packages/db/data-path.cjs:89-95 (requireStorageProvider, which the fixture must pass). lib/creative-family.cjs:56-62 (imagePhash returns null for no buffer, so a fake ad with no media needs no image). docs/ops/a54-acceptance.md whole file, for the exit rule and for the sibling rule ("The card changes a sibling catch only if it touches it"). docs/ops/a53-acceptance.md section "The parity CSV", for the regeneration rule and the cell-by-cell comparison.
Deliverables. (1) One exit rule for the child: the process exits 0 only when the summary it printed says success: true. main reads the value scrapeAndStore returns and sets process.exitCode = 1 when summary.success !== true. The code is set after the stdout write has flushed (the await at :2855-2860 stays), and it is set, not exited on, for the reason the comment at :2862-2866 gives. The pool is closed in the finally as today, and a failed close stays a warning that does not change the code. The rule covers the persist failure (the item) and the capture failure (:2570-2586); both print success: false, and one rule for one field is the contract. (2) scrapeAndStore returns the summary object it printed, and nothing else changes in it: the order of the writes, the summary's fields, the status: 'error' write and its warning, the stdout and --output writes. (3) A test seam with no test switch in product code. main takes one optional argument { openDb = openDataPath, scrape = (config, db) => new MetaAdsLibraryScraper(config, db).scrape() } and passes scrape to scrapeAndStore, which calls it in place of :2634-2635. main is exported. The entry at :2867 runs only when the file is the process's entry, by a check that works on every Node the package supports (package.json:35-37 says >=20.9.0, and Dockerfile:85 accepts any Node 20 or later): the file reads process.argv[1], and only when it is a string does it compare realpathSync(process.argv[1]) with realpathSync(fileURLToPath(import.meta.url)); it calls main().catch(...) only when they are equal, with the catch body unchanged. The string check comes first because an import under node -e has no process.argv[1]. import.meta.main is not used, because Node 20 does not have it. A test that imports the module does not run main, and a spawn of node scraper.js does (both are proofs below). The comment above the entry says why the guard exists (a test imports the module to drive main without a browser). (4) The fixture services/scraper/test/fixtures/quick-scrape-child.mjs: it imports main from ../../scraper.js and calls it with a fake adapter and a fake scrape, so the test spawns a real process and reads its real exit code. The fake adapter has workspaceId, end() and from(table) with upsert, update and .eq() chains that resolve to { error }. The environment variable QUICK_SCRAPE_FAIL is a comma list of the writes that fail (brand-upsert, rank-clear, ads-upsert, brand-complete, brand-error, pool-end) or capture for a fake scrape that returns the zero-ad failure. The fake ad has a library id and no media, and the fixture runs with --no-media-upload --summary. The fixture is test code and its comment says why it exists. (5) The tests below, in a new file services/scraper/test/quick-scrape-exit.test.mjs. (6) Commit order, so the red is real: the first commit adds the seam (deliverables 2 to 4) with no change to the exit; the red cases run on that commit and fail with exit 0; the next commit adds the exit rule (deliverable 1). (7) Parity CSV regenerated with its own tool (npm run inventory in the spike), never merged by hand, as the last step of every commit that touches services/scraper. The seam moves the lines of the five anchored sites inside scrapeAndStore; a regeneration that moves only line numbers or digests is mechanical and needs only the inventory checks below. A regeneration that changes a case, a verdict or a behavioural evidence cell is a semantic change, and that cell's parity leg runs and its result goes in the record. (8) The record docs/ops/a58-acceptance.md.
Interfaces. Exposes exit code 1 with a success: false summary on stdout to the one reader of the child's code, server.js:857. That reader does not change, and the record shows, from the code at :915-973, that the job row, its error text, the schedule move and the metrics refresh are the same as on the base for the same summary; only the code= in the log line at :833 differs. Exposes the same code to the two checkout readers of the file: the n8n-workflow.json:137 command node, which now sees a failed scrape as a failed command, and npm test (package.json:11), which now fails when its live scrape fails. Both are the truth the rule asks for. Exposes the named export main for tests. Consumes the data path, requireStorageProvider and the adapter shape unchanged. Unchanged on purpose: server.js, the refusal path A54 built (a rejected main prints err.message and exits 1), the order of the checks in main, every write in scrapeAndStore, the summary's fields, the capture code, start.sh, the image's CMD.
Acceptance. Targeted tests locally only, from services/scraper: node --test test/quick-scrape-exit.test.mjs test/entry-points.test.mjs. Full suites only in GitHub CI: the scraper job's npm run test:unit and the admin's steps run on the push, per the owner rule. New tests, each one a spawn of the fixture that asserts the exit code, that stdout's last line parses as the summary, and that the process ended by itself within the harness timeout. RED ON THE BASE, run on the seam-only commit of deliverable 6 (each one exits 0 there): brand-complete fails, so the exit is 1 and the summary says success: false with Brand completion update failed; ads-upsert fails, so the exit is 1 with Ads upsert failed; rank-clear fails, so the exit is 1; brand-upsert fails, so the exit is 1; capture returns the zero-ad failure, so the exit is 1, the summary carries the capture error and no write reached the fake adapter. GUARD: no write fails, so the exit is 0, success: true and adsStored: 1; brand-complete and brand-error both fail, so the exit is 1 and the summary carries the first error, not the second; pool-end fails after a good run, so the exit is 0 and stderr has the close warning; importing scraper.js from the test process does not start a run (no usage line, no exit, the exit code of the test process is untouched); the four existing tests of test/entry-points.test.mjs pass unchanged, which proves the entry guard still runs main when the file is the entry. The record shows each RED run on the seam commit and its green run, and states the new totals of the two files. Parity: npm ci, npm run inventory (the regeneration), npm run inventory:check, node --test test/inventory.test.cjs test/parity-manifest.test.cjs, npm run order:check and npm run test:ci in spikes/intelligence-data-path (the last three as the spike-a4 job runs them, ci.yml:1221-1250); the record shows the CSV diff with line numbers taken out, and states that no case, verdict or evidence cell changed, or runs that cell's parity leg. Then grep -rn "scraper.js:[0-9]" services/scraper/test spikes/intelligence-data-path/test spikes/intelligence-data-path/tools, and each hit that names a line of the current tree is moved to its new line; tools/classify.cjs:93 names a line at the pinned source_sha and stays. python3 infra/scripts/test-task-refs.py then python3 infra/scripts/task-refs.py check ., and bash infra/secret-scan/scan-tree.sh (AGENTS.md:38-39). No job's steps change, so no timeout check. The record docs/ops/a58-acceptance.md. Open item #183 closed.
Out of scope. Any change to server.js: its failure branch already handles a non-zero exit with a summary, and the record proves it from the code. A distinct exit code per failure kind (capture against persist): the engine reads only zero against non-zero, and the reason is in the summary. The capture failure's own handling (:2570-2586) and the stale-rank clear, which stay as they are. download-media.js:234, which ends with main().catch(console.error) but is not in the image and has no parent that reads its code (A54's out-of-scope reason still holds). download-ads.js and migrations/export-data.mjs, which already exit 1 and which the spike pins (docs/ops/a54-acceptance.md, "The sibling tools"). A deep-crawl child's exit (server.js:649, :696), which is a different child with its own reader.
Waits on / Unblocks. Waits on nothing; everything it reads is on main at fd299d3c, after A53, A54, A55 and A56 merged. It shares docs/spikes/a4-parity.csv (generated) with every open card that touches services/scraper; whichever merges second regenerates the CSV on its rebase with its own tool, never by hand, and is re-tested on the merge commit. It shares a surface with the write guard (test/write-guard/corpus.mjs:85 names scraper.js); the guard reads the file's text, and the seam adds no write. Unblocks nothing named.
A59 · A line move keeps a parity verdict, because the row evidence holds no continuation line · A · S · S2-W3 · fix/a/a59-a-row-evidence-holds-no-line (surfaced at A53's review, open item #184; carded 2026-09-24)
Context. Measured at main fd299d3c (A53 merged). The item holds in substance. spikes/intelligence-data-path/tools/evidence.cjs:62 is the list of columns that row_evidence does not hash: site, line, result, follow_up, row_evidence and case_evidence. continuation_lines is not on the list, so rowEvidence (:358-366) hashes it as continuation_lines=<cell> with every other generated column (tools/inventory.cjs:48-56, the column at :53). The scanner fills that cell with absolute line numbers (tools/scan.cjs:222). The comment at evidence.cjs:15-20 promises that "no location goes in, so a pure line move keeps it", and the header sentence the generator writes says the same (inventory.cjs:221-227, "except site and line"). The cell adds no fact about the call: the expression that rowEvidence fingerprints already holds the text of every folded continuation chain (scan.cjs:193-208), and a changed continuation text already moves the evidence (test/evidence.test.cjs:107-116). A probe on the base shows the defect: a site at line 2 with one continuation at line 3 has evidence 4e6b975a…; the same source with two comment lines above it (site 4, continuation 5) has 87d88d33…; the same source with one comment line between the anchor and the continuation has fba75cbc…. The existing line-move test (test/evidence.test.cjs:79-85) uses a call with no continuation, so it does not see this. The carry reads the evidence first: tools/classify.cjs:321-458 (carryForward) matches a recorded row by row_evidence, then by scope and site, and a measured port row whose evidence differs goes back to TBD with STALE.row (:441-444, the sentence at :272-273). A53 met this on row ingest-worker.cjs:1259: its cell went from 1240|1241 to 1267|1268, the row fell to TBD, and a closed parity run restored its PASS (docs/ops/a53-acceptance.md:134-151, finding 1 at :244). On fd299d3c that row reads PASS again, and the file holds 397 rows (393 site, 4 annex): 360 PASS, 37 EXPLAINED, 0 TBD. 16 rows carry continuation lines, all 16 PASS: seven in the admin, download-ads.js:187, server.js:321, lib/research/runner.cjs:70 and :96, and five in lib/market-intel (amazon-worker.cjs:480, ingest-worker.cjs:1259, keyword-retagger.cjs:106, trustpilot-worker.cjs:486, youtube-worker.cjs:296). One fact makes the fix larger than the 16 rows. tools/evidence.cjs is itself an input of the run digest (runDigestInputs, :969-973, enforcedBy), so any edit to it moves the header's run_digest (3b0ff58a…edb216cc on the base), and carryForward then drops all 360 measured port verdicts at once with STALE.run (classify.cjs:437-440). The verdict gate refuses a port row at TBD (tools/verdict-gate.cjs:13-19), so the spike's test:ci stays red until a closed run writes the verdicts again. The only path back is bin/closed-run.sh, which rebuilds the pair from the commit, runs test/parity.test.cjs in a closed root and copies the CSV and the catalog back (bin/closed-run.sh:48-75). The run writes only result, follow_up, case_evidence and the run_digest header line (test/parity/verdicts.cjs:31-45, :497-527); row_evidence is the generator's.
Read first. spikes/intelligence-data-path/tools/evidence.cjs:1-62 (the contract and the exclusion list), :326-366 (expressionFingerprint, cell, rowEvidence) and :940-1030 (runDigestInputs, digestOf). tools/scan.cjs:15-22 and :180-225 (the expression and the continuation cell). tools/inventory.cjs:48-68 (columns, carried columns), :160-230 (where evidence is computed and the header is written) and :410-445 (main). tools/classify.cjs:265-458 (STALE, rowKey, carryForward). tools/verdict-gate.cjs:1-60 and :240-260. test/evidence.test.cjs:40-116, :185-213 and :432-478. test/parity/verdicts.cjs:28-45 and :470-530. bin/closed-run.sh whole file and runtime-manifest.txt (the pinned node). docs/ops/a53-acceptance.md:113-196 (the closed runs, the midnight margin at :187) and :244. docs/ops/a54-acceptance.md:116-118 (the mechanical-regeneration rule).
Deliverables. (1) tools/evidence.cjs:62: add continuation_lines to NOT_IN_ROW_EVIDENCE. The comment above the list (:54-61) says why: the cell is location, like site and line, and what the continuation asks is already in the expression fingerprint, so a blank or comment line added above, below or between the anchor and its continuation keeps the evidence, and an edit to the continuation text drops it. The comment at :15-20 names all three location columns. Relative offsets are not hashed in their place: a blank or comment line added between an anchor and a reassigned filter is layout, not a change to the question, and the contract says no location goes in. (2) tools/inventory.cjs:221-227: the header sentence reads "except site, line and continuation_lines". The column stays in the CSV, unchanged, because a reader uses it to find the real source (scan.cjs:22). (3) The tests below. (4) Commit A, the code: the two tool edits, the tests, and the CSV that npm run inventory writes over them. That CSV has every port row at TBD with STALE.run, a new generator_digest, the new header sentence and a new row_evidence on every row. It is the honest state between a changed digest input and its run, and it is never merged alone. (5) Commit B, the restamp: bin/closed-run.sh from the clean commit A, with the node that runtime-manifest.txt pins first on the path, no other container running, and a start more than 300 s away from midnight UTC. Nothing in the CSV or the catalog is edited by hand. (6) The record docs/ops/a59-acceptance.md.
Interfaces. Exposes a new row_evidence value on every one of the 397 rows (the parts list loses one line for each row, so the rows with an empty cell move too) and a new run_digest and generator_digest in the header. Consumes the scanner, the carry, the gate and the parity harness unchanged. Unchanged on purpose: the CSV's column list and the continuation_lines values, rowKey, the STALE sentences, the carry order (evidence first, then scope and site), caseEvidence and canonical, the catalog artefact, every case in test/parity, and every verdict.
Acceptance. Targeted tests locally only, in spikes/intelligence-data-path: node --test test/evidence.test.cjs test/inventory.test.cjs test/parity-manifest.test.cjs, then npm run order:check and npm run inventory:check. The full test:ci runs in the spike-a4 CI job on the push (.github/workflows/ci.yml:1198-1249; owner rule: full suites only in CI). New tests in test/evidence.test.cjs: (a) a site with one reassigned continuation, moved down by two comment lines above it, keeps its row_evidence (RED ON THE BASE: 4e6b975a… against 87d88d33…); (b) the same site with one comment line added between the anchor and the continuation keeps it (RED ON THE BASE); (c) the carry proof: two scans of that source, before and after the move, each made a row with PASS and the same run digest, passed through carryForward, give dropped.row 0 and the PASS on the moved row (RED ON THE BASE: dropped.row 1 and TBD); (d) a site with a lambda continuation (count((q) => q.eq(...))) moved by lines keeps its evidence (RED ON THE BASE). GUARD proofs: the existing continuation-text test (:107-116) stays green, so a changed continuation still drops the verdict; a site that gains a second reassigned filter moves its evidence; the column loop at :185-201 now proves continuation_lines does not move the evidence and every other generated column still does; the committed-file test at :203-213 holds after commit B. The restamp proof, which commit B must show and the record states: a script in the record compares the CSV of fd299d3c with commit B's by scope and site over all 397 rows. result, follow_up and case_evidence are equal on every row (360 PASS, 37 EXPLAINED, 0 TBD, as on the base); every other cell except row_evidence is equal; row_evidence differs on every row; the header differs only in generator_digest, run_digest and the evidence sentence; docs/spikes/a4-parity-catalog.json comes back byte for byte (closed-run.sh prints "is byte for byte the committed one"). Any verdict, case or follow-up that differs stops the card: it is a finding for the record and the owner, never a hand edit. The line-move proof on the real tree, after commit B, in a scratch copy that is not committed: add two comment lines above the site at lib/market-intel/ingest-worker.cjs:1259 and one comment line between the anchor and its first continuation, run npm run inventory, and the run prints 0 dropped on every count and 397 rows carried, with only site, line and continuation_lines cells moved; the same probe on fd299d3c drops that row to TBD with STALE.row (RED ON THE BASE, the A53 incident reproduced). The record shows both outputs, and the scratch edit is removed. The card moves no line under services/scraper/lib, so the grep of test/ and tools/ for <file>.cjs:<n> literals and the CSV line-move checks do not apply beyond inventory:check, test/inventory.test.cjs and test/parity-manifest.test.cjs above. python3 infra/scripts/test-task-refs.py then python3 infra/scripts/task-refs.py check ., bash infra/secret-scan/scan-tree.sh (AGENTS.md:38-39), and gitleaks git --redact with the repo config over origin/main..HEAD. No job's steps change, so no timeout check. The record docs/ops/a59-acceptance.md holds each RED run on the base and its green run, the closed run's output (exit, pass and fail counts, load, containers left) and the restamp comparison. Open item #184 closed.
Out of scope. Hashing relative continuation offsets, for the reason in deliverable (1). Removing the continuation_lines column from the CSV, which a reader needs. The line numbers in the ported paths pins (tools/paths.cjs:24-102) and the file-and-line text in two note cells (app-settings.cjs:39, add_market_intel.sql:137): the pins are not hashed and are checked by attachPaths, and a note is hand-written text whose edit is a semantic change by the A53 rule. The run digest's inclusion of tools/evidence.cjs, which is correct: a change to how evidence is computed must re-measure the set. The nine closed records under docs/ops that cite evidence.cjs by line, because a record is not rewritten after it is closed.
Waits on / Unblocks. Waits on nothing; everything it reads is on main at fd299d3c. It shares the generated docs/spikes/a4-parity.csv with every open card that touches services/scraper, among them A57 and A58. This card merges FIRST of those, because A57 edits lib/market-intel and moves the five continuation rows there: after this card, those moves are mechanical and need no closed run. The closed run of commit B is valid only for the tree it ran on. If a card that changes a run-digest input (the adapter package, db/migrations/unified, services/scraper/migrations, the harness, the lock) merges before this one, this card rebases, regenerates and runs bin/closed-run.sh again on its new head; a card that only moves lines under services/scraper/lib needs a regeneration alone. Whichever of two cards on a shared surface merges second is re-tested on the merge commit, and the CSV is regenerated there with its own tool, never merged by hand. Unblocks A57, whose line moves of continuation rows then carry their verdicts.
A60 · The carry report counts and names every verdict it did not carry · A · S · S2-W3 · fix/a/a60-a-carry-report-names-every-lost-verdict (surfaced while building A59, open item #186; carded 2026-09-24)
Context. Measured at main 6ffdae01 (A59 merged). The item holds. carryForward is in spikes/intelligence-data-path/tools/classify.cjs:321-464, not in tools/evidence.cjs. It counts five ways a row can fail to carry (dropped, :353-355): group (:409), fresh (:422), run (:438), row (:442) and gone (:451, the recorded rows that no scanned row matched). It returns { rows, dropped } (:463) and nothing that names a row. The report in tools/inventory.cjs:443-445 prints only row, run and group. The carry matches a scanned row to a recorded row first by row_evidence, then by scope and site (rowKey, classify.cjs:293). A row whose evidence and site both changed matches neither way: the carry counts it as one fresh row that reads the seed (TBD for a port row, with no sentence in follow_up) and one gone record, and the report prints 0 on its three counts. A probe on 6ffdae01 shows this with no edit to the tree: a copy of the committed CSV in which the PASS row ingest-worker.cjs:1259 has site :1999 and evidence f…f, passed with --carry-from and --out into a scratch directory, prints "0 verdict(s) dropped on moved evidence, 0 on a stale run digest, 0 on an evidence group whose recorded rows disagree", and the written file holds 1 TBD row. The committed file holds 397 rows (360 PASS, 37 EXPLAINED, 0 TBD); 371 of them are port rows with a measured verdict. dropped.gone also counts a recorded row that held no measured verdict (a TBD row, or an EXPLAINED row that is not a port row), and dropped.fresh also counts a new row that is not a port row (it takes its EXPLAINED seed). So neither raw count is "a verdict lost". The carry already has the predicate for a measured verdict: digested && verdict !== 'TBD' && row.disposition.startsWith('port-') (:436). Two facts decide the size of the fix. First, tools/classify.cjs is an input of the run digest: test/parity/inventory.cjs:27 and test/parity/verdicts.cjs:49 require it, so runDigestInputs (tools/evidence.cjs:961) walks it into the list (162 inputs on 6ffdae01; tools/inventory.cjs is not one of them). An edit to carryForward moves the header's run_digest (743155c7…c245493c), and the carry then drops all 371 measured verdicts with STALE.run, as A59's commit A did. Second, generatorDigest (tools/inventory.cjs:103-108) hashes every tools/*.cjs, so any edit to either tool moves the header's generator_digest (88664c55…e0db91c0). So the committed CSV changes in two header lines, and a closed run is needed. Only the carry knows which record it matched, so the names must come from carryForward. A copy of its matching rule in inventory.cjs would avoid the closed run, but two copies of one rule drift, and this card does not make that trade. Nothing reads the report's exact text: no CI step (the spike-a4 job runs order:check and test:ci, .github/workflows/ci.yml:1248-1249), no test (no assertion in test/ matches the report's stdout), no runbook and no README. bin/closed-run.sh does not run the inventory. The closed records docs/ops/a9-acceptance.md:3374, :3388, :3496 and docs/ops/a59-acceptance.md:60, :164, :177 quote the old line, and they stay as they are. The dropped object has readers that compare its whole shape: test/inventory-roundtrip.test.cjs:222-224 and test/evidence.test.cjs:545-547 use deepEqual with its five keys, and tools/inventory.cjs:426-428 builds it for a file that has no record.
Read first. tools/classify.cjs:257-293 (VERDICTS, CARRIED, STALE, rowKey and the comment on why no looser key is used) and :295-464 (carryForward). tools/inventory.cjs:98-108 (generatorDigest) and :410-494 (main and the report). tools/evidence.cjs:944-1030 (runDigestInputs, digestOf). test/evidence.test.cjs:500-700 (the carry tests, among them A59's (c) at :526-551, the stale run digest at :552-563 and the evidence groups at :603-668). test/inventory-roundtrip.test.cjs:55-80 (filledRows, run) and :187-227 (fresh, gone, a moved site). docs/ops/a59-acceptance.md whole file: finding 1, the probes in "The line move on the real tree", the closed run and the restamp comparison script. bin/closed-run.sh whole file and runtime-manifest.txt.
Deliverables. (1) carryForward returns { rows, dropped, lost }. dropped keeps its five keys and what each one counts, so every reader of it stays green. lost holds four lists of rowKey values, each in the order its source holds them: lost.row (each row dropped with STALE.row), lost.group (each scanned row dropped with STALE.group whose own matched record at :401 satisfies the same predicate at :436: a run digest on the file, a result that is not TBD, a disposition that starts with port-; dropped.group stays the raw count of every such drop), lost.gone (each recorded row that no scanned row matched and whose verdict was measured, by the predicate at :436 applied to the record: a run digest on the file, a result that is not TBD, a disposition that starts with port-; each entry is { key, result }), and lost.fresh (each scanned row that matched no record, is a port row and reads TBD). A run drop is counted and not listed: its one cause is the header, and each of its rows already holds the STALE.run sentence in the file. A comment above the return says why lost exists: the counts cannot say which verdict went, and a gone record is in no row of the new file, so the report is the only place that can name it. (2) A moved-and-changed row is reported as two entries, one gone and one new at TBD, and the report does not pair them. The carry has no location-free identity for such a row: its evidence and its site both changed, and a pairing key such as the file, the operation and the table is the looser key the comment at classify.cjs:283-292 refuses, because two sites of one file with the same operation and table would be paired wrong and the report would state a link that nothing proved. The two lists are printed one after the other, each in file order, so a reader sees the pair when it is one. The action is the same in both cases: run the local half. (3) tools/inventory.cjs prints the report in this shape, in place of :443-445. One line: <T> verdict(s) not carried: <row> on moved evidence, <run> on a stale run digest, <group> on an evidence group whose recorded rows disagree, <gone> on a measured site the scan no longer finds, where <T> is lost.row.length + dropped.run + lost.group.length + lost.gone.length, <row>, <group> and <gone> are the lengths of those lists, and <run> is dropped.run. lost.fresh is not in <T>: a new site held no verdict, so it is reported only on the separate line below. One line: <fresh> port site(s) the file has never held, each at TBD, where <fresh> is the length of lost.fresh. Then one line for each entry of lost.row, lost.group, lost.gone and lost.fresh, in that order, and every entry is printed: moved evidence <key>, evidence group <key>, gone <key> (was <result>), new at TBD <key>. So "0 verdict(s) not carried" is true only when every measured verdict carried. The file with no record (:426-428) gets empty lost lists. --check prints the same report, and its pass or fail does not change: this card reports and does not refuse. (4) The tests below. (5) Commit A: the two tool edits and the tests, with the CSV that npm run inventory writes over them. That CSV has all 371 measured port rows at TBD with STALE.run, and new generator_digest and run_digest lines. It is the honest state between a changed digest input and its run, and it is never merged alone. (6) Commit B, the restamp: bin/closed-run.sh from the clean commit A, with the node that runtime-manifest.txt pins first on the path, no other container running, and a start more than 300 s away from midnight UTC. Nothing in the CSV or the catalog is edited by hand. (7) The record docs/ops/a60-acceptance.md.
Interfaces. Exposes lost on the return value of carryForward, read by tools/inventory.cjs and the tests. Exposes the new report text on the inventory's stdout; no program reads it. Exposes new generator_digest and run_digest values in the CSV header. Unchanged on purpose: the five keys of dropped and what each counts, the match order (evidence first, then scope and site), rowKey, the STALE sentences, what carryForward writes on every row, what --check refuses, the CSV's columns and every one of its 397 rows, row_evidence, the catalog artefact, and every verdict.
Acceptance. Targeted tests locally only, in spikes/intelligence-data-path: npm ci, then node --test test/evidence.test.cjs test/inventory-roundtrip.test.cjs test/inventory.test.cjs test/parity-manifest.test.cjs, npm run order:check and npm run inventory:check. The full test:ci runs in the spike-a4 CI job on the push (owner rule: full suites only in CI). New tests. RED ON THE BASE: (a) in test/evidence.test.cjs, a site with a continuation whose anchor moves down two lines and whose continuation text changes, recorded PASS with the same run digest, gives lost.gone [{ key: <old key>, result: 'PASS' }] and lost.fresh [<new key>], with dropped unchanged from the base (gone 1, fresh 1); the base returns no lost. (b) In test/inventory-roundtrip.test.cjs, the CLI run with --carry-from a scratch copy of the committed file in which one PASS port row has a new site line and evidence f…f, and --out a scratch file, prints "1 verdict(s) not carried", "1 on a measured site the scan no longer finds", a gone line with the old key and "(was PASS)", and a new at TBD line with the real key; the base prints the three-count line with 0 on each. (c) A row dropped with STALE.row is named on a moved evidence line; the base counts it and names nothing. GUARD: the moved-site test (test/inventory-roundtrip.test.cjs:212-227) and A59's test (test/evidence.test.cjs:526-551) keep their deepEqual on dropped unchanged, and all four lost lists are empty; a gone record that reads TBD, and a gone record that is not a port row, still count in dropped.gone and are not in lost.gone or in the total; a new row that is not a port row still counts in dropped.fresh and is not in lost.fresh; a stale run digest gives a total equal to the 371-row dropped.run of test/evidence.test.cjs:552-563 and names no row; the group tests at :603-668 keep their counts and name each dropped row whose group held a measured port verdict; an ambiguous group whose recorded rows hold no measured port verdict still counts in dropped.group and is not in lost.group or in the total; in a mixed group, where one matched record holds a measured port verdict and another does not, both count in dropped.group and only the measured one is in lost.group and in the total; --check on the committed file prints "0 verdict(s) not carried" and "is current". The restamp proof, which commit B must show and the record states: A59's comparison script, run 6ffdae01 against commit B, gives 397 rows on each side, every cell of every row equal, and the header different only in generator_digest and run_digest; docs/spikes/a4-parity-catalog.json comes back byte for byte (closed-run.sh prints "is byte for byte the committed one"). Any row that differs stops the card: it is a finding for the record and the owner, never a hand edit. The probe on the real tree, after commit B: the scratch --carry-from copy of the Context, with ingest-worker.cjs:1259 moved to :1999 and its evidence changed, prints 1 not carried and names both entries; the same probe on 6ffdae01 prints 0 on each count (RED ON THE BASE). Nothing is written in the repository; the scratch files are removed. The record states the closed run's output (exit, pass and fail counts, start and end time, load, containers left). The card moves no line under services/scraper, so no <file>.cjs:<n> literal in the spike's test/ or tools/ moves; the builder greps for classify.cjs:<n> and inventory.cjs:<n> literals in the spike and moves each one that names a line of the current tree. python3 infra/scripts/test-task-refs.py then python3 infra/scripts/task-refs.py check ., and bash infra/secret-scan/scan-tree.sh (AGENTS.md:38-39); gitleaks git --redact with the repo config over origin/main..HEAD as the last step. No job's steps change, so no timeout check. The record docs/ops/a60-acceptance.md holds each RED run on the base and its green run, both probe outputs, the closed run's output and the restamp comparison. Open item #186 closed.
Out of scope. A copy of the carry's matching rule in tools/inventory.cjs, which would avoid the closed run and give two rules that drift. A pairing of a gone record with a new row, for the reason in deliverable (2). A refusal by --check or by the gate when a verdict does not carry: the gate already refuses a port row at TBD (tools/verdict-gate.cjs:13-19), and this card only makes the report say why. A sentence on the follow_up of a new port row, which would change what the generator writes for every new site. Moving carryForward out of tools/classify.cjs so that it leaves the run-digest closure, which changes what the run digest binds and is not needed to make the report true. The closed records that quote the old report line, because a record is not rewritten after it is closed.
Waits on / Unblocks. Waits on A57. Both cards edit tools/classify.cjs, a run-digest input, and each ends with its own closed run, so they cannot build in parallel: this card starts from main after A57 merges. What it reads is otherwise on main at 6ffdae01, after A59 merged. The closed run of commit B is valid only for the tree it ran on. If a card that changes a run-digest input (the adapter package, db/migrations/unified, services/scraper/migrations, the harness, tools/classify.cjs, the lock) merges before this one, this card rebases, regenerates and runs bin/closed-run.sh again on its new head; a card that only moves lines under services/scraper/lib needs a regeneration alone, on whichever of the two merges second. Whichever of two cards on a shared surface merges second is re-tested on the merge commit, and the CSV is regenerated there with its own tool, never merged by hand. Unblocks nothing named.
S3 — Studio merge + strategy layer (specs)
Facts every S3 card relies on (verified against RescaleOS): console = Next.js (console/), SQLite console.db in DATA_DIR (console/src/db/client.ts, ~29 tables: sessions, session_events, gates, artifacts, cron_runs, settings, claude_account_state, claude_account_windows, product_settings, research_ads, users, auth_sessions, scripts, script_archive, script_revisions, script_votes, weekly_results, feedback_notes, feedback_inbox, feedback_lanes, fathom_links, notifications, audit_log, backlog_items, backlog_digests, backlog_ideas, supermemory_writes, playbooks, session_sources); agent = @anthropic-ai/claude-agent-sdk spawning the Claude Code binary with a subscription OAuth token (managedSession.ts, sessionManager.ts, accounts.ts rotation); engine API /api/engine/* (x-cron-token), research ingest /api/research/ingest, assign /api/assign (adName.ts), results /api/scripts/results; loops: engine/automation/* (npt-build-loop.sh hourly, frankenstein.sh, dream-daily.sh, backlog-daily.sh, loop-closer, trend-watcher, volume-trigger, lint, backup), cloud runner console/scripts/night-shift-runner.mjs + night-shift-jobs.cloud.json, registry console/src/server/cron/registry*.ts (RESCALEOS_CLOUD=1); skills: engine/skills/ = the versioned mirror of the live ~/.claude/skills (45 skill dirs, each SKILL.md + assets; engine/skills/_doctrine/ 13 shared doctrine files; engine/memory/ 41 wikilinked corpus pages), re-synced by /backup; the cloud image replaces ~/.claude/skills on boot; there is no workspaces/ directory — business workspaces are repo-root dirs dropshipping/, supplements/, strategic-loop/ (each with CONTEXT.md, automations, product folders); engine API = 18 routes under console/src/app/api/engine/* (backlog/{accepted,applied,digest}, fathom, fathom/processed, feedback, inbox, kpi-reminders, loop-close, playbook, playbook-stats, query, scripts, scripts/content-patch, session-sources, supermemory/winners, uploads/[...path], upsert), contract in _doctrine/console-scripts-api.md; console pages: dashboard, login, sessions, sessions/[id], scripts, scripts/[ws]/[id], backlog, backlog/[date], inbox, night-shift, scoreboard, opportunities, audit, users, admin/claude-usage, admin/supermemory; SDK spawn (managedSession.ts): query({ options: { model, cwd: WORKSPACE_ROOT, settingSources: ['user','project'], permissionMode: 'acceptEdits', allowedTools: [Read, Grep, Glob, Skill, Task, TodoWrite, WebFetch, WebSearch, Write, Edit], mcpServers: {trendtrack?}, maxTurns: 400, env: {CLAUDE_CODE_OAUTH_TOKEN: account.token}, resume?, canUseTool } }) — no systemPrompt; behaviour comes from the skills/settings sources + the workstream slash command as the first message (sessionManager.ts:216–221, AUTOPILOT policy); night-shift cloud jobs (night-shift-jobs.cloud.json, UTC): npt-loop-closer 03:11 daily, frankenstein Mon 06:13, npt-trend-watcher Mon 07:23, volume-trigger Wed+Fri 07:47, backlog-feedback 05:03 daily; Mac registry adds npt-build-loop (3600 s), dream 04:31, backlog 05:03, lint Sun 05:17, backup 23:53; SuperMemory winnerHook.ts; workstreams npt | bpt | npt-niels (src/lib/workstreams.ts).
A4c · The agent-runtime mirror gets its refresh lock · A · M · S3-W0 · chore/a/a4c-agent-runtime-lock (surfaced by A4a on 2026-09-14, open item #79)
Context. services/agent-runtime has imported history and no lock, so A4a's procedure cannot refresh it. Measured at 32cfffc: the A1a import brought RescaleOS in at 55b693b (commit b3dc0ce), its rewritten boundary is d811f90 with 118 commits behind it, the mirror holds 631 files, and Skynet has added exactly two deltas since — the nested CI workflow removed (6e978d4, services/agent-runtime/.github/workflows/docker-build.yml, 121 lines) and the console image moved to Node 24 (320e8e2, console/Dockerfile, 3 lines); git diff --stat d811f90 32cfffc -- services/agent-runtime is 2 files, 3 insertions, 124 deletions. The import also replaced two RescaleOS shared secrets with ***REMOVED-A1A*** (docs/ops/a1a-acceptance.md:8); at 3b7ce25 that placeholder stands in 19 files and 38 lines, and both literals still live in the upstream repository's history, so reproducing the import needs the run-time replacement input the procedure already supports — --replacements <file> of literal==>replacement lines whose sha256 is the only thing the lock holds (mirror_refresh.py:547-576, Lock :178-183). The driver's default replacement is ***REMOVED*** (:41), not the placeholder the import wrote, so each line must spell its own replacement. A10 is the first card that reads this mirror, and the runbook says the same in §11.
Read first. docs/runbooks/MIRROR_REFRESH.md §2, §4, §7, §8 and §11; infra/import/mirrors/s2-mirrors.json (the shape a lock has and every field it must carry); infra/import/mirrors/refresh-s2.sh (LOCK=$HERE/s2-mirrors.json at :25, the roots read from the lock at :291, the procedure sentence it writes into the merge message at :433); infra/import/mirrors/mirror_refresh.py (Lock at :142 with runtime_inputs at :178-183, load_replacements :547-576, cmd_check_lock :777-793); infra/import/mirrors/s2-transform.json (attribution_check: the house pattern, the authorship shapes, the declared subject-matter occurrences); docs/ops/a1a-acceptance.md (the two purged values and the import commits); docs/ops/a4a-mirror-refresh-acceptance.md (the proofs a refresh has to record); open item #5 (the owner rotates both values).
Deliverables. (1) The entry point takes its lock instead of naming one: the hard-coded lock path becomes an argument, or a sibling entry point passes it, and every sentence the run prints — the merge message included — names the lock it ran with. (2) infra/import/mirrors/agent-runtime.json: the source repository and the pinned target SHA, the boundary 55b693b with its rewritten counterpart d811f90, the root services/agent-runtime, the path mapping the import used, the transform spec and its digest, the driver digest, the four tool pins, runtime_inputs.replacements.sha256, and the two Skynet deltas as ordered overlay patches with their digests. (3) A transform spec for this mirror. It is thick with the attribution check's subject matter — the house pattern matches 1,313 lines in 191 files of the mirror's tree at 32cfffc, against the S2 refresh's 43 declared occurrences in 21 paths — so the card fixes the rule it follows: one declaration per occurrence, each reviewed, and never a blanket allowance or a widened pattern. (4) The owner's vault input: a file of literal==>***REMOVED-A1A*** lines for the two values, held outside the repository, its sha256 in the lock, and neither literal in the repository, the log, the proofs or any error sentence. (5) The reproduction proof before anything is imported: rewriting the recorded boundary gives d811f90 byte for byte and every older rewritten id is unchanged. (6) docs/runbooks/MIRROR_REFRESH.md covers both mirrors, and §11 no longer says this one has no lock. (7) The refresh runs on the branch to the pinned upstream SHA, with docs/ops/a4c-acceptance.md carrying the proofs A4a's record carries.
Interfaces. Exposes a second lock and the entry point's lock argument. Consumes the owner's vault file, by digest only.
Steps. The lock argument on the entry point → the lock and the transform spec → the owner's vault input → a dry reproduction of the boundary → the declarations for the imported range → the real refresh → the record.
Acceptance. check-lock and check-tools pass for the new lock; the rewritten boundary equals d811f90 and the older rewritten ids are unchanged; the refreshed tree equals the pinned upstream after the rewrite plus exactly the two declared overlays, compared by path and blob manifest; every stop leaves no commit and no changed tree; the leak scans over the tree and over the imported range are clean, and neither literal appears in any output or proof file; A4b's CI job green on the change; the record with the same measures A4a recorded.
Out of scope. A10's port of the console to Postgres; refreshing the S2 mirrors again; services/workos (no history — a scrubbed copy); rotating the two secrets, which is the owner's (open item #5).
Waits on / Unblocks. A4b (the procedure's test in CI) and the owner's vault input / A10 — S3 starts against a named upstream SHA instead of a stale copy.
Built so far (PR #99 → main b9f63de, 2026-09-18). Co-plan 1 round → agreed with two corrections (every one of the tree's attribution matches is read and declared individually, an authorship-shaped line is withheld and never declared; the replacement file stays the owner's vault input). Code 1 review round → four must-fixes and a ruling on the design, then three re-checks → APPROVE. The entry point takes its lock: refresh-mirror.sh --lock, with refresh-s2.sh kept as the S2 mirrors' entry point, and every sentence in the procedure's own voice names the lock (a run with no lock is refused before it reads anything; the boundary to third-party output is stated in the runbook). The schema is exact: every object of a lock and of a transform spec refuses a key it does not read, with description allowed only where a real spec already carries it. A lock may declare its run-time replacement input as pending ({"sha256": null, "reason": …}): it loads, check-tools passes, check-lock stops naming the reason, and every command that would read the source or rewrite history refuses before it starts. agent-runtime.json is written in that state, with the mirror's transform spec and its two overlays. The review's ruling on the spec: the attribution check is scoped to a refresh's range and stops on a declared occurrence the range does not hold, so the 713 reviewed lines of the mirror's own tree are not occurrence allowances, which would stop the first refresh, but a ledger (attribution_check.reviewed_lines, 679 entries over 713 occurrences) that the driver links at range time to a new occurrence of the same line without ever allowing a line by itself; subject_matter_lines is empty. 17 authorship-shaped lines are withheld as findings, each with its line digest (a scrub is structurally closed for them, because they stand in history the boundary proof must reproduce). Audited: every digest re-computed from the repository, five blobs read from the upstream clone because the import's replacement changed them; 205 lines sampled across all 122 files, 205 upheld; 50 lines of the densest file re-read in context, 49 upheld and one judgement ruled subject matter with its dissent recorded; the message and identity arms run over the 118 pre-boundary commits, 9 matches, none authorship, no identity hit. What the review does not prove, recorded and filed: text inside compressed files is invisible to the check (open item #92), and the check's authorship arm has never fired on this corpus (open item #93). The procedure's test 106 passed, green in the mirrors job; the merge commit's CI and deploy were green. Not yet done: deliverable (4), the owner's vault file, whose sha256 alone enters the lock; (5), the boundary reproduction, which needs that file; and (7), the refresh, which also needs upstream to move, because upstream has not moved past 55b693b since the import. Record docs/ops/a4c-acceptance.md.
A4d · The refresh driver counts distinct paths · A · XS · S3-W0 · fix/a/a4d-refresh-path-counts (surfaced by A4a's real refresh on 2026-09-14, open item #82)
Context. Two summary lines call a pair count "paths". infra/import/mirrors/mirror_refresh.py:1038-1043 (the attribution summary) and :1088-1089 (the export summary) both print %d blob versions at %d paths and fill the second field with len(pairs), the number of path-and-blob pairs, while the first field counts distinct blob ids (paths_by_blob, :1010-1012). The real refresh therefore reported "85 blob versions at 85 paths" for a range that holds 85 blob versions at 66 distinct paths — docs/ops/a4a-mirror-refresh-acceptance.md states both numbers, and range-objects.txt has 85 rows with 85 distinct blobs. A wording defect only: every check counts and scans each pair correctly. A4a left it unchanged because the refresh merge commit pins the driver's digest.
Read first. infra/import/mirrors/mirror_refresh.py:1005-1045 and :1059-1090; docs/ops/a4a-mirror-refresh-acceptance.md ("Found, not fixed", and the attribution row of the run table); infra/import/mirrors/s2-mirrors.json (transform.driver_sha256); docs/runbooks/MIRROR_REFRESH.md §5.
Deliverables. (1) Both lines report the pair count as blob versions and count distinct paths for "paths", so the S2 range reads 85 blob versions at 66 paths over 85 path and blob pairs. (2) The same change re-pins transform.driver_sha256 in every lock that names the driver. (3) One case in test-refresh-s2.sh fixes the wording: a fixture range that holds one blob at two paths prints the distinct count and the pair count as two different numbers. (4) docs/runbooks/MIRROR_REFRESH.md §5 shows the new line.
Acceptance. The procedure's test green through A4b's job, the new case included; check-lock passes for every lock with the re-pinned digest; the next refresh's merge commit pins the new digest; nothing else changes — the pair-by-pair scans, stops and proofs are untouched.
Out of scope. Any change to what is scanned, counted or stopped; the proof file formats.
Waits on / Unblocks. The next change to the driver, which is A4c when A4c edits mirror_refresh.py and otherwise this card itself; either way the same commit re-pins the lock's driver digest, and the change merges with that driver change rather than alone / accurate counts in every later refresh record.
Built as (PR #98 → main ba66058, 2026-09-18). Code 1 review round → APPROVE, no findings. The attribution summary reads %d blob versions (path and blob pairs) at %d distinct paths, %d distinct blobs (%d bytes) filled with the pair count, the distinct paths and the distinct blobs, and the export summary %d blob versions (path and blob pairs) at %d distinct paths (%d bytes, …), so the S2 range reads 85 blob versions at 66 distinct paths and 85 distinct blobs where it read 85 at 85; the bytes stay with the versions in the export line because it writes one file per pair. The lock s2-mirrors.json re-pins transform.driver_sha256 (30f80c65… → b1f56e8f…); check-lock passes with the new digest and stops with its sentence on the old one. One fixture case in test-refresh-s2.sh holds one blob at three paths and a second version of one path (10 pairs, 9 distinct paths, 8 distinct blobs), derived from range-objects.txt, failing if any two numbers are equal and asserting both summaries and attribution.txt's first line; on the base driver it fails with its own sentence (62 passed, 1 failed); the procedure's test ends 63 passed, 0 failed (5 min 24 s on the shared dev box; green in the mirrors CI job on the pull request). Nothing scanned, counted or stopped changed. The runbook's proofs table shows the new lines; the A4a record's "Found, not fixed" entry says this card fixed it. Record docs/ops/a4d-acceptance.md; open item #82 closed. This card merged alone, before A4c, because A4c's own lock pins the driver's digest and is authored on top of it.
A33 · The attribution check catches a tool named in a trailer, a header or a generic assistant · A · M · S3-W0 · fix/a/a33-authorship-shapes (surfaced while auditing A4c's declarations on 2026-09-18, open item #93; carded 2026-09-19)
Context. The mirror-refresh attribution check matches a line when it matches the house pattern or one of two authorship shapes that authorship_regex builds (infra/import/mirrors/mirror_refresh.py:484-520): <verb> <preposition> [article] <tool identity> at :518 and <tool identity> <verb> at :519, over the 27 verbs, 7 prepositions and 12 tool identity families each spec lists (agent-runtime-transform.json:31-146, s2-transform.json:75-190). Measured at main 16dd547 with the driver's own Spec through tools/attribution_scan.py: the agent-runtime tree has 730 matching lines in 127 of 631 files, all 730 from the house pattern and 0 from a shape; the S2 trees have 38 in 23 of 273 files, 0 from a shape; and A4a's range gained 0 hits from the shapes (docs/ops/a4a-mirror-refresh-acceptance.md:285). So the shapes have no demonstrated behaviour on a real corpus, and a tool the house words do not name rests on them alone. Probed through Spec.line_matches with both specs: "written by <tool>", "generated by <tool>", "co-written with <tool>" and "<tool> wrote this" match. These miss: every <word>-by: trailer that names such a tool (Assisted-by, Reviewed-by, Generated-by, Signed-off-by and others were probed, with and without a comment prefix), because the passive shape needs whitespace between the verb and the preposition; a by-line with a colon ("Written by: <tool>", "Generated by: <tool>"), because the shape needs whitespace between the preposition and the identity; an author header ("Author: <tool>", "Authors: <tool>", the @author <tool> documentation tag), because "author" is not a verb of the list; "written with the help of <tool>", because only an article may stand between the preposition and the identity; and every generic phrasing ("Pair-programmed with an AI assistant", "Generated by our in-house LLM", "AI-generated draft, reviewed by a human", "Drafted by an LLM"), because no form names a generic assistant. "Co-authored-by: <tool>" matches through the house pattern. The two drop rules (agent-runtime-transform.json:11-22, s2-transform.json:15-26) are exact shapes for a tool co-author trailer and a generator footer; they change the transform, not the check. The pattern is shared by convention only: each spec holds its own copy of pattern, authorship, identity_pattern and product_references, byte-identical today, and nothing checks that they stay so. The shapes are built in code, so the change re-pins four digests: transform.spec_sha256 and transform.driver_sha256 in s2-mirrors.json:25,27 and in agent-runtime.json:20,22 (all four equal the files at 16dd547). Nothing else pins them: the procedure's test builds and relocks its own fixture lock (test-refresh-s2.sh:603-631), the merge message reads the digests from the lock at run time (refresh-mirror.sh:356-357), and the acceptance records quote old digests as history. Nothing in CI proves a real lock's digests either: the mirrors job runs check-tools and the fixture test only (.github/workflows/ci.yml:687-705); check-lock proves the S2 lock's digests when run by hand (it exits 0 at 16dd547) but no job runs it; and it refuses the pending agent-runtime lock before it reads a digest (mirror_refresh.py:974-976, through lock_for_run at :686-690), so a missed re-pin would surface only at the next refresh. The check runs only over a refresh's range (cmd_attribution, :1161-1270), so a line the widened check newly matches in an existing blob stops a refresh only when a range brings a new version of it. No commit message in the history of the three mirror roots carries a <word>-by: or <word>-with: line, and a rough measure of generic passive phrasings over the trees finds 0 and of compound adjectives a handful (one is "LLM-written" in services/scraper/migrations/add_llm_reporting_access.sql:266), so the review this widening creates is small. The test generator reads verbs, prepositions and families by position (test-refresh-s2.sh:171-174), pins 12 families (:157), and invents the key nouns for its unknown-key case (:1366). Its detector is the s2-transform.json and driver beside the test file (:176-177), and S2_GENERATOR_SPEC points only the generator at another spec.
Read first. infra/import/mirrors/mirror_refresh.py:37-78 (WORD at :40, the key sets, AUTHORSHIP_KEYS at :73), :484-520 (authorship_regex, the two shapes and the lookaround refusal at :509-511), :535-591 (Spec and line_matches), :676-730 (refuse_unready, lock_for_run and verify_digests), :974-990 (cmd_check_lock), :1161-1270 (cmd_attribution) and :1574-1612 (the command table); both specs' attribution_check blocks, agent-runtime-transform.json:28-156 (its description carries the closure counts at :29, and the authorship description at :32 says tool_identities is the identity set of the drop rules and identity_pattern, and that a first name like a family's bare name always counts) and s2-transform.json:72-200; both locks; infra/import/mirrors/tools/attribution_scan.py; infra/import/mirrors/test-refresh-s2.sh:1-38 (the file holds no attribution-shaped line), :153-406 (the generator: EXPECTED_FAMILIES at :157, the position constants at :171-174, S2_GENERATOR_SPEC at :176, positives and negatives at :376-394), :576-631 (the fixture spec copied from the real one, and relock), :1339-1372 (the unknown-key cases, nouns at :1366) and :1623-1641 (the authorship-shapes case); .github/workflows/ci.yml:115-125 (the mirrors filter) and :508-705 (the mirrors job); docs/runbooks/MIRROR_REFRESH.md §1 (:53), §4 (:141-170) and §7 (:276-292); docs/CI.md:18; docs/ops/a4c-acceptance.md:238-257 and :369-387; open item #93.
Deliverables. (1) The trailer class: a line holding, at its start or after a comment marker and whitespace, a header that is any hyphenated word ending in -by or -with, then a colon, whose value names a tool identity or a generic assistant anywhere in it, the address included, is an authorship match. The class is a shape over any such header, not a list of trailer words. co-authored-by stays with the house pattern as it is. (2) The header and by-line forms: an author header (Author:, Authors: and the @author documentation tag) whose value names a tool identity or a generic assistant is a match; the passive shape accepts a colon after the preposition ("Written by: <tool>"); and it accepts "the help of" and "the assistance of" between the preposition and the identity ("written with the help of <tool>"). (3) Generic assistants: the authorship block gains a list of generic assistant forms (an AI, an LLM, a language model, an AI or coding assistant), matched in the passive shape with a determiner and one modifier allowed ("our in-house"), in the trailer and header forms, as a compound adjective (<noun>-<verb>, for example "AI-generated"), and with a pair-programming verb appended to the verbs. The forms live in a new key and never in tool_identities, whose own description makes it the identity set of the drop rules and identity_pattern and whose count the test pins at 12. The new key is not named nouns, the invented key of the spec-authorship-unknown-key case, or that case takes another invented key. The driver refuses a lookaround in its forms, as it does for identity forms. Every existing verb, preposition and family keeps its position, because the generator reads them by position. (4) The spec's first-name rule holds in the new forms: a trailer or author header of a person whose first name is a first-name family's bare name is a hit until that occurrence is declared, as the authorship description states today. (5) One detector: pattern, authorship and identity_pattern are byte-identical in both specs, and a check the mirrors job runs refuses a change that leaves them different. Drop rules and product_references stay per mirror, because a product reference names one mirror's files. (6) Both locks re-pin both digests. One driver command proves a lock's pinned digests and loads its spec whether or not the lock is ready, and the mirrors job runs it over both locks, so a missed re-pin fails the pull request and not the next refresh. (7) The fixture of refused and accepted lines, generated at run time from the spec and the test's neutral words, since the test file holds no attribution-shaped line, and checked against the detector. Refused: the trailer class for every family's first form under the assisted, reviewed and signed-off headers and under one header built from neutral words, which proves the class and not a list; the colon by-line, the author header, the @author tag and "with the help of" for one family; every generic form in the passive, trailer, compound and pair-programming shapes; and a person's trailer whose first name is a first-name family's bare name. Accepted: a person's trailer with a neutral name, a trailer that names no tool and no generic assistant, "reviewed by a human", "written by the team", a hyphenated word that is not a header, and a generic assistant named with no authorship verb and no header. The authorship-shapes case carries the refused lines in a file and in a message and stops with exactly their count of hits and none of the accepted ones. (8) The review: tools/attribution_scan.py with the branch spec over both mirrors' trees at the base commit and over the messages of their imported commits; every line the widened check newly matches is read. Agent-runtime subject matter joins its ledger with a note, an authorship line is a finding with its digest in the record, and the agent-runtime spec's description restates its closure (ledger occurrences plus findings equal the lines the check matches). New S2 matches are listed in the record with their digests and classified, so the reviewer of the next S2 range that changes their files reads the earlier judgement. (9) docs/runbooks/MIRROR_REFRESH.md §7 states the trailer class, the header and by-line forms, the generic forms and the compound shape, and gains a paragraph on what the check does not claim to catch: a tool that no family and no generic form names, authorship stated in none of the shapes (a possessive, a claim broken across two lines), text that is not English, a spaced or disguised name, text inside images, and text inside compressed files until A34 lands. §1's attribution row and §4 name the digest command and the sameness check, docs/CI.md:18 describes the new step and carries the new fixture counts, and the A4c record's references "open item 5" and "open item 6" (docs/ops/a4c-acceptance.md:380, :386) name the tracker's #92 and #93. (10) The record docs/ops/a33-acceptance.md.
Interfaces. Exposes the widened authorship block (the trailer class, the header and by-line forms, the generic-assistant key and the compound shape), the sameness check and the digest command. Consumes Spec, the scan helper and the mirrors job. Unchanged: the house pattern, the drop rules, identity_pattern, product_references, the ledger's existing entries, the 17 findings, the lock schema and every stop sentence the procedure prints today.
Acceptance. bash infra/import/mirrors/test-refresh-s2.sh green in the mirrors job with the new cases. Each refused line fails on the base for its own reason: the branch's test file, run from a copy that holds the base driver and base specs beside it with S2_GENERATOR_SPEC set to the branch's s2-transform.json, reports every refused line by name as unmatched by the detector, and its authorship-shapes case stops with the base count of hits; each accepted line matches on neither the base nor the branch. The digest command exits 0 on both locks with the new digests, and stops with its digest sentence on a copy of each lock that keeps the old spec digest and on a copy that keeps the old driver digest; check-lock --lock infra/import/mirrors/s2-mirrors.json exits 0. The sameness check stops on a scratch copy of one spec with one verb removed and passes on one with one product reference removed. The mirrors job log shows the digest command and the sameness check run on the pull request. The record states, per mirror, the lines the widened check newly matches, each classified, and the agent-runtime closure with its new totals. python3 -m py_compile on the driver and the scan helper, shellcheck on the test file, and gitleaks detect --no-git --source . --config infra/secret-scan/gitleaks.toml --redact clean. Record docs/ops/a33-acceptance.md; open item #93 closed.
Out of scope. Widening the house pattern: its words are the house rule, and vendor words there would make every product mention a match (the house pattern alone matches 1,314 lines in 192 files of the agent-runtime tree, docs/ops/a4c-acceptance.md:245). The drop rules: a new drop rule changes the transform, and one that met any pre-boundary line would change rewritten ids and stop the boundary proof; a trailer the widened check finds in a range is scrubbed at its occurrence or fixed upstream. identity_pattern and the identity allowances. Text inside compressed files, which is A34. A shared rules file that both specs reference, which would add a digest and a lock key; the sameness check gives the same guarantee with no schema change.
Waits on / Unblocks. Nothing: the driver, both specs and the mirrors job exist on main / A34, whose review of archive members then runs the final shapes once; A4c's refresh (deliverable 7), which then runs the widened check. A4c's remaining deliverables edit the same agent-runtime lock and spec and rebase on this change.
Built as (PR #113 → main e5645ef, 2026-09-19). Code 2 review rounds → APPROVE. The attribution shapes are built from four parts instead of two: a by-line (a verb, a preposition, an optional colon after it, optionally "the help of" or "the assistance of", an optional article and a tool identity), an active line, a compound adjective, a trailer class (any hyphenated word ending in -by or -with, at the start of a line or after a comment marker) and an author header (Author:, Authors: or @author). A generic assistant counts only where a name of its own stands, and the .ai rule of the second round says a dot or an at sign in front of it makes a dotted name and not an assistant, so a person's address under an .ai top-level domain and a host that begins with such a word name nobody, while a trailer whose address carries a vendor's own name is still a match. Two commands are new: check-digests --lock <lock> proves the driver, the spec, the requirements pin and every overlay of a lock in any state of it, and check-detector --spec <a> --spec <b> proves that pattern, authorship and identity_pattern are byte-identical in every spec. The mirrors job runs both before the test. Four digests are re-pinned: the driver d24e3729… in both locks, the S2 transform spec f14a4c3b… and the agent-runtime transform spec 1ab1f842…. Evidence: test-refresh-s2.sh is 112 passed / 0 failed on the branch (106 results before) and 102 passed / 10 failed on the base, with the fixture naming all 148 refused forms in both a file form and a message form; the scans over both mirror trees gained 3 matches, each read in its file and each subject matter (a skill's inputs section, a teardown prompt and a comment on a reporting view), so no new finding and the 17 findings of docs/ops/a4c-acceptance.md stand. The merge commit's CI failed once on a pre-existing web race, open item #116, and the re-run was green; the staging deploy succeeded. Record docs/ops/a33-acceptance.md; open item #93 closed.
A34 · The attribution check reads inside an archive and declares what it cannot read · A · L · S3-W0 · fix/a/a34-check-reads-archives (surfaced while auditing A4c's declarations on 2026-09-18, open item #92; carded 2026-09-19)
Context. The attribution check splits a blob's raw bytes on newlines and matches each line (Spec.line_matches, infra/import/mirrors/mirror_refresh.py:588-591, reached through check at :1177-1196 from the per-blob loop at :1221-1232), and the scan helper reads the same way (tools/attribution_scan.py:62-68), so a compressed file shows it nothing. Measured at main 16dd547 by magic bytes over all 2,663 blobs of the repository's tree: 55 ZIP containers and 30 PDFs, all under services/agent-runtime, and no gzip, bzip2, xz, zstd, 7z, RAR, tar or OLE2 file anywhere. The S2 mirrors hold none in their trees or in their history, and the agent-runtime history holds the same 55 ZIP blobs and 31 PDFs. The 55 ZIPs are the 54 .skill bundles and one .xlsx workbook, which is a ZIP too. There is no PPTX: the one name with pptx in it, dropshipping/Copyblocks/Copy Blocks Presentation V.2.pptx.pdf, is a PDF by its bytes. With one PNG, one ICO and one .pyc they are the item's 88 encoded blobs of 631; A4c's 89 NUL-carrying blobs add console/src/app/scripts/page.tsx, a text file with one NUL byte at offset 4418, which the line reader reads. The 55 hold 253 entries: 249 deflated files and 4 directory entries, none encrypted, none an archive itself, at most 45 entries and 1,750,696 inflated bytes in one (the workbook), 4,579,610 inflated bytes in all 55, and an inflate ratio of at most 7.0 for an archive (inflated bytes over its size, the workbook) and 16.6 for one entry (inflated over compressed bytes, a workbook member of 42,511 bytes from 2,566). The driver's own matcher over their members finds 129 matching lines in 28 of the .skill bundles and 0 in the workbook; over their raw bytes, which is all the check reads, it finds 0; none of the 129 is authorship-shaped. The PDFs: inflating all 2,029 of their FlateDecode streams and matching the result finds 0, which is the naive probe the item calls inconclusive, while a third-party text extractor (poppler's pdftotext 24.02.0, not in the repository) extracts 14 matching lines in 5 of the 30, none authorship-shaped, and no text at all from 22 of the 30. The leak scans have the same hole: the procedure runs gitleaks 8.30.1, the lock's pin, with no archive depth over the range's commit patches and over two exports it writes as plain files, the range's blob versions and the final tree (infra/import/mirrors/refresh-mirror.sh:436-437, :451-456, :479-482), and gitleaks opens no archive by default. On a scratch canary at depth 1 it found the canary in a .zip and not in the same bytes named .skill, because it picks archives by name. The 249 member files give 0 findings with the repository's config today. Nothing is wrong in the mirror today; the claim is. A4c's closure, 713 recorded plus 17 findings equals the 730 lines the check matches, accounts for plain-text blobs only (docs/ops/a4c-acceptance.md:369-380). The choice, costed: a bounded unpack of ZIP needs only the standard library, so no tool pin changes; it reads each member's exact bytes, so a member line has a stable occurrence; and it covers every archive present. It costs a member occurrence kind, limits and their stops, and one review of the member lines now. A declared finding alone costs a signature table and one declaration list, but it keeps the 129 lines outside the mechanical check for good and puts a hand unpack and read on the reviewer of every refresh that brings a changed bundle. A PDF has no bounded unpack: reading its text needs a third-party extractor, a new pinned dependency whose line breaks, and so whose occurrences, move with its version, and it reads nothing from 22 of the 30. So the check opens ZIP, the one archive format present, and every other container it cannot read is a declared finding.
Read first. infra/import/mirrors/mirror_refresh.py:65-78 (the spec and occurrence key sets), :388-475 (occurrence_of, line_of, describe_line, allowance_map, reviewed_map, describe), :535-591 (Spec), :1161-1270 (cmd_attribution: the check, the blob loop, the occurrences rows at :1234-1245, the summary at :1248-1257 and the two stops at :1261-1270), :1273-1306 (cmd_export_range) and :1519-1539 (cmd_export_tree), which write what the leak scans read; infra/import/mirrors/refresh-mirror.sh:426-456 and :476-482 (the check and the three leak scans); infra/secret-scan/gitleaks.toml:6-7 (useDefault, whose stock path rules skip any .pdf, .docx or .xlsx path); infra/import/mirrors/tools/attribution_scan.py; infra/import/mirrors/agent-runtime-transform.json:28-157 (the check, its description with the closure counts, the ledger); infra/import/mirrors/test-refresh-s2.sh:1-38, :572-631 (the fixture copies the driver and relocks) and :1339-1372 (one unknown-key case per object); docs/runbooks/MIRROR_REFRESH.md §1 (:53), §5 (:171-195), §6 (:196-243) and §7 (:244-376); docs/CI.md:18; docs/ops/a4c-acceptance.md:238-257, :353-387 and :652-669; open items #92, #93 and #104, and A33, which lands first.
Deliverables. (1) Recognition by bytes, never by name: a blob that the standard library reads as a ZIP by its structure (a local-file header at its start, or an end-of-central-directory record with bytes before the first header, and the empty-archive record) is an archive the check opens; a blob that carries a signature from a table beside the code (PDF, gzip, bzip2, xz, zstd, 7z, RAR, lz4, tar's ustar at offset 257, an OLE2 compound file) is an opaque container, and so is a ZIP the driver cannot read completely. Each table entry says why it is there. Images stay plain blobs, read as bytes as today. (2) A bounded ZIP unpack in memory with the standard library only. Depth 1: a member that is itself a container, by the same recognition, is an opaque member and is never opened. Named per-archive limits, each a constant with its reason beside it, set well above what the tree holds today and small enough that an unpack in memory stays bounded on the 8 GB box: at most 1,000 entries in one archive (the tree's largest holds 45), at most 16 MiB inflated from one entry (largest today 491,286 bytes) and 64 MiB from one archive (largest today 1,750,696 bytes), and an inflate ratio of at most 100 for one entry (inflated over compressed bytes; highest today 16.6) and 50 for one archive (inflated bytes over the archive's size; highest today 7.0). A value equal to a limit passes and a value above it is refused. Inflation stops at a limit, never after it: an entry inflates in bounded pieces and stops as soon as its bytes or its ratio would pass a limit, whatever size its header records. An encrypted entry, a compression method the pinned interpreter cannot read, a damaged directory, an unsafe member (deliverable 3) or a reached per-archive limit makes the whole archive opaque, never partly read. Every member file is matched line by line by the same Spec.line_matches, and the archive's raw bytes are still matched as today, so nothing the check sees now stops being seen. (3) Member names are data, never paths. No code path hands a member name to the file system: nothing calls extract or extractall, and no path is joined from a member name, in the check, the exports or the scan helper. A member is unsafe when its name is empty or absolute, holds a .. or . segment or an empty one (a directory entry's one trailing slash aside), a backslash, a colon, or a NUL or other control character; when its recorded Unix file type is a symbolic link or anything other than a regular file or a directory (an entry that records no Unix type, as the workbook's 45 do, is a file or a directory by its name); or when its name equals another member's after normalisation (Unicode NFC, case folded, the trailing slash removed), an exact duplicate included. An archive with any unsafe member is refused whole: it is opaque, its hit names the member and the reason, and none of its members is matched or written. None of the 253 entries of the 55 archives is unsafe today. (4) A limit for the whole run: named limits on the entries and the inflated bytes that one run of any command that unpacks (the check, each export and the scan helper) reads over all its archives together, each a constant with its reason beside it: at most 10,000 entries and 512 MiB inflated in one run, set well above the totals of the agent-runtime tree (253 entries and 4,579,610 inflated bytes in all 55), which the final-tree export reads whole. A value equal to a limit passes and a value above it is refused. Reaching it stops the run, with its own sentence naming the limit, the counts and the archive it was reached in, before any further archive is opened. (5) A member occurrence: the source blob, the mapped path, the member name and the line number. subject_matter_lines and the ledger's seen accept it with exactly those keys, and the exact-key rule, the rule that one line has one digest, describe, the hit sentence and the rows of the occurrences file cover it. A member line has no scrub: an authorship line inside an archive stops the refresh until upstream removes it, and the runbook says so. (6) Opaque declarations: a new list in attribution_check, each entry naming one reviewed container by its source blob and mapped path (and its member, for an opaque member), the sha256 of the container's own bytes (the member's, for an opaque member), its format by signature, and a note of what was read and how. A declaration binds the bytes that were read, never a path alone: it declares a container only when the digest of the bytes the run reads at that occurrence equals its own, so a changed PDF at the same path is undeclared, and stops the run, until a new declaration follows a new read. An opaque container in the range is a hit until declared, and a declaration the range does not hold stops the run, as an absent subject-matter occurrence does. The summary counts archives opened, entries and inflated bytes read, member lines matched, and opaque containers declared and undeclared, by reason. (7) The leak scans read what the check reads: the range export and the tree export write each opened archive's member files as plain files, so the two directory scans read member bytes whatever the archive is called. Each member is written only at a path the driver derives from the archive's source blob and the member's index in the archive's directory, under a directory no exported mirror or tree path can take, and never at a path built from its name, so a name cannot place a file and no path rule of the scan's config skips a member by its name. The same inputs give the same paths on every run, an existing file at a derived path stops the export and is never overwritten, and each export lists, for each derived path, the source blob, the mapped path and the member name. The commit-patch scan is unchanged; an opaque container stays as it is, covered by its declaration, and none of its members is written. (8) The scan helper, with the same unpack, member-name rule and limits, lists member lines in the ledger shape and every opaque container with its format and digest. (9) The agent-runtime review, with the final shapes from A33: every member line the check matches is read; subject matter joins the ledger under the member occurrence, merged into the entry of the same content where one exists; an authorship line is a finding with its digest in the record. The spec's description restates the closure over plain blobs and members together, and the record lists the 30 PDFs, each with its digest and the measured extract counts, as the opaque containers that a range which changes one must declare. (10) Tests in test-refresh-s2.sh, with every archive built at run time by the fixture from generated lines and none committed: a member line built from the spec stops the run and its hit names the member; the same line declared at its member occurrence passes; an archive under a neutral extension is opened; an archive with bytes before its first header is opened; a nested archive and an encrypted entry are each opaque and stop until declared; an archive over each per-archive limit (the entries, the inflated bytes of one entry and of one archive, the ratio of one entry and of one archive), one case per limit and each built so that its limit is reached first, is opaque and stops until declared, and each case proves inflation stopped at its limit; archives that each stay under every per-archive limit but together pass the run limit stop the run with the run-limit sentence before the next archive is opened; one archive per unsafe member (an absolute name, a .. traversal, two names equal after normalisation, a symbolic link member, a backslash, a colon and a control character) is refused whole, its hit names the member and the reason, and the range export writes none of its members, with nothing created at any path the name points to, inside or outside the export; a safe member whose name is the relative path of a file the same export writes lands only at its derived path, that file stays byte for byte its blob, and two exports of the same range write the same derived paths; a canary in a member named with a .pdf extension is found by the range leak scan; a declared opaque container passes, an unused declaration stops, and the same path with changed bytes under the old declaration stops as undeclared; a blob with a PDF signature stops until declared; a canary inside a member of an archive under a neutral extension is found by the range leak scan, and one inside an archive that stands in the monorepo before the range is found by the final-tree scan; one unknown-key case for the declaration object and one for the member occurrence. (11) docs/runbooks/MIRROR_REFRESH.md: §1's attribution and leak rows (the leak row naming the derived member paths), §5's summary line, §6's new stops (the run limit's among them), and §7's container rule, member-name rule, per-archive and run limits, member occurrence, digest-bound opaque declarations and what the check still does not read (text in image metadata, text in a format it only declares); docs/CI.md:18 counts; the record docs/ops/a34-acceptance.md, which points to open item #104: the repository-wide secrets job and the pre-commit hook still open no archive.
Interfaces. Exposes the member occurrence kind, the digest-bound opaque-container declaration list, the member files at derived paths in the proof exports and their listing, the per-archive and run limits with their stops, and the new summary counts. Consumes Spec, the shapes, the sameness check and the digest command as A33 leaves them. Unchanged: the lock schema and the tool pins, the transform and its scrubs, the boundary proof, the manifests, the commit-patch leak scan, and every existing stop sentence.
Acceptance. bash infra/import/mirrors/test-refresh-s2.sh green in the mirrors job with the new cases, and each new case failing on the base sources for its own reason: on the base, the runs with the member line, the renamed archive, the prefixed archive, the nested, encrypted and over-limit archives and the PDF signature pass where each case expects a stop, because the check never opens or recognises them; the declaration and member-occurrence cases stop on the unknown key the base does not read; and both member canaries pass the leak scans unseen. The member-name, run-limit, derived-path and digest cases are stated as guards instead, and the record says so, because the base has no unpack and no declaration list for them to fail against for their own reason: on the base the unsafe-member and run-limit archives pass unopened where each case expects a stop, the derived-path and .pdf-named canary cases find no member written at all, and the changed-bytes case stops on the unknown declaration key; on the branch each asserts its own stop sentence or written paths, and the traversal and absolute-name cases also assert that nothing was created outside the export. The path-count case's summary assertions follow the new summary line. The measured numbers reproduce on the branch through the scan helper over the agent-runtime tree: 55 archives opened, 249 member files read, the member lines matched equal to 129 plus whatever A33's shapes add there, each classified in the record, and 30 opaque PDFs listed, each with its digest; no entry of the 55 archives is refused by the member-name rule, and neither a per-archive limit nor the run limit is reached; the S2 trees give 0 archives and 0 opaque containers. A leak scan of the unpacked member files with the repository config gives 0 findings. Both locks re-pinned, and A33's digest command and sameness check green on both. python3 -m py_compile on the driver and the scan helper, shellcheck on the scripts, and gitleaks detect --no-git --source . --config infra/secret-scan/gitleaks.toml --redact clean. Record docs/ops/a34-acceptance.md; open item #92 closed.
Out of scope. Unpacking PDF or any format other than ZIP: a text extractor is a new pinned dependency whose line breaks move with its version, and it reads nothing from 22 of the 30 PDFs, so those are declared. A scrub inside an archive, which would rewrite the container's bytes. Text in image metadata (the one PNG carries an iTXt chunk), which the runbook states is not read. The repository-wide secrets job and the pre-commit hook, which scan with gitleaks' default depth and pick archives by name: every archive in the repository today is inside services/agent-runtime, where the refresh's scans cover what a range brings, and open item #104 carries the gap. console/backups/console-dump.sql, A4c's handed-forward item 3. The authorship shapes, which are A33.
Waits on / Unblocks. A33: both re-pin the same four digests and restate the same agent-runtime closure, and the member review must run the final shapes once, so this is cut from main after that card merges / A4c's refresh (deliverable 7), whose check and leak scans then read the archives the mirror carries, and every later refresh of any mirror.
Amended 2026-09-20 (co-plan). The recognition table, the bounded unpack, the per-archive and run limits and the member-name rule live in infra/secret-scan/archives.py from the start; the refresh driver imports them from there, both locks pin that file once, and the mirrors filter key names it. A41 imports that module and changes no lock, no transform schema and no digest.
Built as (PR #134 → main 117e72d, 2026-09-21). The attribution check no longer stops at a container's bytes. infra/secret-scan/archives.py is new and standard library only: it recognises a container by its bytes and never by its name (a local-file header at the start, an end-of-central-directory record with bytes before the first header, or the empty-archive record is a ZIP; a signature from the table beside the code is an opaque container, and so is a ZIP the interpreter cannot read whole), unpacks a ZIP in memory one bounded piece at a time and stops at a limit without reading a byte past it (1,000 entries, 16 MiB per entry, 64 MiB per archive, ratios 100 and 50; per run 10,000 entries and 512 MiB, decided at the boundary before another byte is requested), refuses an archive whole on the first unsafe member name, and derives every exported member's path from the source blob and the member's index so no name reaches the file system. The refresh driver imports it, matches every member line as a member occurrence with the detector as A33 left it, declares every opaque container by the digest of the bytes it read (a changed PDF at the same path is undeclared and stops the run, an unused declaration stops it too), and the two proof exports write members at derived paths with a listing settled before the first write, so the leak scans read member bytes whatever the archive is called; the scan helper reads the same way; both mirror locks pin the module once and the mirrors filter key names it. The card was remapped onto the tree after the scraper's mirror graduation: the tests live in test-refresh-transcriber.sh, the two locks are the transcriber's and the agent-runtime's, and the retired S2 lock is named nowhere. The behavioural test is 158 results from 122 (113 clones from 99), every archive built at run time: a member line stops the run and the same line declared passes; a neutral extension and leading bytes open; nested and encrypted entries, each per-archive limit, the run limit, seven unsafe names, a patched size and a patched zero-byte checksum are opaque with their sentence; canaries in members are found by both leak scans. The agent-runtime review: 55 archives, 253 entries, 4,579,610 inflated bytes, 129 distinct member lines in 31 bundles (165 occurrences: 129 from the house pattern, 36 from A33's compound adjective), all subject matter and no finding, the ledger 680 at 714 → 809 at 879 with the 17 findings unchanged; 30 PDFs declared by digest (2,029 Flate streams give 0 matches; a third-party extractor reads text from 8 of the 30 and 14 matching lines, none authorship-shaped); the transcriber tree gives 0 archives and 0 opaque containers; a leak scan over the 249 unpacked members scans exactly 4,579,610 bytes and finds nothing. Review, three rounds to approve, each a real defect: the inflation read one byte past a limit before refusing; the export's listing could overwrite a member path; the run allowance could be overshot by one piece; a stream shorter than its declared size was assumed refused, and the standard library does not refuse it; a zero-byte member with a bad checksum was accepted unread when the allowance was spent. The mirrors job's own run measured 176 s against a floor of 144 s, so the floor and the derived limit (11 minutes) moved in the same pull request. A defect found on the way: loading the reader wrote __pycache__ into the checkout the refresh proves clean, so the import runs with bytecode writing off. Record docs/ops/a34-acceptance.md; open item #92 closed; #104 stays for A41, which imports the module unchanged.
A41 · The secret scan reads inside every archive, whatever it is called · A · M · S3-W0 · fix/a/a41-scan-inside-archives (surfaced while carding open item #92 on 2026-09-19, open item #104; carded 2026-09-20)
Context. The secrets job runs gitleaks git --config infra/secret-scan/gitleaks.toml --redact --log-opts="$RANGE" . (.github/workflows/ci.yml:958) and gitleaks dir --config infra/secret-scan/gitleaks.toml --redact . (:960), and the hook (.pre-commit-config.yaml:1-6) runs the gitleaks hook that gitleaks v8.30.1 defines with --config added. None of the three passes --max-archive-depth, whose default 0 opens no archive, and gitleaks picks an archive by the file name it is given, so the containers this repository carries are never opened at any depth. The job has no path filter and runs on every push and every pull request (ci.yml:938-960, docs/CI.md:22), and the binary is pinned at ci.yml:25. Re-measured at main 92a360f by magic bytes over all 2,836 tracked blobs: 55 ZIP containers, the 54 .skill bundles and the one .xlsx workbook, all under services/agent-runtime and none named .zip, and 30 PDFs, with no gzip, bzip2, xz, zstd, 7z, RAR, lz4, tar or OLE2 file anywhere. The 55 hold 253 entries: 249 deflated files and 4 directory entries, none stored, none encrypted, none an archive itself, at most 45 entries and 1,750,696 inflated bytes in one (the workbook), 4,579,610 inflated bytes in all 55, a ratio of at most 7.0 for an archive and 16.6 for one entry, which reproduces A34's table. Probed with the pinned binary and the repository config at 92a360f: a directory scan of services/agent-runtime at depth 1 logs "skipping binary file" with mime_type=application/zip for all 54 bundles and "skipping file: global allowlist" for the workbook and all 30 PDFs, because useDefault = true (infra/secret-scan/gitleaks.toml:6-7) brings the stock path allowlist, which skips 130 paths over the whole tree (72 PNG, 30 PDF, 9 SVG, 9 JPG, 8 lock files, the workbook and the config itself). A canary token in one member, built at run time, is found only under a .zip name and only at depth 1 or more: at depth 0 nothing is found anywhere, and at depth 1 and at depth 5 the same bytes named .skill, .xlsx, .docx, .jar or with no extension at all yield nothing, with the member deflated or stored. The staged scan and the commit scan behave the same way: a staged bundle.zip and a committed one are opened at depth 1 and give the finding as bundle.zip!SKILL.md, while bundle.skill, bundle.xlsx and bundle.docx give nothing at either depth. So a secret in a member of a bundle added anywhere in this repository outside a mirror refresh passes the job and the hook today. The mechanism, chosen and costed. Exposing the containers under names gitleaks opens cannot work: with --follow-symlinks it resolves the link and applies the name rule to the target, measured, so a symlink named .zip pointing at a bundle is still skipped as application/zip while only a hard link named .zip is opened, which needs one filesystem and puts 55 new paths in the checkout; and the commit scan and the staged scan read blobs out of git, where the name in the commit or the index is the name, so nothing can be renamed without rewriting the commit. That leaves two of the three commands blind, so it is refused. This card takes the other option: one runner that recognises a container by its bytes, unpacks it with A34's bounded unpack, writes each member at a derived path and hands the whole set to one gitleaks dir run with the repository config. Feeding members one at a time by stdin is refused on cost: one gitleaks stdin run costs about 1.44 seconds because the config compiles each time, 10 runs in 14.40 seconds, so 249 members would cost about 360 seconds, and a stdin finding carries an empty File. The directory run costs 1.61, 1.84 and 2.48 seconds over three runs for the 249 members and 4,579,610 bytes, and it reads every one of them: the run reports exactly 4,579,610 bytes scanned, so no member at an extension-less derived path is skipped, and a canary written at such a path is found while the same bytes under .xlsx or .docx are skipped by the stock allowlist before any read. The whole added cost of the tree step is 0.16 seconds to sniff the 2,836 tracked files, 0.23 seconds to unpack the 55 archives and write the 249 members, and one scan, so about 2.1 to 3.0 seconds, against 14.7 to 19.4 seconds for the tree scan that step already runs and 1.7 to 1.8 seconds for the range scan. The range step adds the enumeration alone in the ordinary case: three commits in the whole history touch an archive path, all three inside the imported agent-runtime history, and the worst case, a range that carries all 55, is the same 2.5 seconds. The hook reads the staged blobs from the index, and streaming every blob of the tree through git cat-file --batch, 203,498,654 bytes and a ceiling no commit reaches, takes 3.58 seconds, while the last ten merges touched 4 to 632 paths. The 249 members give 0 findings with the repository config today, so the card closes the hole and changes no verdict.
Read first. .github/workflows/ci.yml:938-960 (the whole secrets job, with the install at :945-949, the range at :950-958 and the tree at :959-960), :25 (the pinned version) and :116-126 (the mirrors filter key and why the gitleaks config is in it). .pre-commit-config.yaml (whole file). infra/secret-scan/gitleaks.toml:1-7 (the header and useDefault) and infra/secret-scan/extract_fingerprints.py:1-10 (the generator that writes that config, which this card does not change). docs/CI.md:18 (the mirrors row), :22 (the secrets row) and :83 (the hand scan), with AGENTS.md:36 and CLAUDE.md:5, which name the hand scan too. A34 whole, and in it the recognition table, the bounded unpack, the per-archive limits of 1,000 entries, 16 MiB from one entry, 64 MiB from one archive and ratios of 100 and 50, the run limits of 10,000 entries and 512 MiB, the member-name rule and the derived member paths, which this card imports and reuses without restating, and infra/secret-scan/archives.py as A34 leaves it, which is the file this card loads. infra/import/mirrors/refresh-mirror.sh:426-456 and :476-482 (the refresh's own scans, which A34 fixes and this card leaves alone). infra/import/mirrors/s2-mirrors.json:23-28 (the transform object) with mirror_refresh.py:76 and :261-265 (the exact-key rule and the digests that read it), which this card must leave exactly as A34 pins them, and infra/import/mirrors/test-refresh-s2.sh:705 (the one line that copies the driver and the module into each fixture) with :1384-1422 (the driver-digest case and its stop sentence), read to see what a second re-pin would cost. infra/scripts/test-image-frontend-gate.py and infra/scripts/test-compose-limits.py for the house shape of a behavioural test for a helper. Open items #92 and #104.
Deliverables. (1) One implementation of the unpack, imported and not moved. A34 lands the recognition table, the bounded unpack, the per-archive and run limits and the member-name rule in infra/secret-scan/archives.py and pins that file once in both mirror locks, so this card imports the module as it stands and changes no lock, no lock schema and no digest: the transform object of each lock (infra/import/mirrors/s2-mirrors.json:23-28) and the exact-key rule that reads it (mirror_refresh.py:76, :261-265) are untouched here, and a second re-pin of both locks never happens. The runner loads the module by path from its own directory, imports nothing else outside the standard library, and restates no limit anywhere: every bound it enforces is the module's own constant, so the refresh and the repository-wide scan cannot drift apart. (2) The runner, infra/secret-scan/scan-archives.py, with three modes that match what already runs: the checked-out tree (every tracked blob of HEAD), a commit range (every blob version any commit of the range introduces, read per commit so a blob added and deleted inside the range is still read), and the index (every staged blob). It enumerates through git in all three modes, never by walking the filesystem, so the set is exactly what the job checks out and an untracked file cannot enter it. Each blob is recognised by its bytes through the module, never by its name or its extension. (3) Derived member paths, held as material that may be a secret. Inflated member bytes are the plain text of whatever the archive carries, so the runner treats them the way the repository already treats generated credential material, where values.json is written and then chmodded to 0600 (infra/secret-scan/extract_fingerprints.py:105-108). The runner owns one TemporaryDirectory it creates for itself outside the work tree, at mode 0700, and writes every member file at mode 0600, with the mode set as the file is created and never after it holds bytes. Each member's path is derived from the source path's digest and the member's index in the archive's directory, never from the member's name, so no member name can place a file and no path rule of the config can skip a member by its name or its extension. No diagnostic, census line, error or stack print ever holds raw member bytes: a member is named by its archive, its name and its index, and a finding by its rule and its redacted match. The runner keeps the map from derived path to source path and member name, runs one gitleaks dir --config infra/secret-scan/gitleaks.toml --redact over the whole set, and translates each finding back to its archive, member and rule. The directory is removed in a finally before the runner returns, so it is gone on a pass, on a finding, on a refusal and on an unexpected error alike, and the runner removes only the directory it created itself. (4) Fail closed on a container it cannot read whole. A finding in a member fails the step, naming the source path, the member, the rule and the redacted match. An encrypted entry, a compression method the pinned interpreter cannot read, a damaged directory, an unsafe member name, a member that is itself a container, or a per-archive or run limit reached refuses that archive whole and fails the step with its own sentence, because an unreadable container is a hole in the scan and the tree holds none today. A container the table names but the runner does not open, the 30 PDFs today, is counted in the census and is not a failure, because the stock allowlist already skips those paths and reading their text needs a pinned extractor for the reason A34 records. (5) A census line in the log on every run, whatever the verdict: archives opened, members read, inflated bytes, findings, containers not opened by format, and archives refused by reason. (6) The three callers. The secrets job gains one step after the two it has, which runs the runner in range mode over the same RANGE the step above it builds and then in tree mode, with its own name so a failure says which. The three existing commands keep gitleaks at its default archive depth, so there is exactly one unpack, one limits table and one report of a member. .pre-commit-config.yaml, which today holds the upstream gitleaks hook alone (:1-6), keeps it and gains a repo: local entry beside it whose entry invokes the runner in index mode. That entry sets pass_filenames: false, because pre-commit appends the staged file names to a local command by default and the runner's contract is the whole index, which it reads itself so the work tree is never read; with language: system, always_run: true and stages: [pre-commit], so a commit that stages nothing an author thinks is an archive is still covered. The hand-scan line in AGENTS.md:36, CLAUDE.md:5 and docs/CI.md:83 names both commands, so a developer's own check covers members too. (7) A behavioural test, infra/secret-scan/test-scan-archives.py, run as a step of the secrets job, which has no path filter and already installs the pinned binary, with every archive and every canary built at run time and none committed. It drives the runner in all three modes on scratch repositories it builds itself, and it drives the hook the way a developer meets it, through pre-commit, not only the runner's index mode by hand, so a wrong pass_filenames, a wrong stage or a wrong entry fails here rather than on somebody's first commit. (8) docs/CI.md:22 states what the job now reads and :18 keeps its counts true. The mirrors filter key (ci.yml:116-126) is not widened here: A34 adds infra/secret-scan/archives.py to it by name when it lands that file, and the two files this card adds beside it are read by no mirror, so naming files rather than the directory is what keeps them from starting that long test. (9) The record docs/ops/a41-acceptance.md, with the census over the tree, the canary proofs, the timings of all three steps of the job before and after, and the containers the runner still does not open, which it files as its own open item.
Interfaces. Exposes infra/secret-scan/scan-archives.py with its three modes, its derived-path rule and its census line, which the job, the hook and a hand scan all call, and a local hook entry whose contract is the whole index. Consumes infra/secret-scan/archives.py exactly as A34 lands it, with its recognition table, bounded unpack, limits and member-name rule, the repository gitleaks config, and the pinned binary at ci.yml:25. Unchanged, and named because a reader will look for them: both mirror locks and their transform schema, the refresh driver, the refresh test's fixture, the mirrors filter key, and every digest A34 pins. Unchanged too: every rule and allowlist in infra/secret-scan/gitleaks.toml and the generator that writes it, the three existing gitleaks commands and their flags, the refresh's own scans and exports, and the ci aggregator's job list.
Acceptance. The runner over the checked-out tree at 92a360f reports 55 archives opened, 249 members, 4,579,610 inflated bytes, 0 findings, 30 containers not opened and 0 archives refused, with no per-archive or run limit reached, and the numbers land in the record. Canary cases, each built at run time, each found by the runner in all three modes and each passing the base job and the base hook unseen: a canary in a deflated member of a bundle named .skill, in a stored member of a bundle named .skill, in an XML member of a workbook named .xlsx, and in a member of an archive with no extension at all. Each of those four passes on the base at archive depth 0, 1 and 5, which is what makes them proof of the gap and not of the flag. Refusal cases, each failing on the branch with its own sentence and each passing unopened on the base: a member name that is absolute, one that carries a traversal, one that carries a control character, two names equal after normalisation, a member recorded as a symbolic link, a member that is itself an archive, an encrypted entry, an archive over each per-archive limit, and a set of archives that together pass the run limit. After the traversal and absolute-name cases nothing exists at the path the name points to, inside or outside the temporary directory. The log holds no secret in any case: findings print redacted, the census prints counts, and no diagnostic of any case, the unexpected-error case included, holds a raw member byte. While the runner holds members, its directory is mode 0700 and every member file is mode 0600, asserted by the test on the live paths; the directory is gone after a pass, after a finding, after a refusal and after an error raised inside the scan, which the test forces; and git status --porcelain is empty after the step, so the checkout the later jobs read is untouched. The hook is proved as a hook and not only as a command: pre-commit run --all-files green, and a run of the hook itself through pre-commit run <id> with a bundle carrying a canary staged is refused, with the same bundle left unstaged not refused, and one case with many files staged proves the entry takes no file names and still reads the whole index. On the mirror side nothing is asserted, because nothing there is touched: the branch changes no file under infra/import/mirrors, so the mirrors job does not fire, and the infra job, which fires on infra/**, is green. actionlint -shellcheck on both workflows, shellcheck on every infra script, and python3 -m py_compile on the runner and its test. The secrets job green on the pull request, with its three steps' times in the record. No comment that the card adds names a card, an item or a phase. Record docs/ops/a41-acceptance.md; open item #104 closed.
Out of scope. Reading the text of a PDF or of any container other than ZIP, for the reason A34 records: a text extractor is a new pinned dependency whose behaviour moves with its version, and it reads nothing from 22 of the 30 PDFs. The other 100 paths the stock allowlist skips before any read, the 72 PNG, 9 SVG, 9 JPG and 8 lock files, which stay as they are, are counted in the census and filed with the PDFs as their own open item. Any change to a rule, an allowlist or the generator in infra/secret-scan/, which would change what a finding is rather than what is read. Passing --max-archive-depth to any of the three existing commands, which would give a second unpack with limits this repository does not set. Scanning the full history rather than the range and the tree, which is what the job scans today. Untracked files, which the job's checkout does not hold. The refresh's own check, exports and leak scans, which are A34's, and the attribution shapes, which are A33's.
Waits on / Unblocks. A34, which lands infra/secret-scan/archives.py with the recognition table, the bounded unpack, the per-archive and run limits and the member-name rule in it from the start, has the refresh driver import them from there, pins that one file in both mirror locks once and adds it to the mirrors filter key by name, and whose record already points here. This card is cut from main after A34 merges and imports that module unchanged, so the locks are re-pinned once in A34 and never again here, and A34 itself waits on A33. The one surface the two cards share is docs/CI.md, so whichever merges second re-runs its own checks on the first one's merge commit before it is called green / every later card that puts an archive of any name in the tree, which the job and the hook then read from its first commit, and any later scan that needs a bounded unpack, which loads the one module instead of writing a second.
Built as (PR #138 → main 79e93a8, 2026-09-21). One runner, infra/secret-scan/scan-archives.py, reads inside every archive in three modes that match what already runs: the checked-out tree, a commit range and the index. It enumerates through git only, recognises a container by its bytes through infra/secret-scan/archives.py, which it loads by path and leaves unchanged with no limit restated, unpacks under that module's bounds with one budget per run, writes each member at a derived path inside one private directory (mode 0700, files 0600 from creation, created under a checked parent outside the work tree and checked again after creation), runs one gitleaks dir with the repository config over the whole set and names each finding by its archive, member, index and rule. A range is read per commit with diff-tree -r -m --root --no-renames, the shape the mirror refresh already uses, so a blob added and deleted inside the range, a merge-only addition and an all-zero base are all read; mode 120000 blobs are read as blobs and 160000 is skipped and counted. An occurrence is a path, a stage and path, or a commit and path; every occurrence is unpacked, counted and materialised under the digest of its own identity, and only a blob's classification is cached. It fails closed: a finding, an encrypted entry, an unreadable method, a damaged directory, an unsafe member name, a limit, a member that is itself a container of any kind the recognition table names, an unusable scanner (exit outside 0 and 1, reported by its exit code alone) and a directory it could not remove (exit 2, checked with lexists) each fail the step with a sentence of their own, and no diagnostic repeats the scanner's output or holds a member byte. The census prints on every run and counts what was read: an archive the reader refuses counts nothing, and one the runner refuses after inflation counts. The secrets job gains four steps after the two scans it had, which are untouched: pre-commit 4.6.2 through pipx (a global pip install can be refused on the hosted image), the runner over the same range, the runner over the tree, and the behavioural test. .pre-commit-config.yaml keeps the upstream hook and gains a local entry (language: system, pass_filenames: false, always_run: true) that reads the whole index. The test, 43 cases with every archive and canary built at run time, proves the four canaries in three modes, every refusal, the enumeration cases, the modes on the live paths, the directory gone after a pass, a finding, a refusal and a forced error, and the hook as a hook, with no network and no Go toolchain: a scratch repository keeps the real config's local block verbatim and pre-commit run is driven from its root and from a subdirectory. The census over the tree equals the card's: 55 archives, 249 members, 4,579,610 inflated bytes, 0 findings, 30 PDFs recognised and not opened, 0 refused, over 2,865 tracked blobs. The base proof: each canary passes the base job's two commands and the base hook unseen at archive depth 0, 1 and 5, while the same bytes named .zip are found at depth 1 and 5. Timings on the box: tree 6 to 7 s, index 6 to 9 s, a 140-commit range 8 to 11 s, the test 68 s; the job went from 35 s to 54 s and its derived limit stays 7 minutes. One sentence of the card was out of date: AGENTS.md is in the mirrors filter key, so the pull request did start the mirrors job, which was green with nothing under infra/import/mirrors changed. Review: 2 rounds to approve, five must-fixes in round 1 (the scanner's stderr echo, the temporary directory's creation, the cleanup guarantee, the pre-commit install, the census of an archive refused after inflation). Record docs/ops/a41-acceptance.md. Open item #104 closed; #147 (the 30 PDFs and the 99 paths the stock allowlist skips by name), #148 (the documented hand scan reads ignored paths, so a fixture secret folded into bytecode is found beside a clean tree) and #149 (the existing range step passes an all-zero base straight to gitleaks; not reachable today) filed.
A10 · Agent runtime on Postgres studio + engine API parity · A · L · S3-W1
Read first. D31; console/src/db/client.ts (DDL) + dal.ts ("the Postgres seam"); every /api/engine/* and /api/research/* route; console/scripts/night-shift-runner.mjs.
Deliverables. Migration db/migrations/unified/studio/001_baseline.sql (every SQLite table → Postgres with workspace_id, JSON columns as jsonb, session_events append-only); DAL rewritten on pg (same function signatures); data migration script SQLite → Postgres with row-count verify; engine API unchanged in shape (x-cron-token replaced by the Skynet service token, one adapter); the console runs as service studio in the stack (Node 24, cpus: 4, mem_limit: 8g) reading the new DB; pnpm --filter agent-runtime test + test_studio_pg parity suite (every DAL function on both backends over the same fixture).
Acceptance. Parity suite green; migrated data verified; a session starts, streams, gates and completes on Postgres.
Split at the brief co-plan (2026-09-26). Two facts split this card: services/agent-runtime is a locked mirror, so the runtime is copied into its own service services/studio rather than edited in place; and studio/001 and 002 already exist on main, so the runtime baseline is studio/003_runtime.sql. A10a delivers the foundation and A10b the port; both are cards of their own below. The service runs at 1 CPU and 2 GiB under a tenant budget raised from 16 to 17 CPUs on the staging box's measured capacity; sessions are archived, never deleted; the engine API keeps its paths, methods and shapes with the x-cron-token header carrying a core service token.
A10a · Studio runtime foundation: the copy, the schema, the importer · A · M · S3-W1
Read first. D31; services/agent-runtime/console/src/db/client.ts and dal.ts; db/migrations/unified/studio/001_briefs.sql and 002_evidence_packet.sql; apps/api/app/core/scripts/import_users.py; infra/import/mirrors/tools/check-mirror-scope.py.
Deliverables. services/studio as a byte-identical copy of the console at a recorded boundary commit (only the package name and a temporary test script differ; the mirror, its lock and the scope check untouched); studio/003_runtime.sql with every console table under studio, workspace_id on every table, key, index and reference, jsonb values, no delete path on session_events, the sessions archive columns, the studio.console_imports ledger per workspace and source digest, grants to the application role by OID with a post-grant assertion and no runtime SECURITY DEFINER; import_console.py, self-contained: a WAL-safe backup with its digest, the email and workstream maps, per-table counts, idempotence per workspace and digest, a dry run, deterministic inactive placeholder users for disabled or unmapped referenced usernames, the ledger row; a fixture generator for a synthetic console database that is the only test input; the plan pins, the staging manifest and the parity pair.
Acceptance. The copy-equality test, the migration test on the full plan, the two-workspace key test, the importer's tests on the fixture; docs/ops/a10a-acceptance.md. Synthetic fixtures may build this card now; the real backup import starts only after Bian confirms D31's source and snapshot rules.
Out of scope. The port of the data layer, the auth, the parity suite and the service in the stack (A10b); the registries, the runner and the assets (A11); the archive and the switch (A20).
A10b · Studio runtime on Postgres: the DAL, the auth, the parity, the service · A · L · S3-W1
Read first. A10a's record; services/studio/src/db/dal.ts, src/proxy.ts, src/server/auth.ts, src/server/**; apps/api/app/backend/services/workos_bridge.py (the service-token shape); infra/compose/compose.staging.yml; infra/scripts/compose-limits.py; docs/ops/RESOURCE_LIMITS.md.
Deliverables. The data layer on PostgreSQL, asynchronous, same names and arguments, every caller awaiting, a type-aware floating-promise check in the studio test command, every query bound to a workspace; sessions archived, never deleted; the console cookie and local auth tables replaced by core access tokens and permission keys, machine requests verified as core service tokens behind the unchanged x-cron-token header name, never a static token; API and data-layer parity as contracts: every engine route's path, method, status, bodies and side effects unchanged, every exported data-layer operation equal between the SQLite reference and PostgreSQL over the same fixture, the behavioural cases (session retitle, the English pin, the English-reference warn tier, output_type and lane parity, the zone rule, winners to SuperMemory with the ledger and lease, the inbox dedupe), and a path walk over the skills, prompts and loops; the service studio in both stacks at 1 CPU and 2 GiB on the intelligence profile with its build job, CI job and required key names, the tenant budget raised from 16 to 17 CPUs with docs/ops/RESOURCE_LIMITS.md amended.
Acceptance. npm --prefix services/studio test green with every suite; the api tests touched; the budget assertion on both stacks; docs/ops/a10b-acceptance.md.
Out of scope. The registries, the runner, the rotation and the skills mount (A11); the console's URL, the archive and the switch (A20, X7); the Studio pages (V23); any read of a real console database.
A11 · Runtime assets, night-shift on the stack, workspaces, subscription binding + rotation · A · L · S3-W2
Read first. D31; engine/ layout (skills, doctrine, memory, playbooks), the repo-root business workspace dirs dropshipping/, supplements/, strategic-loop/, engine/automation/*, night-shift-jobs.cloud.json, registry.mac.ts; accounts.ts (CLAUDE_ACCOUNT_{i}_NAME/TOKEN), /admin/claude-usage.
Deliverables. Skills, doctrine, playbooks and the business workspace dirs (dropshipping/, supplements/, strategic-loop/) become repo-tracked runtime assets mounted read-only into the studio service (no laptop copies; /evidence-layer + _doctrine/evidence-contract.md included, its MCP transport bound to A18b's /mcp/pubmed); every loop (npt-build-loop, dream, lint, backlog, loop-closer, frankenstein, trend-watcher, volume-trigger, backup) registered in one scheduler on the stack (studio.cron_jobs, UTC schedules recorded, per-job limits); the Claude subscription runner = binding studio.agent in core.model_bindings with the account rotation intact and usage posted to usage_events; /admin/claude-usage data in the Skynet admin; node services/agent-runtime/scripts/run-night-shift-smoke.mjs.
Acceptance. Smoke green (each job runs once on staging); curl -sf /api/admin/claude-usage shows rotated accounts. Bian's Mac launchd jobs are disabled per D31's window at X7's console switch, not in this card. On staging, a loop configured to write to a production destination is refused without contacting that destination (both amended 2026-09-19).
Amended 2026-09-19 (the owner's decision: production at the terminal go-live). During S3 the night-shift jobs and the other loops run on staging. Their production activation is terminal X7 work. Disabling Bian's Mac launchd jobs belongs to the console switch X7 executes; until that switch the standalone console stays authoritative. Under the staging write rule (architecture §6), every write a staging loop makes to a shared external system (ClickUp, chat channels, mail, storage or a repository) uses a sandbox destination, credential or allowlist, or is disabled: ClickUp only through the workos bridge's workspace allowlist and the test product 999001, mail only through MAIL_SANDBOX_TO, storage only in the staging R2 bucket. A loop with no approved sandbox runs with its external writes disabled.
Split at the brief co-plan (2026-09-26). The facts pass found no engine/doctrine, engine/playbooks, /evidence-layer, evidence-contract.md or run-night-shift-smoke.mjs in the mirror (doctrine is engine/skills/_doctrine; playbooks are a Studio table; the contract is V14's seed); two registries with ten distinct loops; a runner that keeps file state and spawns the Agent SDK's claude with the rotated account token; a backup loop that pushes the whole workspace to GitHub; the api's scheduler is the worker Supervisor; core.model_bindings and core.usage_events exist with the service runtime. A11a delivers the assets, the scheduler, the guard, the backup and the smoke; A11b the binding, the usage and the admin surface; both are cards of their own below. Neither is accepted until its own acceptance evidence is complete; the real-account staging run needs staging CLAUDE_ACCOUNT_{i}_* values (an owner input, names only in the repo).
A11a · Runtime assets on the stack, the loop scheduler, the staging write guard, the stack backup, the smoke · A · L · S3-W2
Read first. D31 §3, §5, §7; services/studio/scripts/night-shift-runner.mjs, night-shift-jobs.cloud.json, src/server/cron/registry*.ts, docker-entrypoint.sh, Dockerfile; engine/automation/*; infra/backup/pg-backup.sh and the backup service; infra/scripts/compose-limits.py; the architecture design's staging write rule.
Deliverables. A read-only asset volume (engine: skills, _doctrine, memory, automation; the business inputs dropshipping/, supplements/, strategic-loop/; never console/) populated from the release image by the base-compose one-shot studio-init (ONE_SHOT; studio depends on its successful completion) and mounted :ro into Studio; a separate writable workspace-state volume holding the first-deploy working copy of the business inputs, the job working directories, $CLAUDE_HOME's mutable state, logs and spool, with root-owned protected links so the non-root Studio user cannot replace the engine link; the entrypoint no longer copies assets into $CLAUDE_HOME; the runner runs scripts and reads prompts from the asset volume and refuses resolved paths outside it. studio/005_cron_jobs.sql (A10b took studio/004) (studio.cron_jobs, PK (workspace_id, job_id), schedule_utc, script, prompt_path, grace_minutes, timeout_minutes, max_retries, external_writes, enabled, stop_requested, last_fired_at, running_since, lock_token; grants by OID; the post-grant check extended) seeded with exactly ten rows (npt-build-loop, npt-loop-closer, frankenstein, npt-trend-watcher, dream, backlog, lint, volume-trigger, backup, backlog-feedback; UTC schedules and grace from the cloud registry, explicit UTC schedules and limits for the Mac-only jobs; npt-* in the npt workspace, the rest in the importer's * workspace); the runner on the async DAL as the loops' sole scheduler (a PostgreSQL advisory transaction lock per (workspace_id, job_id), the claim conditional on last_fired_at and an absent running_since, grace, timeout kill, stop_requested; cron_runs rows inserted running and finalized ok / refused / failed / timed_out / stopped); the registries retired from the Studio copy and the status UI reading studio.cron_jobs; the api Supervisor untouched. The staging write guard: external_writes from the closed vocabulary engine, sessions, files, trendtrack, fathom_read, skills_edit, repo_push, backup_store; RUNTIME_WRITE_MODE and RUNTIME_ALLOWED_WRITES validated at startup with a destination check per capability (repo_push and skills_edit invalid on staging; a missing control fails startup closed); a pre-spawn refusal that inserts a refused row and never calls the spawn function; backlog proposal-only (no skills_edit), dream and lint file-only, npt-trend-watcher and npt-build-loop only with the staging TrendTrack key. backup never runs git: the backup sidecar remains the archive scheduler (the Postgres custom dump plus a dated, checksummed archive of the workspace-state volume to /backups, the volume mounted read-only in that process), the backup cron row is a post-archive reconciliation job recording the manifest, and a host-run restore proof restores both artefacts into an isolated compose project. The Studio MCP configuration adds only the authenticated PubMed transport to the scraper's /mcp/pubmed. services/studio/scripts/run-night-shift-smoke.mjs runs every seeded row through the runner code path with a stub model and proves each expected cron_runs result, including the listed staging refusals.
Acceptance. The stub smoke green as a builder test; the proofs of the review checklist (asset writes and link replacement refused, paths outside the mount refused, studio-init blocks startup on failure, two-workspace isolation, the ten seeds, refusal before spawn and before any fake destination contact, the lifecycle records, the restorable dump and archive); docs/ops/a11a-acceptance.md with the ten-job table, the volume layout, the guard rules, the manifest format, the retention rule, the reconciliation-row schedule and the restore-proof command. The card is accepted only when the orchestrator records one real-account staging execution of every enabled job after the staging accounts exist, naming the disabled jobs and their refusal evidence.
Out of scope. The Mac launchd jobs (X7); the public cutover and the archive (A20); the inbox (S14); the prompt layers, the evidence layer and the contract (V14); the standalone mirror; production activation; the binding, the usage and the admin surface (A11b).
A11b · The studio.agent binding, usage into core.usage_events, the Claude-usage data in the Skynet admin · A · M · S3-W2
Read first. services/studio/src/server/agent/accounts.ts, sessionManager.ts, managedSession.ts, src/lib/models.ts; apps/api/app/core/bindings/{seed.py,store.py,router.py}; db/migrations/unified/core/007_bindings.sql; apps/web/src/shell/pages/admin/.
Deliverables. The claude_subscription provider, the model claude-opus-5 and the binding studio.agent for every workspace present at seed time, through the bindings seed catalogue (no core migration; missing rows inserted, an administrator's later change never overwritten; no claim of automatic seeding for a later workspace). The Studio session manager resolves GET /api/bindings/studio.agent with its svc:runtime token and takes from it the default model of Agent SDK sessions (replacing DEFAULT_MODEL; an explicit operator model stays a session-level choice; the automation scripts keep their own --model); the account rotation of accounts.ts (pin → healthy → soonest reset) unchanged; one idempotent POST /api/usage per completed session (service runtime, workflow_key studio.agent, request_id = the session id). GET/POST /api/admin/claude-usage in apps/api reading and updating the Studio account tables directly, in the console route's response shape, gated by admin.settings.manage, the pin update and its audit row in one transaction; apps/web/src/shell/pages/admin/ClaudeUsagePage.tsx in the admin rail.
Acceptance. The seed idempotent over every existing workspace; binding resolution workspace-scoped and governing only the session-manager default; usage replay idempotent; the admin route permission-gated; the web test; docs/ops/a11b-acceptance.md. On staging, curl -sf /api/admin/claude-usage shows the rotated accounts once the staging accounts exist (recorded by the orchestrator).
Out of scope. Any change to the rotation order or the exhaustion rules; a core migration; the Mac jobs, the cutover, the inbox and the prompt layers.
A21 · Studio parity APIs · A · L · S3-W3
Deliverables. Every console-facing API the native pages need, on the Postgres DAL: scripts (list/detail/revisions/archive), sessions (start/stream/gates/artifacts), scoreboard/weekly results, opportunities + votes, backlog (digests/ideas/accept), editors/assignment (/api/assign semantics; the RescaleOS ad-name format retired in favour of the creative code + v3 name from S3/S8a), notifications, playbook, research ingest, KPI reminder (now a readback check), feedback pollers (Telegram/Discord) state; route map old → new in docs/checklists/studio-api-parity.md.
Acceptance. pnpm --filter agent-runtime test -- studio-parity green; the parity map complete.
Settled at the brief co-plan (2026-09-26). The facts pass found the Studio service unreachable from the browser (Caddy serves /api/* to the api and the rest to the SPA; the Studio listens unpublished and refuses cross-origin writes), no join key from a script to a creative code, two absent routes the scripts page calls (editor-roster, product-folder), no poller-state route, a proxy allow-list narrower than requirePodWrite, and an acceptance command that matches no package. A21 therefore: gives the Studio its same-origin public seam (Caddy redir /studio /studio/ + handle /studio/* → studio:3000 in both stacks; Next basePath: "/studio"; one base-path-aware helper for every browser-side API, SSE and login path; LINK_STUDIO_URL defaults to /studio; checkOrigin unchanged; the old-URL redirect stays A20/X7's); generates docs/checklists/studio-api-parity.md from a checked route manifest (old path, new /studio/api/... path, method, guard, DAL calls, response keys, consumer, retirement) with a drift test; repairs the page calls (GET /api/editors/assignable with editor_user_id instead of a second roster route; GET /api/product-folder?ws=&product= → {masterUrl, folderUrl, folderName}), adds GET /api/pollers (configured by key-name presence only, cursor, last run) and extends the proxy allow-list for pod-write editors; adds no gates/artifacts routes (the session snapshot already carries them). Assignment retires the legacy ad name: studio/008_script_creative_code.sql adds nullable creative_code, product_number, ad_name_v3, ad_name_state to studio.scripts (the legacy ad_name column stays, no longer written); the assignment form supplies the issue input (registered product_number, kind, team_made, variant, asset_type) and the Studio forwards the strategist's own Bearer token to POST /api/creative-codes (never svc:runtime); a new GET /api/creative-codes/{product_number}/{code}/ad-name (production.read) returns the bridge's AdNameAnswer, and only assembled stores a v3 name, the other states store ad_name_state and the audit reason. The KPI reminder becomes a workspace-scoped readback check through a new GET /api/performance/freshness?product=&codes= (a member with performance.read, or svc:runtime only; a server-to-server call to the api's internal origin): a script is due when its code has no snapshot in the current ISO week; scripts without a code are skipped and counted; typed weekly results stay until V23. Acceptance: cd services/studio && npx tsx --test test/studio-parity.test.ts inside npm --prefix services/studio test. Research ingest, notifications and playbook change only if a manifest row or contract test exposes a parity defect.
Amended at the V23 co-plan (2026-09-27). Two items join A21's required outcome because V23a depends on them: the Studio's authentication selection falls back to a valid Bearer when a present cookie is invalid (401 only when neither verifies; permission, workspace and Origin checks unchanged), and scoreboard/update appends a script.sign_off audit action (script identity, previous and new value, caller) when the sign-off changes; the freshness route admits only svc:runtime.
Amended 2026-09-27 at the Studio seam co-plan (after this card's review). /studio remains the Studio service's Next home, and /studio/api/*, /studio/_next/*, login, SSE, and every page not yet ported remain owned by the Studio service. A native shell Studio page owns only an explicit /studio/<path> prefix declared ahead of the Studio Caddy handler (the @studio_shell matcher before @studio in infra/caddy/Caddyfile, serving the shell index); its card adds that prefix, its SPA route, and seam-test rows in the same change. The SPA router, RequireAuth and RequirePermission then enforce the normal shell gates. When a port captures a former Next URL, existing Studio links to that URL intentionally begin reaching the shell port; API, asset, login, and stream paths are never captured. V14 applies it first (/studio/prompts), V16 (/studio/hooks) and V23a–d (their ported route families) follow.
V14 · Prompt layers + per-user overlays + Mathew-editable assets · V · L · S3-W3
Read first. Functional design §3.0 (layers: doctrine → workflow → brand/product → personal overlay), TrenchOS digest (authoring guardrails), D32; A12's core.prompt_assets stub.
Deliverables. core.prompt_assets(id, workspace_id, layer ∈ doctrine|workflow|brand|user, key, version, body, author_id, status) + resolver (resolve_stack(workflow_key, product, user) → ordered layers, recorded on every run as resolved_stack); editor UI with versions/diff/publish, negation lint and positive-only checks, and an eval step before publish (a benchmark set per layer — 5–20 fixed inputs with expected properties, run against the new version and the current one, results side by side; the pattern of Anthropic's skill-creator), role-gated (Mathew/admin for doctrine + workflow; users for their overlay); static-ads instructions migrated as the first user overlays; evidence-contract (evidence classes + claims policy) seeded as a doctrine-layer asset.
Acceptance. pnpm --filter web test -- studio-prompt-layers; one recorded edit → re-run shows the new version in resolved_stack.
Bridge impact. Seeds this layer system with the Grok extraction schema, the "write one for me" packet contract, and rules.md from bridge I1/I3/I4.
Settled at the brief co-plan (2026-09-26). The facts pass superseded six lines of this card: the static-ads instructions are per workspace since V29, so they migrate as workspace-scoped workflow assets (production.instructions.<step with hyphens as dots>), not as user overlays; core.prompt_assets (core/017) gains owner_user_id (the user layer) and brand_key (the brand layer) with a CHECK, expression uniqueness, an extended guard and a scoped prompt_asset_publish; keys stay dotted lower-case words, so the contract is intelligence.evidence.contract; the bridge seeds I1/I3/I4 never existed and are recorded as not seedable; the acceptance command is cd apps/web && npm test -- studio-prompt-layers; four permission keys are added (studio.prompts.doctrine.manage, studio.prompts.workflow.manage for admin; studio.prompts.brand.edit for strategist and admin; studio.prompts.brand.publish for admin) and the user layer is owner-bound server-side with no extra key. The resolver reads a complete manifest core.prompt_workflows(workspace_id, workflow_key, doctrine_keys, brand_keys, user_keys) (the sole declared authority; optional:<key> marks an optional scoped entry) in the order doctrine → workflow → brand → user, records every resolution in core.prompt_resolutions, and the two asset-consuming run families record {resolution_id, entries}: teardowns at enqueue/retry (ad_teardowns.resolved_stack, one meta_ads migration; the worker executes the persisted stack with a ported resolver pinned to shared fixtures, no API hop) and the static-ads pipeline in production.sessions.launch_config.resolved_stack before the model call (read order: published asset → workspace row → shipped file). Publish requires the negation lint (\bnever\b|\bdon't\b|\bdo not\b|\bavoid\b|\bno\s+\p{L} outside fenced code, exempted by a separate Allow: sentence) to pass and a fresh eval (benchmark cases {input, expect:[{kind, value}]} in core.prompt_benchmarks, results in core.prompt_evals, run only in apps/api through the gateway with an injected model-call seam) whose candidate passes every expectation the current version passes, or an immutable recorded override. The editor is the native shell route studio/prompts (studio.read), with pages/system/Instructions.tsx reduced to a compatibility redirect. One core migration (024 unless A11b takes it) and one meta_ads migration, both digest inputs. V16 authors the brand-tone band content in the brand layer V14 provides.
S16 · Script-entity bridge to ClickUp Script cards + demand-line hand-off · S · L · S3-W3
Read first. D33; workos forward.ts:186 (handleScriptApproved), LISTS.scripts, demand-week.ts, spawn.ts, qa-gates/*; S8a bridge module.
Deliverables. Bridge endpoints in workos: POST /scripts (create the ClickUp Script card from a Skynet script entity, structural fields only per invariant 8c), PATCH /scripts/:id (human-owned fields), webhook → Skynet on Script status changes (approved → Creative spawn continues in workos); POST /demand-lines (from allocation, S3-W6) using the spawn path; Skynet keeps studio.scripts.clickup_task_id; reconciliation report both ways; vitest services/workos/tests/script-bridge.spec.ts.
Acceptance. Test green; bidirectional transcript for one script (Skynet → card → approved in ClickUp → Skynet sees it). Amended 2026-09-19: on staging that script belongs to the reserved test product 999001, and a staging request for any other product, or from a workspace outside the bridge's allowlist, is refused before ClickUp is called.
Amended 2026-09-19 (the owner's decision: production at the terminal go-live). On staging this card writes to ClickUp through the workos bridge: Script cards, their human-owned fields and demand lines. Under the staging write rule (architecture §6) those writes pass the bridge's workspace allowlist (SKYNET_ALLOWED_WORKSPACES) and touch only the reserved test product 999001, the one the staging loop uses. Any other staging write to ClickUp is refused before ClickUp is called. Script and demand-line writes for real products start at X7, under the console switch's own readiness and rollback decision.
Settled at the brief co-plan (2026-09-26). The facts pass found no workos → Skynet webhook and no inbound signed receiver in apps/api; the S8a bridge verifies Skynet's RS256 svc:workos token and cannot mint one for an outbound call, so the card's "webhook → Skynet" is delivered as the webhook-derived mirror D33 rule 1 requires: workos's ClickUp webhook and reconcile sweep keep a Script ledger (rescale_service.skynet_script_cards, migration 008, A15's crash-safe claim protocol), and Skynet polls GET /scripts?changed_since= from a Supervisor hook (SCRIPT_BRIDGE_POLL_INTERVAL_MIN, default 5). POST /scripts writes only rule 3's structural fields, idempotent by (workspace_id, workstream, product, script_id), with demand_line_task_id required; PATCH /scripts/:id writes only the human-owned fields (never Script type, Approach, a machine field, a relationship, assignee or status) after reading the card, with a per-field expected value and 409 FIELD_CHANGED on drift (rule 9's conflict rule); GET /scripts/:id and GET /scripts return the mirror shape; POST /demand-lines calls handleLineSpawn. The state map is a reviewed export of the ClickUp Scripts-list statuses pinned in the shared contract (packages/contracts/scripts/, copied byte for byte into workos), one test per transition in each repository; the Scripts BOARD_GATES entry opens 💡 Idea, Idea approved and Briefed. The staging product guard decides PRODUCT_NOT_ALLOWED from a bridge-owned product-number cache (populated only by an explicit product-sync, never on the refusal path) before any ClickUp call, then verifies the Demand Line's Product (DEMAND_LINE_PRODUCT_MISMATCH) before any mutation; SKYNET_BRIDGE_MODE and SKYNET_STAGING_PRODUCT_NUMBER fail the mount closed when missing. Skynet adds studio/006_script_bridge.sql (after A10b's 004 and A11a's 005): clickup_task_id, clickup_status, clickup_synced_at on studio.scripts, a workspace-scoped pull cursor, the append-only studio.script_bridge_events, and the read-only Demand Line provenance mirror (rule 4). Both reconcile commands are deliverables. Exhausted failures become S14 inbox items; manual paths stay valid (rule 8). The tests live beside the bridge code (service/src/modules/skynet-bridge/*.test.ts), not at the card's services/workos/tests/ path. Sequencing: the workos branch from A15's head (PR base A15's branch until Sven merges A15, then main); the Skynet half from main after A10b and A11a; the bidirectional staging transcript is recorded by the orchestrator after both are deployed.
V23 · Script Studio native pages · V · L · S3-W4
Deliverables. apps/web/src/studio/: scripts list + detail (creative DNA, revisions, votes, assignment, sign-off, learnings, handoff), sessions (live stream, gates: reply/question/permission, artifacts, save handoff), scoreboard (readback-fed), opportunities, backlog, overview, night-shift, admin usage, audit, users (deep-linked to core membership), admin/supermemory; a compact handoff artefact (current state, decisions, changed artefacts, open loops, the exact next step, a resume prompt) that can seed the next run; old console URL → redirect after A20.
Acceptance. pnpm --filter web test -- studio-shell-pages; walkthrough recording (scripts → session → gate → sign-off).
Split at the brief co-plan (2026-09-27). The facts pass measured the console at about 8,000 lines of pages and components plus 1,340 lines of client logic, found no handoff artefact anywhere, sign-off as the Studio-owned sign_off flag written through scoreboard/update (no ClickUp involvement), no readback join until A21's studio/008, no core membership page in the shell, no walkthrough-recording convention, and a Studio front door that fails a request carrying an invalid cookie even with a valid Bearer. V23 is four cards built in order, each a card of its own below: V23a (the shared client: lib/api.ts gains an optional base parameter with one transport implementation and studioApi.ts wraps /studio/api; studioStream.ts relies on the same-origin cookie, resumes by the last event id, dispatches named events and polls after three errors, with a typed FakeEventSource; StudioLayout with a permission-gated rail; the scripts list and detail with Creative DNA, read-only Learnings, revisions, the content editor, assignment by editor_user_id, the creative code and v3 name, and the strategist panel whose sign-off sends only Sign-off; no script votes), V23b (sessions list and page, the live stream, the three gate kinds, the multipart Composer, controls, artifacts, sources, the engine view; the handoff artefact as an immutable studio.artifacts row of type handoff with the JSON {version, current_state, decisions, changed_artefacts, open_loops, next_step, resume_prompt}, created by POST /studio/api/sessions/{id}/handoff and read by GET /studio/api/handoffs/{id}, seeding a new run's intake notes through composeSeed), V23c (a service-only apps/api GET /api/performance/scoreboard aggregate and a Studio GET /studio/api/scoreboard/readback proxy behind studio.read; the scoreboard with readback where a script carries a code and typed marks for the rest; opportunities with votes, backlog and digest, the notifications page), V23d (overview, night shift from A11a's route, the Claude-usage link, admin supermemory, audit, users with the dead account controls dropped and a prefilled invite hand-off instead of a members page, recorded as a follow-up item; the staging walkthrough spec with spec-local Playwright video, recorded by the orchestrator). Two A21 amendments precede V23a: the cookie→Bearer authentication fallback and the script.sign_off audit action. The acceptance command is cd apps/web && npm test -- studio-shell-pages; no V23 card touches the old console URL (A20/X7).
Amended 2026-09-27 at the Studio seam co-plan. Each V23a–d card extends the settled explicit shell-owned Studio seam only for the route families it ports, updating the Caddy matcher and seam-test rows with its SPA-owned prefixes. An existing Next link to a captured /studio/... page intentionally resolves to that shell port; unported pages remain reachable through the default Studio handler. No V23 card captures /studio/api/*, /studio/_next/*, /studio/login*, or SSE paths.
V23a · Studio native pages: the client, the stream, the scripts · V · M · S3-W4
Read first. A21's seam and route map; v23-facts.md §1–3; services/studio/src/app/scripts/** and components/script/**, components/scripts/**.
Deliverables. lib/api.ts with an optional base parameter (one transport implementation); lib/studioApi.ts over /studio/api; lib/studioStream.ts (cookie-based EventSource, resume by last event id, named events, polling after three errors) and its typed FakeEventSource; apps/web/src/studio/StudioLayout with a permission-gated rail (studio.read, studio.write, studio.approve, the admin keys); ScriptsPage (filters, bulk archive, the edit drawer, the add-script sheet, Learnings, the creative-DNA tooltip, product-folder links, MarketRead) and ScriptPage (Creative DNA and hooks, the pod panel and weekly results, history with gated revert, the content editor with content_v, assignment by editor_user_id with the creative code and v3 name, the strategist panel whose sign-off sends only Sign-off); no script votes; the shared studio-shell-pages.test.tsx with its first describe.
Acceptance. cd apps/web && npm test -- studio-shell-pages green; the V23a checklist (base selection, cookie-plus-Bearer, 401, no ?token=; permissions and canEdit; editor_user_id; sign-off writes no ClickUp status and the Studio emits script.sign_off); docs/ops/v23a-acceptance.md.
V23b · Studio native pages: sessions, the live stream, the handoff · V · M · S3-W4
Read first. V23a's client and stream helper; services/studio/src/app/sessions/**, components/session/**, components/gates.tsx, components/engine/**, lib/engine/*, lib/intake.ts.
Deliverables. SessionsPage (the Launcher, archive, delete-as-archive) and SessionPage (the snapshot + stream, a listener per event type incl. account_switch, the permission/question/reply gates through respond, the multipart Composer, control, artifacts, sources, the engine view over its reducer); the handoff artefact: an immutable studio.artifacts row of type handoff with the exact JSON shape, POST /studio/api/sessions/{id}/handoff (resolves the session in the caller's workspace; a new row per save; {id, handoff, markdown}) and GET /studio/api/handoffs/{id} (by workspace, id and type; returns the session_id; 404 otherwise); "Save handoff"; the Launcher seeding the intake notes from resume_prompt through composeSeed.
Acceptance. The suite green; the V23b checklist (every event type through the fake, resume and fallback, each gate kind, multipart, controls, sources, artifacts, engine states; handoff validation, workspace isolation, parent-session validation, id-to-session resolution, append-only, markdown, seed generation); docs/ops/v23b-acceptance.md.
V23c · Studio native pages: scoreboard with readback, opportunities, backlog, notifications · V · M · S3-W4
Read first. A21's creative_code/product_number on scripts and its freshness route shape; services/studio/src/app/scoreboard/**, opportunities/**, backlog/**, inbox/**; apps/api/app/core/readback/router.py.
Deliverables. GET /api/performance/scoreboard?product=&codes= in apps/api (a svc:runtime-only service route; workspace, product and code registration enforced; {code, latest_snapshot_date, spend, roas, hook_rate} only); GET /studio/api/scoreboard/readback?ws=&product= (studio.read; the internal aggregate call with the mounted service token; merged only by registered code and product); ScoreboardPage (the four sub-views; readback for coded scripts, typed marks for uncoded values, "No readback" for coded scripts without snapshots; the editor-only mode), OpportunitiesPage (vote toggle; accept → a run), BacklogPage, DigestPage, NotificationsPage; typed weekly results stay writable.
Acceptance. The suite green; the V23c checklist (the aggregate route's admission and scoping and no raw payload; the proxy's guard and joins; the marks; editor-only behaviour; votes and accept; backlog/digest controls; Notifications states); docs/ops/v23c-acceptance.md.
V23d · Studio native pages: overview, night shift, admin, audit, users; the walkthrough · V · S · S3-W4
Read first. A11a's night-shift route; A11b's Claude-usage page; services/studio/src/app/{page,night-shift,admin,audit,users}/**; e2e/playwright.config.ts.
Deliverables. OverviewPage, NightShiftPage, the Claude-usage link, SupermemoryAdminPage, AuditPage, UsersPage (Studio profile fields only; the dead account and password controls dropped; "Invite to workspace" opens the existing core invite flow with the email prefilled; no /admin/members link — the Members page is a follow-up item); the complete staging walkthrough spec (scripts → session → gate → sign-off) with spec-local video: 'on', recorded by the orchestrator against staging into e2e/test-results/<spec>/.
Acceptance. The suite green; the V23d checklist (loading, empty, error and permission states; no dead controls; the prefilled invite; the recording linked); docs/ops/v23d-acceptance.md.
S14 · Inbox + dispatch · S · M · S3-W4
Read first. core.events_outbox (S4); functional design §3.2, user stories 9, 15, 27.
Deliverables. core.inbox_items(workspace_id, owner_id, kind, source_ref, evidence jsonb, state ∈ open|done|dismissed, created_at); core.chat_feedback_captures(id, workspace_id, source, external_id, channel_id, author_hash, text, attachment_ref, posted_at, ingested_at, processed_at); port the RescaleOS feedback pollers into Skynet for registered team channels (Telegram getUpdates offset, Discord REST GET /channels/{id}/messages?after= with attachment download) so team-feedback channels feed the inbox/dispatch flow here, while customer/community channels remain A23 market-intel sources; producers: script comments/tags, teardown tags, hook kills, chat-feedback captures, alerts (A13b), bridge actions, and manual rough capture (drop a file, link or half-thought into the inbox unsorted, then process or file it later); dispatcher rules (who gets what); UI inbox in the shell with quick actions ("assign to X", "start a script", "process now", "file later"); Telegram/Discord quick replies land here (delivered by S27, acted on by A24); pytest apps/api/tests/studio/test_inbox_dispatch.py.
Acceptance. Test green; one staging item with owner, evidence packet, resolution.
Settled at the brief co-plan (2026-09-26). The facts pass found core.inbox_items already present with the card's seven columns and three states but writable only through routines (the application role holds SELECT; a fence refuses any other writer; studio.brief_tag is the sole producer), an outbox with many emitters and no consumer, A23 already owning each bot's Telegram update queue through its lease (a second getUpdates consumer would steal it), no team/community field on the chat registry, no dispatch concept, and a test path that does not exist (apps/api/app/backend/tests/core/test_inbox_dispatch.py is the path). S14 therefore: core/025_inbox_dispatch.sql keeps the routine-only fence and adds updated_at, resolved_at, resolved_by, resolution, assigned_by, a per-workspace dedupe_key, snoozed_until and the shared waiting_on; the routines inbox_item_create (idempotent by key; takes the locked outbox id and marks it processed after the insert), inbox_item_assign, inbox_item_resolve, inbox_item_snooze (file later) and chat_feedback_capture_ingest (the only write path into core.chat_feedback_captures, emitting chat_feedback.captured); core.inbox_dispatch_rules (id, kind, match jsonb containment, owner, priority; exact kind beats *, lower priority then id; one enabled fallback per workspace by a partial unique index; an invalid configuration defers the event with a log record); one key inbox.dispatch.manage (admin, strategist) while workspace.read acts on one's own items. The dispatcher is the outbox's first consumer: a Supervisor hook claims only registered types with FOR UPDATE SKIP LOCKED in one transaction and processes each row in a savepoint; unregistered events stay pending and are counted. The registry: intelligence.traction, intelligence.auto_recreate, intelligence.suggestion, spend_guard.paused, launch.package_attached, brief.sent, script_bridge.exhausted (S16 emits it), studio.feedback_note.created (a private owner-only core trigger on studio.feedback_notes), chat_feedback.captured; V16's hook_gate and brief_tag call the create routine directly. Team channels are a channel_role (meta_ads/016) on A23's mi_scopes; A23 stays the sole reader per bot and per Discord scope and, for a team scope, writes captures through the ingest routine (author = A23's salted hash of the platform user id; the first image attachment to a tenant-scoped R2 key through injected download and write seams) instead of a corpus row; the console's poller code is not copied. Manual capture POST /api/inbox/capture (text, an http or https link, a file up to 25 MiB of six types; no client-supplied key) creates an open capture item for the caller. Routes: list (mine; all with the key), detail, assign (key), resolve, snooze, capture, rules (key); "start a script" is a client hand-off to /studio/api/scripts/create whose success resolves the item with {action: start_script, script_ref}. The shell's InboxStub becomes the inbox pages. Absent producers recorded as open items, not built: script tags, teardown tags, story 15's storyboard deliverables. Two digest inputs (core/025, meta_ads/016 — 017 if V14 merges first, as the launch message says): the pair is restamped. Amended 2026-09-27 after the S27 and S5b facts passes: the application role keeps core/022's grants on core.events_outbox (SELECT, INSERT, UPDATE; sequence USAGE, SELECT) because the Python emitters insert as that role and the dispatcher's FOR UPDATE SKIP LOCKED claim needs UPDATE; the routine-only fence covers the two inbox tables, and core/025's ACL check re-asserts the outbox grants exactly rather than tightening them.
V16 · Hook session + approval gates · V · L · S3-W5
Read first. Functional design §3.4 (hook gate), Hook Engine digest (seed content, claim validation, brand-tone band), D32; V14 resolver; readback hook rate (W12).
Deliverables. Hook session flow: over-produce (N hooks per brief) → cull (brand rules, claim validation, stop-scroll judge) → survivors → strategist picks keepers / kills with reasons (recorded as signals for V17); gates per asset or batch per D32, with owners and evidence; hook rate shown from readback per launched hook; studio.hook_sessions, studio.hook_candidates.
Acceptance. pnpm --filter web test -- hook-session; a saved session artefact with generated / survivors / kill reasons.
Settled at the brief co-plan (2026-09-26). The facts pass found the Hook Engine rules only in the archive digest and D32 (nothing in the engine mirror; no brand-tone band anywhere as content), briefs carrying product_id but no claim text, Step 4's run_claim_validation and the evidence-packet helpers as the claim check, readback hook_rate per creative_code_readback ( hook N) with no per-hook route, console gates without owner/reason/packet/waiting-on, and core.inbox_items in place; the acceptance command is cd apps/web && npm test -- hook-session. studio/007_hook_sessions.sql: hook_sessions (brief, product, brand_key, resolution, target/drafted/survivor/kept/killed counts, precheck, band asset + version, the gate owner/decision/reason, evidence_packet, waiting_on, inbox item, artefact; statuses drafting|awaiting_gate|approved|rejected|failed), mutable hook_candidates (text, Category | Pattern, grade, per-candidate claim check, stage, kill stage ∈ band|stop_scroll|duplicate|formula|strategist, kill reason, band and stop-scroll judge results, launch-reference columns for a future launch writer) and append-only hook_decisions (V17's signal trail). POST /api/studio/hook-sessions (studio.write; brief_id, target_count, an explicit brand_key, the owner) returns 202 with a drafting session; a Supervisor run resolves V14's stack (studio.hook_session, optional:brand.voice), builds product knowledge, classifies the brief's included evidence findings (no invented brief claims), drafts 3 × N through the gateway, runs run_claim_validation per candidate, culls exclusively in D32's order — the band (deterministic banned words and phrases, then a band judge; skipped when no published brand.voice exists) → the stop-scroll judge (workflow/studio.hook_session.judge, pass + reason) → exact duplicates and sentence-skeleton formulas — records grades for audit only, and transitions to awaiting_gate with the frozen packet and a hook_gate inbox row; a failed run records its reason and no inbox row. Keep/kill and claim resolution are owner-only while awaiting_gate (a resolution never edits copy; removal = a kill); batch approval or rejection needs studio.approve, the owner, ≥ 1 keep and no unresolved non-CLEAN candidate; waiting_on ∈ null|claims|owner. The artefact (candidates, survivors, kills with stage and reason, keeps, gate, packet, resolution) is built once on the terminal transition by trigger and served with a markdown rendering (409 before). GET /api/performance/hooks?code=&product= (performance.read) groups snapshots by hook suffix; the page shows readback only for permitted users and "Not launched" until a launch writer sets the reference (none in V16). The page is the native route studio/hooks showing survivors only. Seeds: workflow/studio.hook_session + the judge asset verbatim from the digests; no brand template — a strategist publishes brand/brand.voice through V14. One batch gate per assigned strategist; no per-asset gate; studio/007 only, no core migration. Amended 2026-09-27 at the Studio seam co-plan: V16 extends the settled explicit shell-owned Studio seam with /studio/hooks and /studio/hooks/*, adds corresponding SPA-versus-Studio seam-test rows, and leaves every other /studio/* page—including Studio API, assets, login, and unported Next pages—at the Studio service.
S15 · Lineage producers (scraper, studio, workos) + coverage store v0 · S · M · S3-W5
Deliverables. Lineage nodes/edges from: Intelligence (ad/family → teardown → brief), Studio (brief → script → session → gate), workos bridge (script card, creative card, launch); core.coverage_facts(workspace_id, product_id, segment, awareness, angle, source ∈ label|creative_code|performance, count, evidence_refs[]) filled from labels (A9 data), creative codes and snapshots; validated-memory writeback: core.validated_learnings(workspace_id, product_id, kind ∈ winner|dead_angle|rule, statement, rule_taught, evidence_refs[], source_event, confirmed_by, created_at) written only from human-confirmed winners and confirmed losers (S9 — a dead angle records the rule it taught, first-class like a winner) or human-rated outputs; promotion threshold per the accelerator data dictionary (second occurrence = pattern proposal, third = rule/workflow proposal) — personal taste signals from V17/V19 stay in the instinct stores and never write here; the SuperMemory writer moves behind this table; pytest apps/api/tests/core/test_lineage_producers.py.
Acceptance. Test green; one chain teardown → script → export → performance → winner candidate; one confirmed winner writes one validated-learning record; one confirmed loser writes one dead_angle record with rule_taught; one unconfirmed edit writes none.
Settled at the brief co-plan (2026-09-27). The facts pass found the lineage vocabulary in five pinned places with no script, gate or card types, no teardown or Studio lineage writer, no winner or dead-angle producer, codes and snapshots keyed by product_number (text) and no segment axis in the label data (labels carry no product; the join is product_competitors). S15 therefore: core/026 adds the node types script, gate, script_card, dead_angle and the relations analysed_as, scripted_as, ran_as, gated_by, carded_as (no confirmed_as: one edge per pair already carries evaluated), core.coverage_facts keyed by (workspace, product_number, segment = the Studio avatar or * for labels, awareness, angle, source) with count, confidence and evidence refs, and core.validated_learnings with scope, kind ∈ winner|dead_angle|rule|rated, a closed source_event list, a typed source reference verified row-and-workspace by the SECURITY DEFINER record routine, a unique source identity, rule_taught required for a dead angle, and a separate learning.promote key and routine for workspace scope; studio/009 adds a nullable, workspace-verified brief_id on studio.scripts; meta_ads/017 extends teardown_finalize to write ad|family → teardown in the same transaction on the move to done. The Studio DAL calls core.lineage_write_batch at script create (the script node, plus scripted_as when a brief is referenced), session start and gate open/answer (one gate node, attrs merged); after S16's Skynet half, its pull path writes script_card on the first clickup_task_id; the Creative card is absent-source (open item #209); no winner candidate is derived (S5b's proposals; S9 confirms). The coverage refresh recomputes one product atomically per run: competitor-only label facts through product_competitors, creative-code facts from studio.scripts joined to codes (A21), performance facts from those codes to snapshots, with out-of-taxonomy values as other. The second occurrence of a normalized statement emits learning.pattern_proposed, the third learning.workflow_capture_proposed, both registered in S14's inbox registry; no rule is written automatically. The SuperMemory consumer of validated_learning.recorded is built with its own core ledger and lease but has no live producer until S9 replaces the Studio scoreboard writer; the Studio path and its ledger stay until then. The acceptance's first sentence now reads: one fixture graph links teardown → brief → script → session → gate and, from that session, the existing export → performance path; it derives no winner candidate. The test path is apps/api/app/backend/tests/core/test_lineage_producers.py.
S5b · Winner/loser rule evaluation · S · M · S3-W6
Read first. workos platform-sync/classify.ts (zombie ≤ BEROAS×1.115; winner ≥ target ROAS 15 % and spend ≥ 5× target CPP; super-winner + 7-d spend ≥ €1 000; TOF review zone), thresholds.ts (Google Sheet knobs); ecomprofits business-logic.ts; core.performance_snapshots.
Deliverables. core.classify_rules(workspace_id, product_id?, knobs jsonb, version) (knobs migrated from the sheet, editable in admin); evaluator over snapshots → core.winner_proposals(creative_code, verdict, evidence, rule_version); fixture parity vs workos thresholds.
Acceptance. pytest apps/api/tests/studio/test_winner_rules.py green with the parity fixture.
Settled at the brief co-plan (2026-09-27). The facts pass found that workos's classify() needs three per-product economics values (Breakeven ROAS, Target ROAS 15%, Target CPP) that live on the ClickUp Product card (D22 rule 6 keeps them there) plus the ad's frequency, which is in neither the ecomprofits view nor core.performance_snapshots; that workos has no loser tier (zombie is the kill suggestion, the winner test also requires sevenDayEligible, and "target ROAS 15 %" is a per-product target); that platform-sync writes only advisory Launch fields and never a status; and that the card's test path does not exist. S5b therefore: one core migration (number in the launch message) with core.classify_rules (four knobs per workspace with an optional product override, append-only versions, seeded with workos's DEFAULT_KNOBS, product-specific active rule preferred over the workspace default), core.winner_evaluations (every ad's verdict per evaluation date) and core.winner_proposals (verdict winner|loser, tier, evidence, rule_version, status proposed|confirmed|overridden|superseded, one open proposal per (workspace, product_number, creative_code); writes only through the SECURITY DEFINER core.winner_proposal_upsert), plus frequency on the snapshots filled only after a capability check against the ecomprofits view (the view change is an external dependency; until then frequency is null and the judgment zone is watch/Review); economics are read at each evaluation through a new read-only workos bridge route GET /products/:product_number/economics and frozen into the evidence, never stored (a 404, a null field or a bridge failure → withheld); the classify port is proven by the 13 pure workos cases as classifier-semantics parity, not data-grain parity (Skynet evaluates each ad's latest final snapshot and aggregates to a code by workos's precedence); super-winner/winner → an open winner proposal, zombie → an open loser proposal, healthy/watch → evaluations only and any open proposal superseded with its inbox item dismissed by the inactive, no-login winner_rules service user, while withheld (no economics: a 404, a null field or a bridge failure) writes its evaluation and leaves open proposals untouched (amended at the round-1 review) (a member of every workspace: inserted by the migration for existing workspaces and by an idempotent boot reconcile before each evaluation for later ones; no role); a decided proposal is terminal (an equal verdict creates nothing, a contrary one a fresh proposal); a Supervisor hook WINNER_RULES_INTERVAL_MIN; winner.proposed registered in S14's registry as kind winner_proposal; knobs edited through admin routes behind admin.settings.manage. S5b makes no decision, writes nothing to ClickUp, writes no lineage and adds no page. The acceptance test is apps/api/app/backend/tests/core/test_winner_rules.py.
S9 · Winner/loser human gate + winner lineage · S · M · S3-W6
Deliverables. Proposals (winner and loser) surface in the inbox (S14) and the winners view; confirm/override with reason → winner or dead_angle lineage node + event, the loser carrying the rule it taught (S15); resolved status exposed to Studio (scoreboard) and Production (winners gallery); the workos platform-sync flag stays as a mirror until S4.
Acceptance. pytest apps/api/tests/studio/test_winner_gate.py green; one confirmed winner emits the lineage event; one confirmed loser emits a dead_angle lineage event with reason + rule_taught; the API shows both.
Settled at the brief co-plan (2026-09-27). The facts pass found the winner node type and the evaluated relation already on main with no writer, dead_angle arriving with S15's routine, no permission key for confirming an outcome and no assigned-media-buyer concept, the Production "winners gallery" listing pre-launch concept picks, no native scoreboard until V23c, and the console's ad_category WINNER/LOSER set by any staff user without a reason. S9 therefore: one core migration (number in the launch message) with a new key performance.outcome.confirm seeded to admin and media_buyer and the SECURITY DEFINER core.winner_proposal_decide (verifies the open proposal, the current-member actor, a non-empty reason and rule_taught for a loser outcome; marks confirmed or overridden; calls S15's core.validated_learning_record with the new source type winner_proposal, added by CREATE OR REPLACE under the same fence, which writes the winner or dead_angle node and emits validated_learning.recorded; writes the evaluated edge from the as-of performance node; emits winner.confirmed or loser.confirmed; resolves the inbox item in the same transaction); a decided proposal is terminal (no reopen; a contrary machine judgment creates a fresh proposal through S5b); routes GET /api/winners/proposals, POST …/{id}/confirm and …/override (performance.outcome.confirm and (item ownership or inbox.dispatch.manage); 422 without rule_taught on a loser; 409 on a decided proposal), and the read-only Studio endpoint GET /api/winners/outcomes?product=&codes=… (latest terminal decision per requested code, undecided codes omitted); the outcome exposed on GET /api/performance and GET /api/winners, with a confirmed-outcome badge and a "disagrees" marker against S16's mirrored ClickUp status in the web winners view (vitest winner-gate); the Studio scoreboard no longer writes WINNER/LOSER (422) and shows the Skynet outcome read-only, winnerHook.ts and studio.supermemory_writes are retired and S15's consumer is the only live winner writer (the Studio behaviour contract is rewritten on purpose). S9 writes nothing to ClickUp and adds no node type or relation; V23c may join the outcome endpoint into its aggregate later. The acceptance test is apps/api/app/backend/tests/core/test_winner_gate.py.
V18 · Coverage map + gap finder + allocation + brief queue · V · L · S3-W6
Read first. Functional design §3.3 (Mathew's system map), D32; S15 coverage facts.
Deliverables. Coverage map per product (segments × awareness × angles) with the evidence-count honesty guard (cells show counts + confidence, never a bare heat colour); gap finder ranked by importance × reach; allocation view where the team lead sets net-new / variations / scaling (human gate, pre-filled from last batch's results and the gap ranking) → brief queue (studio.briefs) — each brief packet carries swipes from a curated swipe library (studio.swipes(id, workspace_id, mechanism, source_ref, media_ref, one_thing_to_steal, added_by, visibility ∈ personal|shared, approved_by, shared_at), organised by mechanism, not by brand — the accelerator convention; personal swipes stay personal until explicitly promoted to shared) next to its teardown / winner / market-intel / coverage evidence → Script Studio; each brief packet carries market-intel corpus evidence (customer phrases), teardown evidence, winner evidence and coverage evidence, co-selectable, and the review screens show those packets explicitly; rejected proposals persist with reason codes; weekly proposal batch generator (review, not auto-run).
Acceptance. pnpm --filter web test -- coverage-map; one reviewed allocation batch linked to brief cards.
Settled at the brief co-plan (2026-09-27). The facts pass found that studio.briefs accepts only four Intelligence sources and requires a VOC group, has no batch, lane or proposal column and is keyed by product_id while S15's core.coverage_facts is keyed by product_number; that label data has no segment axis (competitor and own ads meet on awareness × angle); that no swipe store, no team-lead role and no winner, coverage or swipe evidence kind exist; that "importance × reach" is defined nowhere and is not computable at the cell grain from intel_label_distributions_v2 (marginal axis rows); and that the acceptance command matches no package. V18 therefore: one core migration (number in the launch message) with the keys strategy.allocation.approve and studio.swipes.promote (admin, strategist) and the lineage node type proposal in the five pinned places; one studio migration with studio.swipes (personal by default, irreversible promotion recording who and when, typed source, a workspace-derived mechanism list), studio.allocation_settings (min_evidence_count default 3), studio.allocation_batches (both product_id and product_number, unique per workspace, product and Monday week_start, lane split validated to total, status proposed|approved|rejected with closed reason codes off_brand|already_covered|low_evidence|not_now|other) and studio.allocation_proposals (lane, cell, the frozen coverage-proxy inputs, evidence refs, prior results, status, reason, brief_id), the proposal source kind on studio.briefs with an optional VOC group (rendered "No VOC group selected"), the evidence kinds winner, coverage_cell and swipe as validated snapshots, and SECURITY DEFINER routines; GET /api/strategy/coverage?product_id= returns every cell with count, confidence and evidence refs per source and never a colour or score without its inputs; the gap finder ranks eligible awareness × angle cells by the versioned coverage proxy importance × (1 − own_coverage_share) (competitor label share; performance-backed own-code share) labelled as a proxy, insufficient cells never ranked; a weekly Supervisor hook proposes one batch per product for the coming Monday (idempotent; prefill = the prior approved split adjusted by each lane's terminal winner − loser outcomes, clamped and normalized by largest remainder to concepts_per_week; equal lanes without a prior batch) and creates one inbox item (allocation.batch_proposed, kind allocation_batch); the team-lead gate approves, edits or rejects the split and accepts or rejects proposals with reasons; accept creates a studio.briefs row with the proposal source, the frozen evidence and the proposal → brief briefed_as lineage in one transaction; pages /strategy/coverage/:productId, /strategy/allocation, /strategy/swipes replace the stub. V18 spawns no Demand Line, ClickUp card or Script card, builds no WoW-post parser and adds no segment vocabulary. The acceptance is cd apps/web && npm test -- coverage-map plus one reviewed allocation batch linked to brief rows; the api test is apps/api/app/backend/tests/core/test_strategy_allocation.py.
V17 · Instinct loop for hooks · V · M · S3-W7
Read first. Instinct Transfer digest, functional design §3.4 (instinct loop rules); V16 signals.
Deliverables. Per strategist × deliverable type: exemplar pool (shipped or human-rated only), two-tier rules (hard → auto-reject; tendency with context), before/after pairs, a judge scoring drafts before review; captures are proposed from edits/picks/kills and ratified by the user; repeated signals surface as proposals (second occurrence = pattern/tendency proposal; third occurrence = reusable rule/workflow proposal); two explicit ritual moments in the UI — end-of-session reflect ("what is worth keeping, and where: rule / tendency / exemplar / dead angle") and mid-session remember this (a standing rule stated in chat or review becomes a proposed capture) — per the accelerator skills; rules apply to the author's drafts after their review, team-wide only after approval; rule audit view.
Acceptance. pnpm --filter web test -- instinct-hooks; one ratified rule capture attached to a hook review.
Bridge impact. Miguel's reaction-banking rule from bridge I4 is seed content for the instinct captures and ritual moments here.
Settled at the brief co-plan (2026-09-27). The facts pass found no instinct, judge or hypothesis store on main (only free-text studio.scripts.hypothesis/learnings, feedback notes with a verdict, script revisions and votes), V16's cull order settled as exhaustive with a closed kill-stage set and a frozen terminal artefact, V16's own stop-scroll judge, S15's output.rated/rated with no producer and its red proof barring hook decisions and instinct stores as validated sources, V14's negation lint and eval gate, the accelerator skills present only as names, bridge I4 never built, and the acceptance command matching no project. V17 therefore: a studio migration (number in the launch message) with studio.instinct_captures (per owner and deliverable type; kinds hard_rule|tendency|exemplar|pair with before/after text and a principle for pairs; scope me|format; proposed|ratified|rejected|retired; one ratified canonical row per normalized hash, proposed rows merged into it at ratification), studio.instinct_capture_occurrences (one row per distinct signal; occurrences = its count), studio.instinct_scorecards (status claimed|completed|review_error; unique per target, owner and rule_set_hash; the shared key is claimed atomically before any paid call, so a candidate or revision is judged at most once per rule set), studio.instinct_reflect_requests (durable, idempotent mining requests), the V16 kill_stage CHECK widened with instinct (a D32 rule 1 amendment: after duplicate/formula, an instinct stage may auto-reject only against the session author's ratified hard rules; a model score never creates or promotes a rule; the human decision is the capture's ratification) and SECURITY DEFINER routines; a core migration (digest input) extending core.validated_learning_record by CREATE OR REPLACE with the source type hook_session (the terminal gate decision by the named owner → kind rated, output.rated; the red proof stays red for every other source); the hooks rules judge behind the gateway (binding key studio.instinct.judge.hooks seeded through the catalogue; resolve_stack over a seeded workflow asset plus the author's optional:instinct.hooks user asset; a strict completed schema; a malformed answer persisted as review_error and never a kill; borderline never kills; another author's rules never applied); reflect as a durable request claimed after the terminal transition's commit (a failure creates no capture and may be retried by the author; a success cannot run twice), remember-this, owner-only ratify/reject/retire, deterministic occurrence counting with the second and third distinct pieces creating at most one direct-owned instinct_proposal inbox item each through core.inbox_item_create (the kind registered in S14's closed registry; no outbox event), a rule audit route; the overlay hand-off renders ratified rules into the user-layer asset instinct.hooks as a V14 draft (positive form with Allow: companions; benchmark cases from pairs' before/after text and principle; the owner evals and publishes; the route never publishes) declared optional:instinct.hooks in studio.hook_session's user_keys; team-wide only by a studio.prompts.workflow.manage publish; exemplars = kept candidates of approved sessions plus ratification, pairs entered by hand; the studio/instinct page and scorecards on the hook review. No I4 seed exists and no named person's rule is fabricated. The acceptance is cd apps/web && npm test -- instinct-hooks; the api test is apps/api/app/backend/tests/core/test_instinct_hooks.py.
V19 · Instinct loop for scripts + judge + hypothesis ledger · V · M · S3-W7
Deliverables. Same loop for script drafts, with the same reflect / remember-this ritual moments; the judge on drafts; studio.hypotheses(brief_id, statement, expected_signal, outcome, evidence) ledger linked to briefs, sessions and results; the third occurrence of a repeated move proposes a reusable workflow/skill (save-as-skill, with approval).
Acceptance. pnpm --filter web test -- instinct-scripts; one draft shows judge output + a linked hypothesis.
Settled at the brief co-plan (2026-09-27). The facts pass found that script drafts are born in the agent runtime (engine-birth revisions on the studio.agent binding) with no apps/api drafting pipeline to host a pre-review judge, that studio.scripts.strategist is a text name while revisions and notes carry user ids, that the card's studio.hypotheses shape has no workspace or id and that results are S9 outcomes keyed by product number and creative code, that no skill registry exists, and that the acceptance command matches no project. V19 therefore: one studio migration (number in the launch message; no core migration, digest unchanged) with studio.hypotheses (workspace-scoped, nullable product id and product number, open|supported|refuted|inconclusive, append-only evidence, resolve once by a named member with a reason) and studio.hypothesis_links (brief, hook session, script, Studio session, creative code with the canonical ref <product_number>:<creative_code>) behind SECURITY DEFINER routines; the script judge through V17's seam (binding key studio.instinct.judge.scripts; the judge owner derived only from script_revisions.edited_by_user_id; a Supervisor scan keyed by V17's shared scorecard key (revision identity, owner, rule_set_hash) claimed atomically before any paid call; an on-demand route that rejudges only after the rule set changes or as a reviewed retry of a review_error; flags shown before review that never kill, edit or block a save or sign-off); script reflect on sign-off or on demand through V17's reflect-request store (each source mined at most once successfully), pairs from the revision history, remember-this, and the third occurrence's direct-owned workflow proposal inbox item whose approval is a V14 workflow-layer publish by a studio.prompts.workflow.manage holder (no skill registry); the overlay asset instinct.scripts through V17's flow; the hypothesis ledger fed from briefs, hook sessions and scripts ("record as hypothesis" copies the free text once), with an outcomes worker attaching each terminal S9 decision once (studio.hypothesis_outcome_evidence, unique per hypothesis and proposal, written before the evidence item; the late creative-code link discovered idempotently) and directly creating the registered hypothesis_review inbox item for the author; nothing writes core.validated_learnings. The acceptance is cd apps/web && npm test -- instinct-scripts; the api test is apps/api/app/backend/tests/core/test_instinct_scripts.py.
V20 · Layer-authoring assistants, template sync, video recipes · V · M · S3-W8
Read first. TrenchOS digest (layer-authoring assistants, template fork + upstream sync, provider adapters as data), functional design §3.5 (recipes).
Deliverables. Assistants that help write a layer with the guardrails (negation lint, context-block authority); templates with fork + upstream sync, each template/skill change run through the V14 eval step before publish; production.recipes registry (statics + video) over code-backed executors, statics-first graduation to video recipes.
Acceptance. pnpm --filter web test -- studio-assistants; one synced template + one video recipe on staging.
Settled at the brief co-plan (2026-09-27). The facts pass found no template entity, no production.recipes (the recipe today is the production.sessions.mode CHECK), no provider adapters as data, skills repository-tracked and PR-changed under D31, the storyboard stages reading instructions through load_spec_with_source without a recorded stack, the derived relation restricted to asset pairs, and the acceptance command matching no project. V20 therefore: one core migration (digest input) with platform-owned core.prompt_templates keyed (layer, key, version), three fork-origin columns on core.prompt_assets (all null or all set; immutable once a version leaves draft; update_available derived only for the published fork, never stored), the hygiene doctrine seeded as asset key prompts.hygiene in layer doctrine and as its template, and the lineage relation graduated_to (session, session) in the SQL vocabulary and the Python map; one production migration (not a digest input) with production.recipes (workspace-scoped; executor exactly one of the six existing modes; workflow keys declared bindings) seeded from the modes and production.storyboard_jobs.resolved_stack as a stage-keyed object; the assistant (POST /api/prompts/{id}/assist; the V14 edit permission; model_gateway.check before the injected seam and record after; advisory deterministic hygiene findings plus V14's sole publish lint; never creates, updates or publishes an asset; "use as draft" is V14's draft-save path); template sync as an ordinary new draft with two three-way diffs, no merge, no publish until V14's eval after the draft passes; the storyboard text stages resolved through V14 at load_instruction (one declared workflow key per registered step; _call and S30's boundary unchanged; a resolver failure → StoryboardLLMError with the existing fallback and warning); graduation by source_session_id only, the winner resolved server-side from S9, every non-winner and concept-only bypass refused, a video_storyboard session started from the persisted winning concept with the graduated_to edge; skills excluded from in-app sync and publish (the card's "template/skill change" wording is superseded for skills); the editor's assistant and template panels and the /production/recipes page whose graduation controls follow the server's winner authority. The acceptance is cd apps/web && npm test -- studio-assistants; the api tests are test_prompt_assistant.py and test_recipes.py under apps/api/app/backend/tests/core/; the staging proof (one synced template, one video recipe) is recorded by the orchestrator.
A20 · Standalone console retirement + archive · A · M · S3-W9
Deliverables. Per D31: Mac launchd jobs off, console URL → redirect to /studio, SQLite archived (WAL-safe copy to the Storage Box), /opt/rescaleos stack removed, bridge-only skills and transport notes retired (/lfs, /evidence-layer bridge transport wording where the Studio-native flow replaces it, R7 MCP wiring), docs/runbooks/S3-studio-cutover.md; comms to Bian.
Acceptance. In S3-W9, the staging proof (amended 2026-09-19): the disable, redirect, archive and rollback procedure rehearsed on staging, with its transcript; a rehearsal step pointed at the live console, its vhost, Bian's Mac launchd jobs or the production archive path is refused before it acts; rg "/lfs|evidence-layer" services/agent-runtime apps/web/src/studio docs shows only native/live references. At the go-live, in X7's live acceptance: authoring jobs run only on the stack (job log), old URL redirected, archive present.
Bridge impact. Retires every console-side bridge path proved in MC1/I3/R7.
Out of scope. Anything the Studio-native flows have not yet replaced (stays until they do).
Amended 2026-09-19 (the owner's decision: production at the terminal go-live). A20 prepares and rehearses the disable, redirect, archive and rollback procedure on staging. X7 executes the actual console switch: the final disable, the redirect and the archive, recorded in X7's go-live record under X7's own readiness and rollback decision, separate from P-1. Until that switch the standalone console stays authoritative. The acceptance above splits the staging proof from X7's live acceptance. Under the staging write rule (architecture §6), the rehearsal writes only to staging copies: a copy of the console's SQLite, a staging vhost for the redirect and a staging archive destination. It never changes Bian's Mac launchd jobs, the live console, its vhost or the production archive path.
Settled at the brief co-plan (2026-09-27). The facts pass found the live console on server.rescale.media, the same box as the staging stack, Bian's D31 confirmations pending, A19's retirement pattern with only production|test-fixture target kinds, the Studio image and the api, Studio and CI tests still reading the mirror services/agent-runtime, the tracked console-dump.sql with three live password hashes (open item #208), D31 §6's archive wider than the card's "SQLite archived" (the importer carries rows only, not DATA_DIR/uploads), two rehearsals required by D31 §4, the card's rg command failing today, and the work handed forward by A10a and A10b. A20 therefore prepares and rehearses (X7 executes): infra/retire/console/ in A19's shape with a staging-rehearsal target kind whose load_target validates every field before any subprocess, network, Docker, SQLite, SSH or launchctl action and refuses, after lexical normalization and alias/descendant checks, any value at or beneath the closed deny list (server.rescale.media, compose project rescaleos, the two rescaleos_rescaleos-* volumes, /opt/rescaleos, /opt/rescaleos-backups, the T-1 live vhost, the live Mac LaunchAgents directory and media.rescale.* plists, the production archive directory and the skynet-archive/ prefix), requiring distinct STAGING_* values; snapshot-verify.py accepting only an owner-attested read-only snapshot (id, time, digests, schema version, .backup method; a nonempty sibling WAL refused; never a live path or the pre-deploy file); import-rehearsal.sh run twice from independently attested snapshots (duplicate id or digest refused) into a recreated isolated staging Studio database; studio-proof.sh (A10b's list; the real session only in the owner's rehearsal); stop-jobs.sh against a fixture LaunchAgents directory with a fake launchctl; redirect.conf + verify-redirect.sh (301 to /studio/, ACME excepted); archive.sh (D31 §6's contents plus uploads, never the home volume, digest mismatch refused, staging prefix only, never --delete); rollback.sh into an isolated fixture console project with the D31 live rollback written in the runbook; remove-old-stack.sh production-only after the archive and rollback receipts; receipts with schemas and a fixture harness in CI; the never-built bridge surfaces retired by record in the closed-list guard with the mirror untouched; docs/runbooks/S3-studio-cutover.md (T-1, T-0 in receipt order by a person, T+1 graduation steps as instructions only, T+14, rollback), a GO_LIVE console-switch row (console=<url>=301), corrected LEGACY_HOSTS.md, CUTOVER.md, docs/comms/bian.md and docs/ops/RETENTION.md (a console-retirement retention row), and open item #208 recorded as an explicit owner decision (rotate now and preserve history, or rotate and authorize a history rewrite) without action. A20 has no migration and never touches the live console, box or Mac; it is accepted only after the orchestrator records two successful staging rehearsals from independently attested snapshots with their receipts and Bian's confirmation.
S27 · Notification platform port (registry, outbox, dispatcher, destinations, mutes, digests) · S · L · S3-W7
Context. Every job and rule in Skynet needs one outbound path with dedup, routing and mutes — ecomprofits already has it; Skynet ports the pattern (Python) rather than re-inventing it.
Read first. ecomprofits packages/features/notification-platform/src/server/{emit,dedup,destinations,mutes,dispatch,deliver,api,cutover}.ts, events/registry, schemas/index.ts (severity, EmitEventOptions), naming-conventions/src/server/naming-violation-event-factory.ts (an event family with filters + attachment); workos Discord/Telegram senders; X3's alert senders (to be folded in).
Deliverables. core.notification_events(id, workspace_id, feature, event_type, severity, payload jsonb, dedup_key, final_status, created_at, dispatched_at) (unique (workspace_id, dedup_key)); core.notification_deliveries(id, event_id, workspace_id, provider, channel_type, destination_fingerprint, destination_metadata jsonb, status ∈ pending|processing|sent|failed|dead_letter|muted|skipped|retry_scheduled, attempts int default 0, next_attempt_at, locked_at, locked_by, provider_message_id, provider_response jsonb, last_error, created_at, updated_at); core.notification_destinations(workspace_id, channel_type ∈ discord|telegram|email|webhook, purpose, config_encrypted, is_enabled); core.notification_mutes; event registry (apps/api/core/notify/registry.py, typed payloads per feature: intelligence, studio, production, workspace, admin, spend_guard, synthetic); emit() → outbox; dispatcher job + sweeper with delivery claim / retry / finalize helpers (idempotent on provider_message_id); digests (daily ops digest); routing is purpose-based (no subscription UI yet): one enabled destination per (workspace_id, purpose, channel_type) with deterministic fallback rules in docs/contracts/notifications-routing.md; message renderers per channel with quick-action buttons (Discord components / Telegram inline keyboards) that call Skynet actions through the same permission checks (A24 wires the handlers); initial senders migrated onto emit(): X3, A13b, S9, V4 and every new S3 job; pytest apps/api/tests/core/test_notifications.py.
Acceptance. Test green (dedup, mute, routing by purpose, digest); on staging one event of each feature reaches Discord and Telegram; no inline requests.post to a chat API remains (rg = 0). Amended 2026-09-19: on staging those events reach only destinations on the staging allowlist. The proof: a synthetic alert reaches the approved staging alert endpoint (the webhook named by SYNTHETIC_DISCORD_WEBHOOK_URL, or by SPEND_GUARD_DISCORD_WEBHOOK_URL when that is blank, and the chat named by the TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID pair), and a staging delivery routed to any other destination not on the allowlist is refused and recorded before any contact with the chat API or the mail provider.
Out of scope. Chat ingestion (A23); the agent (A24).
Amended 2026-09-19 (the owner's decision: production at the terminal go-live). On staging the dispatcher writes to chat channels (Discord, Telegram), mail and webhooks. Under the staging write rule (architecture §6), every staging destination is on an explicit staging allowlist: approved sandbox channels and the staging alert endpoint X3 configured, which is the only alert exception. Mail goes only through the mail sandbox (MAIL_SANDBOX_TO), and any other destination is refused before a send. The production destinations are listed in X7's go-live record and take over under the console switch's own readiness and rollback decision.
Settled at the brief co-plan (2026-09-27). The facts pass found no requests.post on main (the inline sends are httpx.post in bindings/notify.py, curl in the synthetic sidecar and the CI deploy notice, and fetch in the read-only workos mirror), one processed_at on core.events_outbox owned by S14's dispatcher, no stage flag in the api, the ecomprofits pattern writing notification_events directly with no destinations table, no digest producer, no quick actions and only Discord and Telegram delivery, and S9 unbuilt. S27 therefore: one core migration (number in the launch message) with core.notification_events (unique (workspace_id, dedup_key), final_status), core.notification_deliveries (unique (event_id, provider, destination_fingerprint), the card's status set), core.notification_destinations (platform-wide workspace_id NULL rows from env, workspace rows as overrides, purpose ∈ alerts|ops|intelligence|studio|digest, config_encrypted), core.notification_mutes (scope feature|event_type|destination) and core.notification_actions (delivery-scoped opaque tokens), routine-only writes except the event insert, a notifications.manage key; emit() writes core.notification_events in the caller's transaction (registry-validated, sha256 dedup key with declared parts, no time window) and the outbox is not involved; a fifteen-entry typed registry (spend guard, synthetic, intelligence traction, the Script events script.review_ready|needs_edits|unassigned_working emitted from the mirror's append_event(), studio.script_bridge_exhausted, workspace.inbox_item_created from S14's dispatcher, the two production producers readiness and launch-package attachment beside their outbox emits, admin.test, the daily digest); dispatch and deliver as Supervisor hooks without Redis (claim by one UPDATE, advisory lock per destination, the ecomprofits retry schedule and dead-letter after 6, misconfiguration classes, stale processing with a message id finalizes sent without a resend, otherwise at-least-once and recorded); five adapters (Discord webhook and REST, Telegram bot, mail through app/core/mail, signed webhook) with an injected transport; a deployment-mode allowlist (NOTIFY_DEPLOYMENT, NOTIFY_DESTINATION_ALLOWLIST, fail-closed; the derived X3 alert endpoint is staging-only and alerts-only; production lists X7's destinations) refused before the adapter and recorded skipped; the three Python senders migrated with queued-event counters (bindings/notify.py removed; the sidecar and the CI notice are named exceptions of the rg gate; the workos mirror is S17's); a produced daily digest per workspace per UTC date; native buttons only on action-capable destinations with A24 owning the handlers; admin routes and a page behind notifications.manage. The acceptance test is apps/api/app/backend/tests/core/test_notifications.py (the card's path does not exist); the staging events are recorded by the orchestrator.
S30 · The storyboard split's model boundary keeps its fallback promise · S · fix/s/s30-storyboard-split-boundary
Context. apps/api/app/backend/routes/video_storyboard.py:341-346 promises that a model failure at session creation falls back to the deterministic sentence split with a warning, and delivers it only for StoryboardLLMError (the catch at :406). A missing or empty model key (the dev stack; any stack whose binding has no stored credential) escapes as httpx.LocalProtocolError from the client's Bearer header and becomes a bare 500 — found by V10b's browser drive (open item #67). Measured while planning (2026-09-14): the same escape reaches every storyboard text stage through run_with_fallback → _call, the motion-adjust route (:2954) and the character stages (pipeline/storyboard_images.py :361/:501/:662 behind the route's _image_guard); the image transport builds the same header; nine of ten provider failure shapes escape the create route; the web page drops the create response's warning (pages/video-storyboard/NewVideoSession.tsx:365), so the promise was invisible even where it held.
Read first. apps/api/app/backend/routes/video_storyboard.py:337-470 and :2940-2970; apps/api/app/pipeline/storyboard_llm.py (StoryboardLLMError :144, run_with_fallback, _call :280-304, split_script_llm :364); apps/api/app/pipeline/utils/claude_client.py (credential_or at :99-100, the header at :147, the answer read at :206); apps/api/app/pipeline/utils/openrouter_client.py:84-95; apps/api/app/backend/services/storyboard_image_runner.py (:176-185, :369-376); apps/api/app/backend/main.py (the StorageNotConfigured handler); app/core/gateway (S22 replaces where the key comes from — this card does not touch that).
Deliverables. (1) Both OpenRouter transports refuse a missing, blank or unsendable key BEFORE any connection, through one shared check and one typed refusal whose sentence never carries the key. (2) The text client reads a 200 answer defensively (no message, empty choices, a non-object body or error, usage: null → its ValueError-class failure with a body excerpt), and describes a failure as a sentence (host, status, the provider's own message; a transport error's class and message; never headers). (3) Every storyboard text stage surfaces every model failure — the refusal, transport after the client's retries, non-2xx, error bodies, unusable answers, both instructions unusable — as StoryboardLLMError with a sentence; the gateway's own refusals are never converted. (4) The create route then does what its docstring says: the deterministic split, warning carrying the sentence, the audit's instruction_source "deterministic"; motion adjust answers 502 with the sentence and writes nothing. (5) One app-wide handler answers the typed refusal with 503 and its sentence, so the character stages and every other synchronous caller answer a sentence, not a bare 500. (6) The image runner re-raises the gateway's refusals before its broad catches (a paused spend guard refuses instead of saving a failed empty cast). (7) The web shows the create warning: createSession's return type declares it and NewVideoSession.tsx toasts it before navigating. (8) No change to the key source (S22).
Acceptance. pytest: a keyless create makes the session with three deterministic beats and a warning naming the missing key, with zero requests; a transport failure does the same with the transport's sentence; the happy path unchanged; every storyboard stage's failure shapes surface as StoryboardLLMError; gateway refusals propagate; motion adjust 502; the character route 503 with the sentence; each new test file passes alone. Web vitest on the new-session page's warning. On the dev stack, POST /api/video-storyboard/sessions answers 200 with warning set. Record: docs/ops/s30-acceptance.md.
Out of scope. The gateway credential seam (S22); a paid-but-unusable split missing from the session's spend ledger (open item #69); the headless analysis_llm worker (its own explicit-key error contract).
Built as (PR #78 → main 62023d4, 2026-09-14). Plan 2 review rounds, code 4 (the api boundary ×2, the web half, the record). One pre-wire check and typed refusal in pipeline/utils/provider_key.py used by the text, image, embedding and summary paths; _call → StoryboardLLMError for every storyboard stage and the second parse; the create fallback with its warning; motion adjust 502; an app-wide 503; the image helper degrades on a key refusal; the image runner re-raises the gateway refusals; the new-session page toasts warning. Whole api tier 4019 passed / 11 skipped / 0 failed; dev stack keyless create → 200 with the warning and 3 deterministic scenes. Record docs/ops/s30-acceptance.md; carded while building: open items #71 and #72.
Waits on / Unblocks. — / the browser drive can create its own storyboard fixture through the route again (optional; the seed fixture stays).
S31 · A paid split that fell back is on the session's ledger · S · S · W6 · fix/s/s31-split-spend-ledger (discovered while planning S30 on 2026-09-14, open item #69)
Context. POST /api/video-storyboard/sessions writes the session's split spend only when the audit carries usage: routes/video_storyboard.py:439-446 records the audit, then record_spend(session_id, "split", audit["split"]["usage"]) under if (audit.get("split") or {}).get("usage"). The fallback branch at :408-412 holds {"instruction_source": "deterministic", "error": str(exc)} and no usage, so a split that was PAID FOR and could not be used never reaches storyboard_spend. Two paths produce exactly that, both AFTER a confirmed 200: storyboard_llm.run_with_fallback raises at :274-278 when the shipped instruction's own output will not parse (one paid call's usage dropped, held in usage from :267), and at :292-298 when a DB-edited instruction and then the shipped retry both fail to parse (two paid calls dropped — the merge at :299-303 that would have added them is never reached). Nothing is lost at _call's own boundary (:340-357): a key refusal, a transport failure and a non-2xx never confirmed an answer and never billed. The workspace guard still sees the money — pipeline/utils/claude_client.py:232-235 records one core usage event per confirmed response — so what is wrong is the PER-SESSION figure storyboard_service.session_text_cost (:3935-3960) serves: it reports a lower bound and its unpriced count cannot say that this one is low, because the row was never written at all. The same failure shape already bit the shot writer once and is written up at services/storyboard_prompt_runner.py:98-118 — a whole board billed, the parse then raising, the record written as failed and record_spend never reached — where it was answered by a pre-call guard. No guard is available here: the thing that fails IS the model's answer.
Read first. apps/api/app/backend/routes/video_storyboard.py:395-446 (the split call, the except branch, the audit, the conditional record_spend) and :2943-2963 (motion adjust's 502, the second explicit catch of StoryboardLLMError); apps/api/app/pipeline/storyboard_llm.py:148-175 (StoryboardLLMError's contract, StageResult), :221-311 (run_with_fallback, the two raises, the usage merge), :314-357 (_call, and why its failures are unpaid); apps/api/app/pipeline/utils/claude_client.py:228-239 (_read_answer, then the core usage event, then the return); apps/api/app/backend/services/storyboard_service.py:3896-3932 (SPEND_STAGES, record_spend and why it never raises), :3935-3960 (session_text_cost, unpriced); db/migrations/unified/production/001_baseline.sql:2575-2597 (storyboard_spend: append-only, a retried stage is a second row); services/storyboard_prompt_runner.py:98-118 (the precedent and why its answer does not transfer) and :193; the other record_spend callers, each of which passes a SUCCESSFUL StageResult.usage and drops a failed one — storyboard_image_runner.py:191, 375, 864, 1223, 1320, 1879, 2575, storyboard_motion.py:92, storyboard_reference_runner.py:130, storyboard_song_runner.py:217; apps/api/app/backend/tests/test_video_storyboard_split_boundary.py (S30's suite, :332 stubs record_spend); open item #69.
Deliverables. (1) An error-plus-usage contract on the boundary S30 built: StoryboardLLMError carries the usage of every CONFIRMED paid attempt it consumed, as an ordered list that is empty when nothing was billed; both raises in run_with_fallback fill it (one entry at :274-278, two at :292-298, in call order), _call's conversions carry none, and the gateway's own refusals are still never converted. The class docstring states which of its five failure classes can carry a paid attempt. (2) One helper beside record_spend that writes one storyboard_spend row per carried attempt for a given session and stage, never raising, on the same stance the existing writer takes. (3) Every caller that catches the boundary's error AND holds a session id records through it before it answers: the create route's fallback branch (routes/video_storyboard.py:408-412, stage split), motion adjust's 502 branch (:2954-2963, stage motion_adjust), and the broad catches that degrade rather than fail — the cast (storyboard_image_runner.py:183-189, stage cast), the shot variants (:2565-2571, stage variants), and the prompt, song and reference runners. (4) The create response is unchanged in shape; the audit's split block gains the attempts it paid for beside its error, so the audit and the ledger agree on the count. (5) SPEND_STAGES is unchanged — a failed attempt is the SAME stage as a successful one, because the money went to the same call. (6) docs/ops/s31-acceptance.md.
Interfaces. Exposes the carried usage on StoryboardLLMError to every storyboard caller. Consumes nothing new.
Acceptance. pytest: a create whose split answers 200 with output the shipped instruction's parse refuses writes exactly ONE storyboard_spend row under split, carrying the attempt's model, prompt and completion tokens and cost, and still answers 200 with the deterministic beats and the warning; a DB-edited instruction that fails and then a shipped retry that fails writes TWO rows, in call order, and the audit names two attempts; a keyless create writes NO row and makes zero requests (S30's case, unchanged); a gateway refusal still refuses the create and writes no row; session_text_cost then counts those rows in total and in unpriced when the gateway reported no cost; motion adjust's 502 writes its row and still writes no version; the cast and shot-variant degradations write theirs and still degrade; a write failure inside the new helper never changes the answer the route gives; the happy paths unchanged; each new test file passes alone. Whole api tier green. Record docs/ops/s31-acceptance.md; open item #69 closed.
Out of scope. The three call_claude sites inside pipeline/storyboard_images.py — design_character (:361), instruct_character (:501), describe_supplied_character (:662) — which are not behind the storyboard_llm boundary and have no fallback that could hide a paid attempt; their routes' error contract is S33. The core usage event, already written for every confirmed answer (claude_client.py:232-235). Image and render spend, which the version rows and storyboard_jobs.spend_state answer. The gateway credential seam (S22).
Built as (PR #86 → main fd97be5, 2026-09-16). Code 1 review round → APPROVE with no must-fix. StoryboardLLMError.attempts (keyword-only, a per-instance list of copied usage dicts, empty when nothing was billed) is filled at exactly the two parse-failure raises in run_with_fallback (one entry for the shipped instruction's unusable answer, two in call order for a DB edit and its failed shipped retry); _call's conversions carry none and the gateway's refusals are still never converted; the class docstring names which of the five failure classes buy an answer first. storyboard_service.record_spend_attempts writes one storyboard_spend row per carried attempt through the existing never-raising writer. Eight sites record before answering: the create route (after the session id exists, the attempts travelling in the audit's split block beside its error, absent rather than empty when nothing was billed), motion adjust, the cast, the sheet match (one site beyond the card: its broad catch holds a session id and match_sheets runs through the same boundary), the shot variants, the shot writer, the reference analysis and the song split (the pool closure folds a failed attempt into the usage list the post-pool loop already records, then re-raises). Deliberately not wired: the degrading sheet read, whose describe_supplied_character call is not behind this boundary and has no fallback (S33). SPEND_STAGES and the create response shape unchanged; an attempt the gateway did not price lands as an unpriced row. 24 tests in test_storyboard_spend_ledger.py, 19 failing on the base sources; whole api tier 4125 passed / 11 skipped / 3 deselected, +24. Rebased onto V38's merge and re-run green before the squash (the second-of-two-PRs rule). Discovered, recorded: the song audit's split_usage now lists every paid split call rather than every usable one, the failures staying named in split_warnings; a split paid for while the session row itself cannot be written is still lost, there being no id to bill it to.
Waits on / Unblocks. S30 (merged) / an honest per-session text-spend figure wherever session_text_cost is read.
S32 · A refused character sheet releases its claim · S · S · W6 · fix/s/s32-character-claim-release (discovered by S30's verification on 2026-09-14, pre-existing on main, open item #71)
Context. services/storyboard_image_runner.py:1618-1791 claims, renders and settles a character sheet, and nothing between the claim and the settle survives an exception. The claim is _refuse_if_sheet_in_flight at :1640 (defined :1600-1615), which answers the next attempt "A character sheet is already generating. Wait for it to finish — otherwise you pay for both." while storyboard_service.pending_character_version_count (:3255-3271) sees a pending row for that slug younger than PENDING_CLAIM_SECONDS = 300 (:2735); the row itself is written status="pending" at :1696-1705; the render is images.tweak at :1712 or images.generate_character_sheet at :1720; every settle is below it at :1728-1791. A model-gateway refusal is precisely the exception that path has no answer for: utils/openrouter_client.py:304-308 re-raises model_gateway.GATEWAY_ERRORS on the first attempt — a paused spend guard (402), an inactive model (400), an unresolved workspace (403), a bindings failure, an unbound key (404), a usage write that failed after a paid call (500) — and storyboard_images.generate_one catches only PermanentImageGenError (:165-173), so the refusal leaves _render_character_sheet with the row still pending. For the next 300 seconds every attempt at that character is refused with a sentence that is false: nothing is generating, and on a paused guard nothing was bought. S30 settled the KEY refusal — provider_key.ModelKeyRefused ends as (None, {}) at openrouter_client.py:309-317 and becomes a refused result the settle records — and the scene path already holds the answer this one lacks: generate_candidates wraps everything after its claim and releases on any exit (storyboard_image_runner.py:2656-2675, _fail_claims at :2621-2632, which is best effort and never masks the original).
Read first. apps/api/app/backend/services/storyboard_image_runner.py:1600-1615 (the guard and its sentence), :1618-1705 (the claim and the version row), :1707-1791 (the render and every settle, including the store failure at :1776-1780), :2621-2638 (_fail_claims / fail_claims — the shape to copy), :2641-2689 (the scene path that already releases), :179-182, :377, :2563-2564 (the three gateway re-raises S30 left); the four entry points — build :1301-1323, tweak :1984-1988, rework :2004-2011, redraw :2014-2064; apps/api/app/backend/services/storyboard_service.py:2735 (PENDING_CLAIM_SECONDS), :3138-3226 (create_character_version, set_character_version_status, set_character_version_result), :3255-3271; apps/api/app/pipeline/storyboard_images.py:88-108 (StoryboardImageError, ImageResult), :145-194 (generate_one's three outcomes); apps/api/app/pipeline/utils/openrouter_client.py:268-356; apps/api/app/backend/services/model_gateway.py:264-265 (GATEWAY_ERRORS) and the statuses at app/core/gateway.py:30-37, model_gateway.py:55-71, services/workspace_scope.py:45-49; apps/api/app/backend/tests/test_storyboard_image_runner_gateway_refusals.py (S30's two cases, the pattern to extend); open item #71.
Deliverables. (1) _render_character_sheet releases on EVERY exit between the claim and the settle: the version row is moved off pending with the failure's own sentence as its provider_message before the exception propagates, by one best-effort helper beside _fail_claims that never raises and never masks the original — the claim guard then sees no pending row and the next attempt is answered on its merits. (2) The helper covers the whole window, not only the render: set_character_version_result (:1728), store_character_sheet (:1774) and write_character (:1767, :1788) are inside it, so a dead database between the claim and the settle leaves no stranded row either. (3) The refusal itself is untouched — it keeps its own type and status and reaches the app-wide handlers registered in main.py:315-319, per S30's rule that a gateway refusal is never converted; the row's message names the cause and never a key. (4) The character record is not half-applied on this path, the same rule the failed-result branch already states at :1732-1771: a refused render changes the status and the message and nothing else. (5) A regression test per refusal class, and one for a store-time failure, over all four entry points. (6) docs/ops/s32-acceptance.md.
Acceptance. pytest: for SpendPaused, ModelNotActive, WorkspaceUnresolved, BindingNotFound and UsageRecordFailed raised from the render inside each of build, tweak, rework and redraw — the route answers the refusal's own status (402 / 400 / 403 / 404 / 500) with its detail, the version row reads failed carrying that sentence, pending_character_version_count(session_id, slug) is 0 the instant the call returns, and an immediate second attempt is NOT answered "A character sheet is already generating"; the same for a raise from set_character_version_result and from store_character_sheet; a paused guard on the LEAD leaves a supporting character's claim untouched (the guard is per slug); the existing refused and blocked results still settle exactly as they do today, with the stored sheet, fields and lock kept; the happy path unchanged, including dropped_versions; each new test file passes alone. Whole api tier green. Record docs/ops/s32-acceptance.md; open item #71 closed.
Out of scope. The 300-second claim window itself, which is unchanged. The scene-frame path, which already releases (:2656-2675). The route's error contract for transport and provider failures (S33). A session-ledger row for a paid attempt that could not be used (S31). The gateway credential seam (S22).
Built as (PR #87 → main 828931a, 2026-09-16). Code 1 review round → 1 must-fix (the routes' second press was not exercised per refusal class) and two notes on the record, one re-check push-back on the record's run table, then APPROVE. _render_character_sheet now runs everything between the claim and the last settle (the style read, the render, the prompt, result and status writes, the sheet store, both character-record writes) inside one window; on any exception _release_sheet_claim, placed beside the guard it undoes, moves the row to failed with the failure's own sentence (never a key; a blank sentence becomes "The sheet could not be drawn (<class>)") and the exception propagates unchanged, so a gateway refusal keeps its own type and status; the release is best effort exactly like _fail_claims. storyboard_service.release_character_claim is one conditional statement, UPDATE … SET status = 'failed', provider_message = %s WHERE id = %s AND status = 'pending', returning whether this call released it, so a settle that already wrote ready is never rewritten when the record write after it fails. The store-failure clause re-raises GATEWAY_ERRORS first instead of converting a refusal into a sentence about storage. The character record is untouched on this path. 80 tests in test_character_claim_release.py: six refusal classes (SpendPaused 402, ModelNotActive 400, WorkspaceUnresolved 403, BindingNotFound 404, UsageRecordFailed 500, BindingsError 503) through build, tweak, rework and redraw, each route pressed twice with the pending count zero between the presses and the second press reaching the render; the store-time failure; per-slug independence; the ready row never rewritten; the existing refused and blocked settles and the happy path unchanged; the real SQL pinned through a captured cursor; 70 fail on the base sources. Whole api tier 4203 passed / 11 skipped / 3 deselected on the build base b496952 (S31's pre-squash tip), +78; the branch then moved onto fd97be5 with both touched files byte-identical and the targeted files re-run there; the PR's api job was the tier on the actual base. Discovered, filed as open item #88: _invent_character checks the guard before the paid design text call while the real claim comes about 60 lines later, so two simultaneous presses both pay for the text call.
Waits on / Unblocks. S30 (merged) / — .
S33 · The character image routes answer a failed provider with a sentence · S · S · W6 · fix/s/s33-character-image-error-contract (discovered by S30's review on 2026-09-14, open item #72)
Context. _image_guard (routes/video_storyboard.py:898-918) maps three types, all to 400 — StoryboardError, storyboard_images.StoryboardImageError, StyleUnavailable — and nothing else, so anything else from the character stages is a bare 500. Measured at 32cfffc, three failures arrive that way. (1) The character stages' own text calls are NOT behind the boundary S30 built: pipeline/storyboard_images.py calls call_claude directly at :361 (design_character), :501 (instruct_character) and :662 (describe_supplied_character), with no catch, so a transport failure after the client's retries, a non-2xx and an unusable 200 leave as httpx.HTTPStatusError, httpx.ConnectError or the client's ValueError — and main.py:356-360 re-raises any ValueError whose message does not say "not found" — so POST /sessions/{id}/character (:921), /character/refine (:1260), /character/pick (:1275) and /character/redraw (:1302) answer 500 with "Internal Server Error" where POST /scenes/{id}/motion/adjust answers 502 with the provider's own sentence (:2954-2963). (2) storyboard_images.generate_candidates catches Exception per candidate at :1016-1023, so the ONE exception generate_image_with_retry deliberately re-raises — model_gateway.GATEWAY_ERRORS, at utils/openrouter_client.py:304-308 — is swallowed into a failed candidate instead of stopping the batch, the opposite of what its three siblings do (storyboard_image_runner.py:179-182, :377, :2563-2564). (3) The labels misstate the cause: that catch writes "Generation failed unexpectedly: …", which calls a paused spend guard a crash, and a key refusal now ends as refused with "The image model returned no image after retrying. A refused call bills nothing." (storyboard_images.py:175-180), which names nothing an operator can act on. ImageResult's own declaration still documents ready | refused | blocked (storyboard_images.py:94-97, the status comment at :97) while :187 and :1020 write failed. The web needs no change: apps/web/src/lib/api.ts:467-497 already carries FastAPI's detail onto ApiError.message, and components/video-storyboard/CharacterPanel.tsx shows e.message at every catch (:1674, :1758, :1826, :1912, :1928, :1946) — the sentence is missing, not the screen.
Read first. apps/api/app/backend/routes/video_storyboard.py:898-918 (_image_guard and why its three types are 400), :921-942, :1260-1272, :1275-1300, :1302-1316 (the four character routes), :2943-2963 (the 502 shape to copy); apps/api/app/pipeline/storyboard_images.py:88-108, :145-194 (generate_one's three outcomes and its fourth written status), :288-380 (design_character), :451-545 (instruct_character), :618-799 (describe_supplied_character), :984-1024 (generate_candidates and the per-candidate catch); apps/api/app/pipeline/utils/openrouter_client.py:42-59 (PermanentImageGenError), :268-356; apps/api/app/pipeline/utils/claude_client.py:380-406 (describe_failure — host, status and the provider's own message, never headers) and apps/api/app/backend/tests/test_claude_client_answers.py:165-195 (its proven sentences); apps/api/app/pipeline/storyboard_llm.py:314-357 (the boundary the text stages already have); apps/api/app/backend/main.py:310-360 (the gateway, render-floor, storage, key-refusal and ValueError handlers); apps/web/src/lib/api.ts:467-497; apps/web/src/components/video-storyboard/CharacterPanel.tsx (the error path only); open item #72.
Deliverables. (1) The three character text calls get the boundary the storyboard text stages have: one shared place converts a transport failure after the client's retries, a non-2xx, an error body and an unusable 200 into a typed failure whose sentence comes from describe_failure (host, status, the provider's own message; a transport error's class and message; never headers and never a key), and lets model_gateway.GATEWAY_ERRORS and provider_key.ModelKeyRefused through as themselves. (2) _image_guard maps that type to 502 with the sentence — the same shape and the same reasoning as motion adjust's (:2963) — and its three existing 400s are untouched. (3) generate_candidates re-raises model_gateway.GATEWAY_ERRORS before its per-candidate catch, like its three siblings, so a paused guard stops the batch instead of writing a row of candidates labelled "unexpectedly". (4) The per-candidate label names the cause: a provider failure carries the provider's own sentence, anything else names its class; the refused sentence says when a key refusal is what produced no image, so the configuration fault is not read as a silent model refusal. (5) ImageResult's documented status set matches what the module writes, failed included. (6) docs/ops/s33-acceptance.md.
Interfaces. Exposes 502 with the provider's sentence on the four character routes, matching motion adjust. Consumes describe_failure from the text client.
Acceptance. pytest: each of design_character, instruct_character and describe_supplied_character, failing as a transport error, as a 502 from the provider and as a 200 with a body the client cannot read, makes its route answer 502 with a sentence naming the host, the status and the provider's message, and no key value appears in any of them; a gateway refusal through the same four routes keeps its own status and detail (402 / 400 / 403 / 404 / 500) and is never converted; a key refusal still answers 503 with S30's sentence, unchanged; a paused guard during generate_candidates refuses the batch, writes no candidate labelled "Generation failed unexpectedly" and leaves no claim behind; a per-candidate provider failure carries the provider's sentence and a non-provider failure names its class; the refused sentence distinguishes a key refusal from a silent model refusal; the existing 400s and the happy paths unchanged; each new test file passes alone. Whole api tier green. Record docs/ops/s33-acceptance.md; open item #72 closed.
Out of scope. Any web change — the panel already shows the detail this card supplies. The claim that a refused generation strands (S32). The session-ledger row for a paid attempt that could not be used (S31). The gateway credential seam (S22). The headless analysis_llm worker's own explicit-key error contract.
Built as (PR #88 → main 6d1d7c4, 2026-09-16). Code 1 review round → APPROVE-WITH-CHANGES (three notes: the retry function's docstring still promised (None, {}) for every failure; the gateway pass-through was proven only at the route with stubbed runners; the record named a stale base), then confirmed. _character_model_call is the one boundary under design_character, instruct_character and describe_supplied_character: a transport failure after the client's retries, a non-2xx, an error body and an unusable 200 become CharacterModelError with describe_failure's sentence (host, status, the provider's message; never a header, never a key), while model_gateway.GATEWAY_ERRORS and provider_key.ModelKeyRefused pass through untouched, refusal clause first; the boundary sits outside each stage's parse so an answer that arrived whole but could not be used is still the stage's own 400. A new type rather than StoryboardLLMError, because that contract says a key refusal arrives as it and it carries paid attempts; these calls have no fallback, so nothing here can have bought an answer and the routes record no spend. _image_guard maps the type to 502, its three 400s untouched. generate_candidates re-raises GATEWAY_ERRORS before its per-candidate catch like its three siblings; the per-candidate label names the cause (the provider's sentence, or the exception class); a key refusal's own sentence travels back with the empty answer under openrouter_client.KEY_REFUSED, so the refused row says a key could not be sent and the silent-refusal sentence is kept for a genuine silent refusal; the retry function's docstring states that one exceptional channel; ImageResult's documented statuses match what is written. 96 tests in test_character_model_failures.py (79: the four routes through the real stages and a stubbed transport, transport / 502 / unusable 200 per stage; every gateway class and the key refusal passing through at the route and at the stage, 7 kinds × 3 stages plus the boundary itself, with a set pin equal to GATEWAY_ERRORS ∪ {ModelKeyRefused}, bite-proven: 29 fail when the pass-through clauses are removed) and test_storyboard_candidate_failures.py (17: the batch refusal with no misleading label and no claim left, the labels, the two kinds of empty answer, the status set); 32 fail on the base sources. Two existing tests that pinned the old behaviour corrected; the AST pin of the usage-recording lines moved with its call. Whole api tier 4207 passed / 11 skipped / 3 deselected on fd97be5, +67 over that base's 4140; the branch then moved onto 828931a with the six touched files byte-identical and the targeted files re-run there (960 over 18 files); the PR's api job was the tier on the actual base. storyboard_image_runner.py untouched. Recorded trade-off: a gateway refusal that trips mid-batch discards the siblings already rendered, which is the fatal-gateway rule the sibling paths already follow.
Waits on / Unblocks. S30 (merged) / — .
A24 · Agents in chat: bot proposals with quick actions, @skynet tag-and-ask · A · L · S3-W8
Context. The bot tags humans with proposals when work finishes ("scrape done — 3 new families, 1 alerted; teardown / add to brief / ignore"), and the team can tag the bot in a thread to ask or act.
Read first. S27 renderers + quick-action contract; RescaleOS managedSession.ts (session with context), feedbackPollers/*; S14 inbox; the permission catalogue (S1a).
Deliverables. Quick-action handlers (Discord interaction endpoint, Telegram callback queries) → Skynet actions (teardown, add to brief, promote, confirm winner, assign) with the actor resolved to a Skynet user (chat identity ↔ user mapping, core.chat_identities) and permission-checked; @skynet / bot mention in a thread → an agent session with the thread + detected entities (creative code, product, ad link) as context, answers in-thread, may act through the same handlers; transcript + outcome attached to the inbox item; rate limits and a kill switch; pytest apps/api/tests/core/test_chat_agent.py (mocked chat APIs).
Acceptance. Test green; staging demo: a finished research run posts a proposal with buttons, a human presses "promote" and the product is created with lineage; an @skynet question about a creative code answers with its hook rate. Amended 2026-09-19: the demo runs in a staging sandbox channel with the staging bot; a quick action from a channel outside the staging allowlist is refused, and a promote whose ClickUp write would leave the test product 999001 is refused before the bridge calls ClickUp.
Out of scope. Voice; DMs; channels not registered as destinations.
Amended 2026-09-19 (the owner's decision: production at the terminal go-live). On staging this card writes chat messages and in-thread answers, receives quick actions, and through a promote can write a ClickUp Product card via the bridge. Under the staging write rule (architecture §6), staging uses its own staging bot applications and tokens on sandbox channels: a Discord interactions endpoint and a Telegram webhook belong to one bot, so sharing the production bot would move production's endpoint. The ClickUp side of a staging promote is disabled unless it passes the bridge's workspace allowlist for the test product 999001. The production bots and channels are listed in X7's go-live record. The owner supplies the staging bots, their credentials and the consented sandbox channels at this card's co-plan; until then the card builds and tests with fixtures and cannot claim its staging live acceptance.
Settled at the brief co-plan (2026-09-27). The facts pass found that A23's Telegram reader never requests callback_query and is blocked by a webhook on its bot, that no Discord interactions endpoint, webhook or send/edit/answer call exists on main, that no chat identity exists (A23 stores a one-way author hash), that the agent runtime's free-prompt path has no route and returns no text, that no research-run-finished event exists, that promotion is two steps with two keys, that S9's decisions need a reason and rule_taught, and that the card's test paths match no package. A24 therefore: one core migration (number in the launch message; digest input) with core.chat_identities (one active binding per provider user and per user per workspace), core.chat_link_requests (opaque one-use link requests, hash only) and core.chat_interactions (the idempotency, durable-ingress and audit ledger with status and lease fields, claim/finalize routines, an inbox-item reference) behind SECURITY DEFINER routines, the action bot's credentials read only from its S27 action-capable destination row; the inbound endpoints in apps/api (POST /api/chat/discord/interactions with Ed25519 verification, PING → PONG and deferred responses within three seconds; POST /api/chat/telegram/webhook/{secret_path} with the secret header, 200 only after the receipt commits so Telegram retries pre-commit failures, every callback query answered) with completion claimed by a bounded Supervisor hook, never a process-local background task; the action bot separate from every A23 reader bot and the alert bot; identity binding by a one-use link redeemed by the signed-in member on /link-chat; a fail-closed order (receipt → identity → membership → the route's key → the inbound allowlist → consume + write in one transaction through an internal execute_<action>(cur, actor, input) command with a server-constructed ChatActor, never a JWT, a service actor or the bot); the action catalogue on registered S27 events only (assign_to_me with inbox.dispatch.manage, snooze_24h, open, teardown where applicable; confirm/override through a modal or ForceReply collecting outcome, reason and rule_taught; add_to_brief; recommend only after the new real intelligence.research_ready producer and notification, approval shell-only; "process now" out of scope; no text-requiring action as a one-press button); tag-and-ask with deterministic answers in the api and a svc:runtime-only Studio route POST /api/agent/ask (API-attested bound user, read-only chat-task profile, creator recorded, MAX_CONCURRENT, bounded, sanitized untrusted text; actions never derived from prose but validated against the catalogue and emitted as chat.agent_reply); three allowlist keys only (NOTIFY_DESTINATION_ALLOWLIST, MARKET_INTEL_CHAT_BOT_ALLOWLIST, CHAT_ACTION_BOT_ALLOWLIST with a parsed grammar, blank = deny-all), a kill switch and a per-workspace feature flag; web pages /link-chat, /settings/chat, the admin identities rail and the inbox transcript projection. The staging demonstration is amended: one press records a recommendation; a separately authorized shell approval creates the product, lineage and permitted ClickUp card. The acceptance test is apps/api/app/backend/tests/core/test_chat_agent.py; the staging live proof is recorded by the orchestrator once the owner's staging bots exist.
S4 — Workspace cutover (specs)
Facts every S4 card relies on (verified against rescale-workos): 23 ClickUp lists in service/src/domain/ids.ts (team 90121818343); the 13 creative-workflow lists in scope: researchIntake, products, competitors, scripts, creatives, funnelsLanders, ugcCreators, launches, emailFlows, creativeDemand, campaigns, adsets, ripSources; cascade modules demand-week.ts (paste → parsed → locked → spawn), spawn.ts, forward.ts (handleScriptApproved, handleCreativeApproved → Launch), apply-names.ts, reconcile-cron.ts (15-min sweeps), platform-sync/* (15-min readback), qa-gates/*, bounty* (weekly payroll — stays), launch-orphans.ts; ClickUp client clients/clickup.ts (700 ms rate guard, resolveField by name); Railway project "Rescale Tools", railway.json, nixpacks.toml; rescale_service schema in ecomprofits Postgres (sequences, get_creatives_ranking); field sets per list in FIELDS.<list> (auto: true = machine-written): researchIntake (Signal strength, Signal note | Legacy ID), products (24 human incl. Brand, Market, Supplier link, Niche, Evidence, Channels, offers… | auto Product ID, COGS, Margin %, Breakeven ROAS, Target ROAS 15/20 %, Target CPP, Shopify SKU, RESEARCH-ID, Currency…), competitors (Active ads, Total ads, Language, Competitor type, Ad library link), scripts (Hook type, Hooks, Angle, CR_AdCopy, CR_Headline, Script type, Proof needed, Approach, CR_avatar, CR_Hypothesis, Loom link, Script URL | auto Sequence #, Week/Batch #, Is image?, Image sub-type), creatives (Format, CR_fileurl, Ad copy, Headline, Script URL, CR_Hypothesis, CR_avatar, CR_tool | auto Market, Week/Batch #, Spawn #, CR_ex…CR_editingrhythm, Rip source code/URL, Source winner codes, Creative code), funnelsLanders (Funnel type/Flow, Flow code, Angle, Price point/visibility, Fast shipping, Payment, Channel | auto Funnel URL, version #, task kind, Funnelish name/ID, Domain, Available?), launches (Channel, Market, Headline, Ad copy, Error, Error reason, Objective | auto Ad name NC, Creative URL, Platform ad ID, Spend to date €, ROAS, KPI met, Classification note, Launched date, Zombie, Ad account, Credit line, Pixel, Campaign name NC, Adset name, Funnel missing?), emailFlows (Flow type, Channel, Creator/Agency | auto Klaviyo flow name), creativeDemand (Raw post, Notes, CR_Hypothesis, CR_avatar | 20 auto incl. Week label, Totals, Spawned?, Produced target/count, Fulfilled?), campaigns (Type, Platform, Objective | auto CBO budget, Market, Increment #, Campaign name, Platform campaign ID, Spend €, ROAS), adsets (all auto), ripSources (Source ad link, Market, Channel, Creative type, CR_avatar | auto Assigned?, marker); ugcCreators (list 901218779868) has no field definitions at all — D42 must define it before S19; status templates (scripts/field-setup/fields-manifest.json): production_lifecycle Briefed → In Progress → In Review → Needs Edits → Approved → Live / Learning → 🏆 Winner → ⭐ Super Winner → Loser; product_pipeline Candidate → Vetting → Testing → 🏆 Winner → Retired → Rejected; simple_flow; per-list overrides for Products, Scripts (adds 💡 Idea, Idea approved, Proof), Creative Demand (Draft, Parsed, Locked, Delivered), Campaigns (Draft, Live, Exhausted, Closed), Adsets (Active, Archived).
S0b · Pre-pilot discovery (per-brand launch process) · S · M · S4-W0 · docs/s/s0b-launch-discovery
Context. The launch board is the proposed first native board. Gate G2 confirms or re-targets it. Without a real map of how each brand launches statics today, S8's data model is a guess.
Read first. Functional design §3.6 (launch with a creative code) and §7 item 10; Native Workspace PRD (rescale-workos-native-workspace-prd.md) phases A–D; workos service/src/domain/ids.ts lines 187–210 (the 23 Launch fields) and service/src/modules/cascade/launch-outcome.ts (outcome statuses); rescale-workos-handoff.md.
Deliverables. docs/discovery/launch-process.md: (1) one table per brand with columns request source · approval chain · asset packaging (Drive/ClickUp/other) · naming pattern used · account destination · who publishes · time from request to live · where it breaks (with an example); (2) a list of the fields a media buyer actually reads before launching (compare with the 23 ClickUp fields — which are dead); (3) the proposed pilot product + media buyer, with why; (4) two interview notes (media buying, ops), 45 min each.
Interfaces. Feeds S6 (G2 write-up) and S8 (data model must cite the table).
Steps. Book both interviews; run them plus one screen-share of a real launch; draft; then S6.
Acceptance. Doc merged; every active launch brand in scope has a row; pilot product and media buyer named; Agon and Vince have read it (comment on the task).
Out of scope. Designing the board UI (V9); ClickUp changes.
S6 · Discovery write-up → Gate G2 · S · S · S4-W1
Context. Decides whether the launch board is the first native board.
Read first. S0b doc; lock doc §3 (G2 exit + fail path); technical plan §4.1.
Deliverables. docs/decisions/G2-workspace-board-order.md: decision (launch board first — go / re-target / defer), pilot product, media buyer, the S8b field list derived from the discovery table (which of the 23 Launch fields the app shows/writes), and the ordered list of the remaining 12 creative-workflow boards from D42. Gate review with Agon (+ Vince).
Acceptance. ADR merged before S8b starts; S8b's PR cites it.
Out of scope. Anything after G2.
S8b · Launch board API + ClickUp mirror · S · L · S4-W2
Read first. S8a module (extend); lock §4.5 (status classes, field ownership, per-field compare-and-write, durable URLs); forward.ts:550, LISTS.launches, FIELDS.launches, reconcile-cron.ts, launch-outcome.ts, clients/clickup.ts; D41, D42.
Deliverables. Full launch API in workos (GET /launches, GET /launches/:id, POST /launches with Creative + Product relationships, PATCH /launches/:id per-field compare-and-write with conflicts[], outcome statuses observe-only → 409), idempotency table rescale_service.launch_writes, webhook → Skynet on every launch change, native store workspace.launches in Skynet as a mirror (ClickUp still authoritative until the board flips), reconciliation job both ways with a report; LAUNCH_LIST_ID sandbox; vitest services/workos/tests/launch-board-mirror.spec.ts.
Acceptance. Test green; bidirectional create/update transcript; 24-h reconciliation report on staging = 0 diffs.
V9 · Launch board native UI · V · L · S4-W2
Deliverables. apps/web/src/workspace/launch-board/: queue, detail (package, code, ad name copy, assets), status transitions (pre-launch only), assignee, filters, conflict banner, reconciliation state; flag launch_board per workspace.
Acceptance. pnpm --filter web test -- launch-board; the named media buyer completes one launch end-to-end on staging (recording).
S11 · Pilot SOP + walkthrough → G3 · S · S · S4-W3
Context. The launch-board pilot may only move to production dual run after the buyer workflow, reconciliation behaviour and rollback path are proven on one named product.
Read first. S6 (docs/decisions/G2-workspace-board-order.md), S8b API + mirror contract, V9 UI, S10 loop E2E runbook, X5 rollback drill, S12 migration rehearsal, D41.
Deliverables. docs/pilot/launch-board-sop.md (who does what, when ClickUp still rules, how conflicts are resolved, proof-of-launch loop); recorded walkthrough with the named media buyer on staging; pilot-product data-hygiene checklist (assets, naming, assignees, package fields, mirror health); final reconciliation rerun over the pilot window with a diff report (0 unexplained diffs); docs/decisions/ADR-004-pilot-go-live.md with go / no-go and the exact feature-flag state for production.
Acceptance. SOP merged; walkthrough recording attached; reconciliation report with 0 unexplained diffs; ADR-004 merged before S4-W4 starts.
Out of scope. Building the board; ClickUp schema changes; boards beyond Launches.
S26 · Board batch 2 API + mirrors: creativeDemand, scripts, creatives · S · M · S4-W4
Read first. D42 (authority, ownership per list), demand-week.ts, spawn.ts, forward.ts, qa-gates/*, fulfillment.ts; S16 (script bridge — becomes the native store).
Deliverables. Native stores workspace.demand_weeks/demand_lines, workspace.scripts (merging studio.scripts link), workspace.creatives; the cascade logic (parse → lock → spawn → approve → creative → launch) runs in workos against the native stores with ClickUp as the mirror (write-through both ways) until the board flips per list; QA gates ported (M4); the Creative analyzer (M5: video/audio evidence inputs, the 17 CR_* fields auto-filled on Creative entities) ported onto the native Script/Creative approval surfaces; dual-run reconciliation report; vitest services/workos/tests/board-batch2.spec.ts.
Acceptance. Test green; one dual-run report (both directions, 0 unexplained diffs); owner sign-off per list.
V24 · Board batch 2 native UI · V · M · S4-W4
Context. Batch 2 moves the highest-coupling creative-workflow boards native while ClickUp remains the rollback mirror.
Read first. S26 API contract, D42 board-authority doc, S16 script-bridge state map.
Deliverables. Boards for Demand (weeks, paste → parsed lines, bounce rows, lock), Scripts, Creatives (structural fields, briefs, QA-gate status, assignment, spawn visibility), rollback state per board.
Acceptance. pnpm --filter web test -- workspace-batch2; parity checklist signed by the list owners.
Out of scope. Launches, Products, Competitors, Research Intake, Rip Sources, Campaigns, Adsets, Email Flows, company-ops lists.
S18 · Board batch 3 API + mirrors: products, competitors, researchIntake, ripSources · S · L · S4-W5
Context. Batch 3 moves the entity/intake boards native so product setup, competitor tracking, intake and rip sourcing no longer depend on ClickUp as the active workflow store.
Read first. D42, A13a/A13b, A15, the workos reconcile rules (reconcile-cron.ts, provenance sweeps), the economics sync modules (modules/economics/*, thresholds.ts).
Deliverables. Native stores + mirrors for the four lists; products link to the Skynet product (A15) and the naming product number; competitors link to A13 entities; research intake feeds A14; rip sources feed Expand; provenance/reconcile sweeps ported; M8 Sheet sync + unit economics parity (COGS, margin %, breakeven ROAS, target ROAS 15/20 %, target CPP, currency — the sync/reconcile rules that fill those fields today, sourced from the economics modules) on Products; vitest services/workos/tests/board-batch3.spec.ts.
Acceptance. Test green; dual-run report; owner sign-off.
Out of scope. Demand, Scripts, Creatives, Launches, the final read-only cutoff, company-ops lists.
V25 · Board batch 3 native UI · V · L · S4-W5
Context. Batch 3 moves the entity and intake boards native so research/product setup no longer depends on ClickUp for creative-workflow work.
Read first. S18 API + mirror contracts; A13 competitor entities; A15 Promote to product; D42 board-authority doc.
Deliverables. Native boards for Products, Competitors, Research Intake and Rip Sources with per-board rollback state, owner/status visibility, provenance indicators and direct links into Intelligence/Product flows. Products show the naming product number + linked ClickUp id and the unit-economics fields (S18); Competitors show linked A13 entities; Research Intake shows promote/reject state; Rip Sources feed Expand.
Acceptance. pnpm --filter web test -- workspace-batch3; parity checklist signed by the list owners; one dual-run walkthrough attached.
Out of scope. Launches, Demand, Scripts, Creatives, Campaigns, Adsets, Email Flows, company-ops lists.
S17 · workos host move into the stack + credential discontinuation · S · L · S4-W6
Read first. S0a inventory; service/.env.example + config.ts (EnvSchema); Railway config; scripts/register-clickup-webhook.mjs; rescale_service role on ecomprofits.
Deliverables. services/workos deployed as service workos in the Skynet stack (Node 24, limits), env from .env.production; new ClickUp bot token, OpenRouter key, Discord token, AssemblyAI key, Google service account, ecomprofits rescale_service password — old values revoked (report); webhook re-registered to the new host; cron parity (reconcile, platform-sync, bounty, group-sync) plus the media proxy (GET /media/:fileId) verified against a 24-h Railway side-by-side, with an explicit parity check over every retained module (webhooks, reconcile, platform-sync, bounty, group-sync, media proxy, notification/rules); rollback = Railway kept warm for the rollback window then deleted; docs/runbooks/S4-workos-host-cutover.md.
Acceptance. Parity checklist green; revoked-credential report; Railway off after the window.
S19 · Final batch API + ClickUp read-only cutoff: funnelsLanders, ugcCreators, emailFlows, campaigns, adsets · S · L · S4-W7
Context. The final batch completes the replacement of the 13 creative-workflow lists and turns ClickUp into a read-only rollback surface for those lists only.
Read first. D42 (incl. the ugcCreators schema), S17 host-move runbook, the naming assemblers (assemble.ts, funnelish-name.ts, klaviyo-name.ts, campaign-name.ts), platform-sync/*, modules/rules/*.
Deliverables. Native stores + mirrors for the five lists (campaign/adset naming via the assembler; funnel availability + Funnelish/Klaviyo namers ported); then the 13 lists set read-only in ClickUp (statuses locked, a banner comment), the mirror kept as read-only rollback for the retention window; company-ops lists untouched; M7 KPI + M9 Rules parity and the Phase-8 workos features for the in-scope lists: launch outcomes propagation, fulfillment tracker, winner-iteration demand, URL rip engine, auto-CBO / increment fields, Discord/group-sync notification rules; the ugcCreators schema from D42 implemented here; vitest services/workos/tests/board-batch4.spec.ts.
Acceptance. Test green; read-only cutoff checklist; bounty still runs from the native stores.
Out of scope. Company-ops lists, payroll/bounty replacement, non-creative Workspace programs.
V26 · Final batch native UI + Workspace as the default path · V · L · S4-W7
Context. The last creative-workflow boards go native and Workspace becomes the default entry path; company-ops stays in ClickUp.
Read first. S19 API + mirror contracts; D42 board-authority doc (incl. the ugcCreators schema); the shell nav (V6).
Deliverables. Native boards for Funnels/Landers, UGC Creators, Email Flows, Campaigns and Adsets; nav default switched to native Workspace; company-ops deep links remain explicit ClickUp links; the read-only state of the retired ClickUp lists is visible in the UI.
Acceptance. pnpm --filter web test -- workspace-batch4; nav walkthrough attached showing the native default path and the ClickUp-only company-ops links.
Out of scope. Company-ops board replacement.
Bridge track B — standalone work for Mathew while S1 runs (temporary)
These cards live in the standalone repos (meta-ads-scraper, RescaleOS) and are retired by A19 / A20 / B0. Same standard as the slice cards. The PubMed R-cards are specified in development-processes/pubmed-evidence-layer-plan.md §9 (R1–R8) and are only summarised here.
R1 · PubMed doctrine file · Bian-side / Vince · S
Deliverables. engine/skills/_doctrine/evidence-contract.md: the evidence-class table from pubmed-evidence-layer-plan.md §2 (strong / moderate / preclinical / case-report / narrative-review / traditional-use / unclassified), the claims policy (evidence class ≠ permission; DSHEA gate; RAW → CA derivation; form-mismatch blocks claims), citation format, and transport order WebFetch → MCP → paste. Acceptance. File merged; referenced from CLAUDE.md routing.
R2 · RescaleOS /evidence-layer skill · Bian-side / Vince · M · depends R1
Deliverables. Skill in engine/skills/evidence-layer/SKILL.md: inputs mechanism, concern, ingredient(s), market-language seeds; six lenses (mechanism, root cause, symptoms, ingredients, failed solutions, desired outcomes) as PubMed E-utilities queries over WebFetch first; outputs M0–M9 (evidence table with class + citation, mechanism angles, fact-based hooks, authority statements, belief-shifting arguments, symptoms to call out, why other solutions fail, proof anchor, combine step with Reddit VOC); writes evidence_report_{product}.md; dry-run fixture. Acceptance. Fixture run classifies the sample studies correctly; the report is written in the workspace and consumed by the downstream skills.
R6 · Prove on one product · S · depends R2 — Acceptance. One living evidence report for the chosen product; retro edits applied to R1/R2.
R3 / R4 / R5 · Consumers · S each · depends R2, R6 — BPT Phase 2.5 reads the evidence output; belief rows cite evidence rows or are marked as non-evidence-derived; scriptwrite feeds from M2/M3 with RAW/CA handling. Acceptance. One BPT run, one offer brief and one script show cited evidence.
R8 · RescaleOS maps + memory flip · S · depends R2, R6 — routing, supplements/CONTEXT.md, engine/memory/evidence-layer.md say PubMed is live and is evidence, not VoC.
R7 · Console MCP wiring · Bian-side / Vince · S · depends R6 (optional accelerator)
Deliverables. console/src/server/env.ts (getPubmedMcp()), console/src/server/agent/managedSession.ts (mcpServers.pubmed, mcp__pubmed__ pre-trust), console/.env.docker template, DEPLOY.md; wired to the standalone PubMed MCP when that endpoint exists. Acceptance. A console session lists the four PubMed tools and /evidence-layer reports transport mcp.
R7a · PubMed scraper adapter + MCP · Agon · M · depends R6 (optional accelerator)
Deliverables. lib/market-intel/pubmed-{client,normalize,classifier,worker,corpus}.cjs, lib/mcp/pubmed-mcp.cjs at /mcp/pubmed, wired for RescaleOS use. Acceptance. The endpoint serves the four tools and produces the same report contract as WebFetch.
I1 · Scraper LFS bridge · Agon · L
Context. Mathew wants ranked long-copy native ads from an Ads Library link and continuous lander breakdowns before S2 Intelligence exists.
Read first. scraper.js (quick scrape, impression-rank order), lib/deep-crawl/*, ads_browse (destination_url ILIKE search), lib/teardown/{worker,prompts}.cjs (teardown_model setting), lib/analysis/taxonomy.cjs (format axis: no long-copy class), lib/media-storage.cjs, tools/doc-render/ (self-contained HTML pattern).
Deliverables. (1) Long-copy detection as a bridge field ads.is_long_copy_native (heuristic: format ∈ {Native Image, Static} and length(ad_copy) ≥ 900 chars, threshold in settings) — not a taxonomy change. (2) POST /bridge/lfs/runs {ad_library_url, landers[], model}: quick scrape + deep crawl of active ads → filter by landers on destination_url → rank (impression rank, then run span) → enqueue the new teardown tier lfs_extract (prompt: "extract and analyze the long-copy native ad form: structure, hooks, word choice, reading grade level, persuasion devices, rhythm, CTA pattern; produce a 'write one for me' packet"; teardown_model per run, default x-ai/grok-4 via OpenRouter). (3) GET /bridge/lfs/runs/:id (status) and GET /bridge/lfs/runs/:id/package: a self-contained HTML report (ranked cards with image, copy, run span, destination, extraction) + per-ad JSON + media manifest. (4) Schedules: a run can be registered per brand + landers for daily re-execution.
Acceptance. One run from the Primal Queen Ads Library link with both landers returns the ranked candidates and an HTML report that opens standalone; Grok extraction present per selected ad and reproducible from stored inputs; node --test test/ extended.
Out of scope. Native pages; taxonomy redesign; anything inside RescaleOS.
I2 · Primal Queen continuous job + export/drop · Agon · M · depends I1
Deliverables. Drop-target config (BRIDGE_DROP_ROOT: local path and/or Drive sync path); per run: swipes/<brand>/<date>/ gets all candidates (HTML report + per-ad JSON + images), copy/<brand>/ gets drafted LFS packets; winners/ and losers/ only on an explicit human flag; stable names, re-runs idempotent per report scope; daily schedule registered for both landers.
Acceptance. Two consecutive daily runs produce the package in the target root with no duplicate ambiguity; Mathew opens the report from his folder.
Out of scope. Folders as canonical state; any auto winner/loser classification.
MC1 · RescaleOS model-configuration boundary · Bian-side / Vince · S
Deliverables. docs/settings state the true boundary: session model choice = Claude models via setModel (sessions.model, the usage-fallback chain); non-Claude models (Grok etc.) are reachable only through the scraper/OpenRouter tiers (teardown_model, lfs_extract) or a dedicated MCP; the /lfs skill exposes --model as the scraper tier's model, not the session's. Acceptance. No "any model" claim remains in RescaleOS docs; /lfs --model x-ai/grok-4 sets the scraper run's model.
I3 · RescaleOS /lfs <ad-library-url> skill · Bian-side / Vince · M · depends I1, MC1
Read first. engine/skills/npt/SKILL.md (slash-command pattern), engine/skills/_doctrine/, I1 API, the accelerator folder convention.
Deliverables. engine/skills/lfs/SKILL.md: submit run (--landers, --model) → poll → fetch package → file into the workspace (swipes/, copy/) → print report path + top ranked ads → optional --draft: write one LFS using the stored extraction packet as the only source. No scraping, no Grok inside RescaleOS.
Acceptance. One end-to-end invocation from an Ads Library URL to filed artefacts; the draft cites the stored extraction.
I4 · Miguel's feedback → rules.md · Vince · S · depends Miguel's notes
Deliverables. rules.md in Mathew's structure: hard rejects · tendencies · before/after pairs · reaction-log format · promotion rule (one reaction → repeated pattern → reusable rule); sections for AI animated ads and cartoon ads; referenced by the relevant RescaleOS skills. Acceptance. File exists; at least one reaction → rule example.
B0 · Bridge sunset checklist · Agon · S · after A19 + A20 (Skynet card)
Deliverables. docs/checklists/bridge-sunset.md: every bridge path (I1 endpoints, I2 drops, /lfs, /evidence-layer transport notes, R7 MCP) either ported or removed, owner named, folder drops replaced by native entities, docs no longer describe the bridge as active. Acceptance. Checklist signed by Agon and Mathew.