Days are engineering days for one engineer with an agent, verification included. "Holds under" says which Sprint 1 reads the item survives; the swaps are in section 5.
Item 1
Migration 0020 — the bench with a log of every flip: status 'bench' for every upload and link, card_set_status as the only door to live, an AFTER UPDATE trigger that logs even the SQL editor, content_hash and 'Made with' through p_meta, card_by_slug for unlisted cards, my_cards, bench_feed, bench_summary, the fit columns; on the client the share-link fix, the honest copy and the landing line
2.25 days
Why
STRATEGY section 9: no card goes live unreviewed on a phone, and a model never flips a card live. Sprint 1 ran the by-hand daily check because submit_link/submit_media insert 'live' and the shipped copy promises 'it is live, and it is next'; Sprint 1's own kill criterion made the bench sprint 2's first item whatever the numbers. Strangers' pages cannot come in at any volume without the mechanism, the trigger is what makes 'zero unreviewed cards' readable (an RPC-only log cannot see a direct update), the hash keeps a model's regenerate-with-a-new-title from filling the bench with duplicates, and the loop's promise to the maker (it is next, for you) needs the card reachable by its own link while it waits — the same code path as STRATEGY bet 1's share-link fix (a /c/<slug> outside the feed page opens card 0 today with entry_surface='share' on the wrong card). Holds under every fork.
Done when
supabase/migrations/20260923090000_bench.sql applies three times on a fresh Postgres 15 through bash tools/pg/verify.sh and tools/pg/check-bench.sql passes. Schema: card.status check becomes ('live','bench','hidden','blocked'); card gains fit jsonb, fit_ok boolean, content_hash text with a unique partial index where not null, generation jsonb; card_status_log(log_id, card_id, from_status, to_status, reason, actor_id, via check in ('rpc','sql'), ts) with all client grants revoked; trigger card_status_logged AFTER UPDATE OF status ON public.card writes one row whoever wrote — reason from current_setting('blitz.status_reason', true), via from current_setting('blitz.status_via', true) defaulting to 'sql', actor from current_actor() (null from the SQL editor) — so a direct update is logged at write time. RPCs: card_set_status(p_card_id, p_status, p_reason) SECURITY DEFINER raises 42501 unless is_admin(), sets both GUCs with set_config(..., true) so its row reads via='rpc', returns {ok, status}, never reads fit; submit_media(p_kind, p_path, p_title, p_meta jsonb default null) and submit_link re-created from 0015's and 0016's bodies (translators, consent and the per-network upload trigger kept) insert status='bench' for every caller, the founder included (ruling 2), and return {ok, card_id, status:'bench'}; submit_media reads content_hash (validated ~ '^[0-9a-f]{64}$') and made_with (text under 40 chars into generation) from p_meta, and a hash already present answers {ok:true, card_id:<existing>, existing:true} with no new row; card_set_fit(p_card_id, p_fit jsonb) with no client grant (owner role only, called by the fit-check workflow through SUPABASE_DB_URL) sets fit and fit_ok and never touches status; card_by_slug(p_slug) SECURITY DEFINER for anon+authenticated returns the card_public shape for a live card, for a bench card only to its creator or an admin until fit_ok is true and to anyone with the link after (ruling 1's default), null for hidden/blocked; my_cards() for authenticated returns the caller's own cards in every status with status, fit_ok, fit->>'summary', generation->>'made_with', created_at and the latest log reason; bench_feed() behind is_admin() returns bench cards oldest first as card_public plus status, fit, generation, handle; bench_summary() behind is_admin() returns {live, bench, hidden_7d, oldest_bench_age_h, cap} where cap is STRATEGY section 5's greatest(8, least(50, floor(votes_per_day_7d * 28 / 20))) reported, never enforced. RLS card_live_readable is untouched, so card_public, feed(), cards_by() and the end card's re-read serve no bench card. ui_event's kind check and log_ui's whitelist are re-created with 'brief_copy','make_open','stats_open' added (log_ui stays granted to authenticated only, one bucket token each). check-bench.sql asserts: submit_media as a handle-holding account lands status 'bench'; feed() and cards_by() return 0 rows for it; card_by_slug returns null for a second actor before fit_ok and the row after card_set_fit sets fit_ok true; my_cards() returns it for the owner and nothing for another actor; card_set_status as a non-admin raises 42501 and as an admin flips to live with a log row via='rpc' carrying the admin's actor; a direct update public.card set status='hidden' writes a row via='sql' with actor null; card_set_fit is permission denied for authenticated; the same content_hash twice returns existing:true and one row; select rank, card_id from card_rank(14) where rank <= 1 still returns deepstate-demo, chess-program-tutor (verify.sh's standing assertion). Client: src/api/cards.ts gains cardBySlug(slug) and myCards(); src/api/media.ts uploadMedia computes SHA-256 with crypto.subtle and sends p_meta {content_hash, made_with}; the Upload tab gains an optional 'Made with' input (data-testid upload-made-with); App.tsx resolves a /c/<slug> absent from the feed page through cardBySlug and splices it at index 0 before the carousel mounts so entry_surface='share' lands on the shared card; the upload and link success copy and the drop-to-publish toast read 'Added under @handle. It is next for you; everyone else sees it once it has been looked at on a phone — usually today.'; public/landing/index.html's creators section loses 'Paste a link. It's live.' for 'Paste a link or a page. It's yours, and it's next — opened on a phone before it joins everyone's feed.' Tests: tests/unit/cards.test.ts covers cardBySlug, myCards and the p_meta shape against a fake client; tests/e2e/link.spec.ts, comments-upload.spec.ts and pages.spec.ts assert the new copy and the absence of 'It is live' and 'It's live'; new tests/e2e/share-link.spec.ts: /c/<slug> for a fixture card outside the first page opens on that card (src/api/mockApi.ts gains bySlug over MOCK_GAMES). Post-deploy (Deploy 1, Day 2): a page uploaded from a non-admin test account is absent from /feed in a fresh profile and opens at /c/<slug> on the founder's phone signed in as its creator; select public.card_set_status('<id>','live','reviewed: phone') as @rand flips it and card_status_log has the row with via='rpc'; a direct update in the SQL editor leaves a via='sql' row; the Migrate workflow shows 0020 applied.
Holds under
every fork
Touches
supabase/migrations/20260923090000_bench.sql (new), tools/pg/check-bench.sql (new), src/api/cards.ts, src/api/media.ts, src/api/mockApi.ts, src/App.tsx, src/components/LinkSite.tsx, public/landing/index.html, tests/unit/cards.test.ts, tests/e2e/link.spec.ts, tests/e2e/comments-upload.spec.ts, tests/e2e/pages.spec.ts, tests/e2e/share-link.spec.ts (new), docs/ACCOUNTS.md
Depends on
Rulings 1 and 2 by Day 1 noon; Sprint 1's 0016–0019 applied (0020 re-creates submit_* and log_ui from their bodies)
Item 2
The save channel: window.blitz.save/load through the html viewer's probe, host-kept per device, 4 KB, keyed by card id
1.5 days
Why
Since the sleep/wake work a card BLITZ leaves is unloaded and reloads on return with its state gone, and there is no save channel (DESIGN-NOTES section 3). The brief asks for pages that remember — a counter, a drawing, a puzzle's progress, 'your best' — and those are the families whose loop is come-back-and-see; without the channel every such page the loop produces is a one-session card and the D1 read is set against it. STRATEGY bet 2's mechanism, scoped to the html viewer because that is the only hosted class strangers can make. Holds under every fork.
Done when
public/cards/html/index.html: on start it posts {blitz:'load'} to the host in parallel with the fetch and waits up to 300 ms for {blitz:'state', data}; the injected PROBE then defines window.blitz = { load(): the parsed object or null, synchronous, from the state embedded in the probe (JSON with </script and U+2028/2029 escaped); save(obj): JSON-serialised, returns false and posts nothing over 4096 bytes, otherwise posts {blitz:'save', data:<string>} upward; touch() }; the viewer relays upward only 'touch', 'beat' and a 'save' whose data is a string <= 4096 bytes, and forwards a late {blitz:'state'} from the host (e.source === parent) down to the nested frame so a page that asks after start still gets it. src/lib/WebCardHost.ts: BlitzCardMessage gains 'save' | 'load'; onWindowMessage answers 'load' by posting {blitz:'state', data} to frame.contentWindow with targetOrigin '' from localStorage key blitz:state:<meta.id> (null when absent, in private mode or when storage throws) and on 'save' writes that key through new src/lib/cardState.ts (cap 4096 bytes, trailing-edge throttle to one write a second per card, last wins, every read and write in try/catch); keyed by meta.id so no card can read another's; never forwarded to telemetry; sleep(), warm() and resume() are unchanged — the reloaded page asks for 'load' again. tests/unit/cardState.test.ts: cap, throttle, key isolation, a storage that throws. tests/e2e/save-channel.spec.ts on the default touch project against a fixture at public/cards/e2e/remember.html (a tap counter using the three calls), inserted through __blitz's api.insertGame with page_url /cards/html/index.html?src=<bucket>/e2e/remember.html and page.route fulfilling that bucket URL from the fixture with a CORS header, so the no-backend build exercises the channel: three taps show 3; BLITZ then the chevron back reloads the frame (the unload web-card-sleep.spec.ts observes is asserted here too) and it shows 3 within 2 s; a second card id with the same fixture shows 0; a 5 KB save leaves the key unchanged; ten saves in 200 ms produce one write; tests/e2e/web-card-sleep.spec.ts stays green unchanged. docs/EMBEDDING-A-SITE.md gains 'Make your page remember' — the three calls, the 4 KB cap, 'this phone only; nothing of it ever ranks' — and the one sentence to hand a model: 'If window.blitz exists, restore from window.blitz.load() at start and call window.blitz.save(state) whenever state changes; keep state under 4 KB.'; docs/DESIGN-NOTES.md section 3 records the channel; docs/RANKING.md gains 'saved state never ranks'. Phone check after Deploy 2: the founder's counter page keeps its count across BLITZ and the chevron on the phone; localStorage on the feed origin holds only blitz:state: keys, none over 4 KB.
Holds under
every fork
Touches
public/cards/html/index.html, src/lib/WebCardHost.ts, src/lib/cardState.ts (new), public/cards/e2e/remember.html (new), tests/unit/cardState.test.ts (new), tests/e2e/save-channel.spec.ts (new), docs/EMBEDDING-A-SITE.md, docs/DESIGN-NOTES.md, docs/RANKING.md
Depends on
Nothing (Deploy 2); ruling 4
Item 3
'Make one': the brief in src/lib/brief.ts, a third tab in the + sheet, paste-back with fence-stripping and 'Made with', ?make=1 as the door, and the end card's honest count
1 day
Why
The record button. Today the Upload tab says 'something a model wrote for you' and leaves the person to guess what to ask; the pages that come back are desktop layouts that squeeze, pages that fetch, pages that scroll. A brief the person copies into any model is the cheapest possible creation path and it is not ours: no endpoint, no cost, no anonymous prompt, no model deciding anything on our side. The brief is readable by anyone (it is text); adding remains gated exactly as upload is today. The in-sheet 375x667 preview is dropped (two judges: it needs a preview mode in the viewer and a scaled frame in a sheet on a 375-wide phone) — the card itself, next in the maker's own feed and unlisted, is the preview, and the fit verdict on the profile is the check. Holds under every fork.
Done when
src/lib/brief.ts exports BRIEF_VERSION = 1 and three briefs (page, image, clip) as plain text. The page brief says, in full: one self-contained .html file; designed for a 375x667 portrait phone, fills the viewport, no page scroll, no horizontal overflow; dark ground #0b0e14; does something on its own within 1 s (it is seen before it is touched) and answers the first touch within 100 ms; touch-first (pointer events, touch-action: none where it handles drags, no hover-only affordances, no long-press menus); no network calls of its own — scripts and styles inline, the only external scripts and styles from the html viewer's CSP list, images as data: or https; no <form>, <iframe>, <base>, meta refresh or navigation; if window.blitz exists, restore from window.blitz.load() at start and call window.blitz.save(state) whenever state changes, under 4 KB; a <title>; <meta name='blitz-brief' content='1'> in <head>; under 200 KB ideal, 25 MB max. The image brief: portrait 9:16 or 3:4, PNG/JPEG/WebP, under 50 MB. The clip brief: portrait MP4 (H.264) or WebM, <= 30 s, made to work muted. tests/unit/brief.test.ts asserts the page brief names 375, 667, window.blitz.save and blitz-brief, and that its CDN list equals the list parsed from public/cards/html/index.html's CSP meta (the two cannot drift). src/components/LinkSite.tsx: the mode tabs (Link a website / Upload / Make one) render for everyone; Link and Upload keep their account doors; Make one shows the brief in a read-only textarea with 'Copy the brief' (navigator.clipboard with a select-all fallback; logs ui_event 'brief_copy' through src/api/ui.ts), the line 'Ask any model for a page with this. Paste what it gives you back here.', the paste textarea (the same account door as Upload appears on Add for anonymous visitors, ruling 6), the optional 'Made with' input, and 'Add to Blitz', which is the Upload tab's html path (htmlFile + uploadMedia with p_meta); a model's answer wrapped in ```html fences is unwrapped before upload. App.tsx: ?make=1 opens the sheet on the Make tab at boot (logs 'make_open') and syncUrlToCard drops it; the end card's line becomes 'You played {cards with a session this visit} of {deck length}' (client-side, from the carousel's visit record). New tests/e2e/make-one.spec.ts: an anonymous visitor sees the brief on the Make tab and the account door on Add; the copy button fills the clipboard (Chromium clipboard permission granted in the spec) and uiLog carries 'brief_copy'; a fenced answer is unfenced; /feed?make=1 opens the sheet on the Make tab and uiLog carries 'make_open'; 'Add to Blitz' on the no-backend build says there is no backend; tests/e2e/end-card.spec.ts asserts the count line after two cards. Phone check after Deploy 3: the founder copies the brief on the phone, pastes a model's answer, adds; the card is next in the founder's own feed at 375x667.
Holds under
every fork
Touches
src/lib/brief.ts (new), src/components/LinkSite.tsx, src/components/Carousel.tsx (end-slide), src/App.tsx, src/api/ui.ts, src/index.css, tests/unit/brief.test.ts (new), tests/e2e/make-one.spec.ts (new), tests/e2e/end-card.spec.ts
Depends on
Item 1 deployed (the bench and the copy); 0019's logUi call sites
Item 4
The phone-fit check: tools/fit-check.mjs, a gated workflow that writes a verdict on the row and can never change a status
1.5 days
Why
STRATEGY's Deepstate lesson: framing permission is not fit, and only a phone-sized render can tell. Reviewing every stranger page by hand on a phone is the founder's bottleneck at 15 h/week; a check that paints the page at 375x667, counts pixels, taps it and lints its text turns the review into a glance at a verdict, and gives the maker an honest reason when their page does not fit. It runs where Playwright already runs (CI) with the secret the Migrate workflow already has, so no function, no new host, no anonymous endpoint. The 15-minute schedule is gone (all three judges: a private repo's metered minutes, delayed crons, a flaky 30-minute assertion): hourly with an early exit, dispatch on demand, by hand as the fallback. It writes a verdict; a person flips status. Holds under every fork — under (a) it is also the card-0 diagnostic.
Done when
tools/fit-check.mjs (npm run fit:check -- <file|url> [--json]) reuses tools/probe-webcard.mjs's magenta-backdrop pixel count and tools/verify-cards.mjs's two-origin shape: the candidate is served under the html viewer's exact CSP meta inside a sandbox='allow-scripts' frame at 375x667 in a touch context (the iPhone SE descriptor); it screenshots at 1 s and 3 s and counts non-background pixels, evaluates documentElement.scrollWidth and scrollHeight inside the frame, taps the centre and screenshots again, records console errors and securitypolicyviolation events, and lints the text: size, <base>, <form>, <iframe>, meta refresh, external <script src>/<link rel=stylesheet> off the CSP list (a violation), top-level location assignments (a warning), the blitz-brief meta (brief_version). Verdict {ok, painted_1s, painted_3s, overflow_x, overflow_y, reacts, errors[], violations[], lint[], brief_version, summary, tool_version, checked_at} where ok = painted_3s >= 60% and not overflow_x and violations is empty and errors is empty; summary is one phrase a maker can read ('fits' / 'wider than a phone' / 'painted 12% at 3 s' / 'loads a script off the list'). tests/unit/fit-lint.test.ts covers the lint on tests/fixtures/fit/{good,wide,blank,evil}.html; .github/workflows/ci.yml gains a 'fit-check fixtures' step (about 2 minutes) asserting good=ok, wide=overflow_x, blank=painted_3s<60, evil=violation. .github/workflows/fit-check.yml: workflow_dispatch plus an hourly schedule that skips entirely when SUPABASE_DB_URL is unset (the Migrate pattern) and exits before installing Playwright when select count(*) from public.card where class='web' and source='hosted' and fit is null and created_at > now() - interval '14 days' is 0, so an empty hour costs a checkout and a psql; otherwise it selects card_id, media_url for up to 20 such cards, runs the tool per card and writes select public.card_set_fit(:'id', :'fit'::jsonb); it never calls card_set_status — tests/unit/workflows.test.ts greps the workflow file for card_set_status and fails if present. FIT GATE before the workflow writes anything (Day 6 morning): the tool's Day-2 hand-run verdicts on the live deck agree with the founder's Sprint 1 phone verdicts on every card but at most one, else the tool is fixed first and my_cards()/the strip say 'not checked yet' honestly. tools/column-check.sql gains: every hosted web card older than 24 h since Deploy 3 has fit not null; card_status_log has no row with to_status='live' and via='sql'. Images and clips get no automated check this sprint (the media viewer letterboxes; the brief's variants are text). Phone check: a deliberately 900 px-wide page uploaded from a test account shows 'wider than a phone' in my_cards() and on the strip within the hour.
Holds under
every fork
Touches
tools/fit-check.mjs (new), tests/fixtures/fit/{good,wide,blank,evil}.html (new), tests/unit/fit-lint.test.ts (new), tests/unit/workflows.test.ts (new), .github/workflows/fit-check.yml (new), .github/workflows/ci.yml, tools/column-check.sql, package.json (fit:check)
Depends on
Item 1 deployed (card.fit, card_set_fit); SUPABASE_DB_URL present in the repository secrets (it is, for the Migrate admin grant); ruling 11
Item 5
Migration 0021: card_stats and loop_funnel; 'Your cards' with host-measured numbers on the profile; the loop panel and bench_summary on /stats
1.25 days
Why
The maker's payoff, per STRATEGY bet 6 and the goal: what a maker cannot get anywhere else is honest, host-measured hold from a phone audience. Nothing shows a creator a single number today. The loop's outcome metric (stats_open) does not exist until this does, and the readout's funnel is the same RPC. Aggregates only, creator or admin only, the creator's own sessions excluded, no actor id leaves the function, and none of it ranks. Holds under every fork.
Done when
supabase/migrations/20260928090000_card_stats.sql applies three times and tools/pg/check-stats.sql passes: card_stats(p_card_id, days default 14) SECURITY DEFINER for authenticated, 42501 unless the caller is the card's creator or an admin; over the window and excluding actor.is_internal, card_session.is_probe, ua_class='headless' and the creator's own actor: impressions (distinct session_uuid), viewers (distinct actor_id), networks (distinct ip_hash), held (span_ms >= 5000 and no CRASH), blitz_past (SWIPE_UP under 5 s), comments (live), likes (like minus unlike), shares, as counts plus held_rate with n. loop_funnel(days) behind is_admin(), per UTC day and total, excluding @rand's actor, internal, probe and headless: actors who logged brief_copy; make_open; actors who uploaded and uploads (card.creator_id set, source 'hosted'); uploads with fit_ok; uploads flipped live by an admin (a card_status_log row to_status='live', via='rpc', actor is_admin); live uploads with >= 10 non-creator browser sessions from >= 3 distinct ip_hash; actors who logged stats_open; complete loops = actors with uploaded, flipped live, reached 10/3 and stats_open all true. check-stats.sql: a creator reads their own card's stats; another authenticated actor gets 42501; the creator's own sessions do not count; loop_funnel raises 42501 for a non-admin and returns every column for an admin on a seeded loop. Client: src/api/cards.ts cardStats(); src/api/stats.ts loopFunnel(), benchSummary(); src/components/AccountSheet.tsx's profile view gains 'Your cards' (data-testid my-cards): one row per card — title, a status word ('next for you · waiting for a look on a phone' / 'live' / 'hidden: <reason>'), the fit summary, made_with when set, and for live cards impressions, held %, BLITZ-past and comments over 14 days; a row links to /c/<slug>; opening the panel logs ui_event 'stats_open' once per visit; the line 'Excludes you on this device; your other devices count as viewers.' sits under the table (ACCOUNTS.md's known limitation written beside the number, ruling 8). src/components/StatsPage.tsx gains the loop_funnel(14) table and bench_summary() beside retention_daily, and a 'make' via filter. tests/unit/stats.test.ts renders both from fixture JSON; tests/e2e/account.spec.ts covers my-cards on the mock with a card in each status word. Post-deploy (Deploy 4): the founder's profile shows numbers for a card the pilot played; a test account's card shows its bench state and fit line; select * from loop_funnel(14) returns every column for @rand and 42501 otherwise.
Holds under
every fork
Touches
supabase/migrations/20260928090000_card_stats.sql (new), tools/pg/check-stats.sql (new), src/api/cards.ts, src/api/stats.ts, src/components/AccountSheet.tsx, src/components/StatsPage.tsx, src/api/ui.ts, tests/unit/stats.test.ts, tests/e2e/account.spec.ts, docs/CONTENT.md numbers table
Depends on
Items 1 and 4 deployed (status log, fit); 0019's ui_event and ua_class
Item 6
The review strip on the founder's phone (/feed?bench=1): play it, read the verdict, tap Live or Hide
0.75 days
Why
The bench only works if the flip is ten seconds on a phone: the founder opens the bench as a feed, plays each card, reads the fit verdict, taps Live or Hide with a reason. Without it card_set_status lives in the SQL editor and the median time-to-live is whatever the founder's laptop hours are. The image/clip aspect line that shared this item is dropped up front (three judges: the media viewer letterboxes and the brief's variants are text). This is the item that funds the swap under (a) and (c): the SQL editor is its fallback, and the bench, the log and the 24-hour rule stay whatever happens to the strip.
Done when
App.tsx: when location.search has bench=1 and the account is an admin, the feed resource is bench_feed() instead of feed(), the transport still runs (the founder is internal, so nothing votes), and src/components/ReviewStrip.tsx sits below the meta strip and never over the surface: @handle · made_with · the fit summary ('fits' / 'wider than a phone' / 'painted 12% at 3 s' / 'not checked yet') · bench_summary()'s 'live 11 · bench 4 · oldest 6 h', and two 44 px buttons Live and Hide (Hide asks a one-word reason from a short list: does-not-fit, blank, broken, not-for-strangers, takedown) calling card_set_status with reason 'reviewed: touch=y fit=y show=y' or 'hidden: <word>'; on success the strip advances to the next bench card; non-admins with ?bench=1 see the normal feed and no strip. src/api/cards.ts gains benchFeed() and setCardStatus(); src/api/mockApi.ts gains them with a statusLog exposed on __blitz. tests/e2e/review-strip.spec.ts on the no-backend build with __blitz's admin fixture flag: the strip renders only under ?bench=1 for the admin fixture; Live pushes {card_id,'live',reason} to __blitz.statusLog and the next bench card becomes active; Hide requires a reason; the strip's box and the surface's box are disjoint. docs/CREATING.md documents the words a maker sees. Phone check after Deploy 4: the founder reviews the day's bench on the phone in under a minute a card, and card_status_log shows each flip with via='rpc' and the reason.
Holds under
b, d — dropped under a and c to fund the swap (card_set_status and bench_feed() in the SQL editor from the laptop each evening; the bench, the log and the 24-hour rule stay)
Touches
src/App.tsx, src/components/ReviewStrip.tsx (new), src/api/cards.ts, src/api/mockApi.ts, src/index.css, tests/e2e/review-strip.spec.ts (new), docs/CREATING.md (new)
Depends on
Item 1 deployed; item 4 writing verdicts; bench_summary from 0020
Item 7
Docs, the loop readout query and the column check, during the freeze
0.5 days
Why
The readout is the deliverable and is not written on the morning it is read; docs ship during the freeze because they do not change the client bundle. The card protocol (window.blitz) is now something strangers' models are asked to speak, so it must be written down where the brief points.
Done when
docs/CREATING.md (new): the brief, the three calls, the bench and its words, the fit verdict's fields, what numbers a maker sees and what never ranks; docs/EMBEDDING-A-SITE.md's 'Make your page remember' finished; docs/RANKING.md gains 'fit, saved state, card_stats, made_with and the status log never rank'; docs/CONTENT.md's numbers table gains card_stats, loop_funnel, bench_summary, fit, card_status_log, content_hash; docs/ACCOUNTS.md gains 'Your cards'; docs/STRATEGY.md section 5 gets a two-line amendment (the bench exists with a log; the brief is the first creation path, remix-by-pack a later one). tools/sql/loop-readout.sql: the Day-14 block (loop_funnel(14), bench_summary(), cards by status x uploader class, fit pass rate by brief_version and made_with, median hours bench->live and the max bench age per day from card_status_log, select count(*) from card_status_log where to_status='live' and via='sql', retention_daily(14,'make') beside (14,'pilot') and (14,null) with the binomial interval) rehearsed on Day 13 against real rows; tools/column-check.sql's fit and status-log assertions pass on Day 13.
Holds under
every fork
Touches
docs/CREATING.md (new), docs/EMBEDDING-A-SITE.md, docs/RANKING.md, docs/CONTENT.md, docs/ACCOUNTS.md, docs/STRATEGY.md, tools/sql/loop-readout.sql (new), tools/column-check.sql
Depends on
Items 1–6
Item 8
The maker-wave fix day (reserved, Day 10): P0 only; when there is no P0 the day goes to the fork's swap-in, or on the core path to the trust floor (Report and Remove it, no auto-hide)
1 day
Why
The first pages strangers paste will break things no fixture did — a 3 MB inline font, a page that saves 4 KB every frame, a model's answer wrapped in prose, a status word that lies. Reserved rather than pretended away. If nothing is P0, the day is where the Sprint 1 fork lands under (a) and (c); under (b) and (d) it goes to the one trust piece that becomes necessary the day a stranger's page is live in front of other strangers — a button, with no auto-hide behind it, so three phones can never remove a card (two judges dropped B's auto-hides as a bigger lever than demote-only ever gave a forger).
Done when
docs/RETENTION.md has the Day-10 loop row (brief copies, make_open, uploads by class, fit pass rate, hours bench->live, live non-founder cards, stats opens, n) written before any fix; each P0 has a failing test first, e2e green, ships in Deploy 6 the same day and is phone-checked within the hour; the freeze gate in the evening — tools/column-check.sql clean, fit-check.yml green on its last three real runs, no bench card older than 24 h, no open P0 — passes before Day 11's second send; anything not P0 is listed under 'carried to sprint 3'. With no P0: under (a) or (c) the day is that fork's swap item (9 or 11); under (b) or (d) it is migration 0022 (supabase/migrations/20261002090000_trust.sql: card_report(report_id, card_id, actor_id, reason check in ('broken','not_for_phones','offensive','spam','mine','other'), note <= 200, ip_hash, created_at, status) and card_request(request_id, card_id, kind check in ('claim','remove'), contact <= 200, note <= 500, actor_id, ip_hash, created_at, status), report_card() and request_card() for any authenticated caller at 5 a day per actor and 20 per network via ip_hash, unique (card_id, actor_id) on reports, NO status change in either, ui_event kinds 'report','remove_request','menu'; tools/pg/check-trust.sql; a fourth 40 px rail button 'More' in GameViewer.tsx opening src/components/CardMenu.tsx with Copy link, Report (reason radios + note; toast 'Thanks — a person looks at every report') and Remove it ('Yours and you want it gone? Leave a way to reach you. It is hidden today.'); open rows shown on the ReviewStrip and cleared by the founder the same day; tests/e2e/card-menu.spec.ts on the mock; the concierge DM's 'reply remove and it is gone today' is then a button as well as a promise).
Holds under
every fork (the filler swaps: a and c take the swap item, b and d take the trust floor; under b the day shrinks to 0.5)
Touches
whatever the wave names; docs/RETENTION.md; on the core path supabase/migrations/20261002090000_trust.sql (new), tools/pg/check-trust.sql (new), src/components/GameViewer.tsx, src/components/CardMenu.tsx (new), src/api/cards.ts, src/api/ui.ts, tests/e2e/card-menu.spec.ts (new)
Depends on
Day 8's sends; the Day-10 loop row written first
Item 9
Fork (a) swap — card 0 and the chrome: the deck's fit read on Day 1, card_editorial re-decided on Day 3 by ruling, migration 0023 card_zero with the fixtures in one commit, first-minute.spec.ts, the carried chrome P0
1.5 days
Why
Sprint 1's read under (a) says the first minute is the problem — card 0's phone fit and the chrome, not supply, not composition. The fit tool built for strangers' pages is exactly the card-0 diagnostic (Deepstate's paint and overflow at 375x667, read from data on Day 1), and the second-week days that would have gone to the strip and the fix day's slack go to card 0 and the one chrome P0 the pilot named. Supply keeps its bench, its brief and the founder's own uploads — those hold — but no founder hour goes to the makers' hunt until card 0 holds.
Done when
Day 1: the fit tool's hand run on the live deck (Deepstate, chess, the Sprint 1 pages, the platform embeds) is written into docs/RETENTION.md before any ruling. Day 3: if Deepstate's verdict is painted_3s < 60% or overflow_x (ruling 9), card_editorial is re-ordered in the SQL editor so the deck opens on the best-fitting founder page by fit then card_rank(14) hold — an editorial act RANKING.md permits, nothing promoted on telemetry. Days 8–10: supabase/migrations/20260930090000_card_zero.sql rewrites card_editorial to the ruling with MOCK_GAMES, feed.json and the e2e fixture in the same commit (chain x3; verify.sh's rank assertion updated to the new pair in that commit); the one chrome P0 Sprint 1's carried list names first (the chevron not found, the slide-thumb blank before a frame paints, the meta strip over a card's control) with a failing test first; new tests/e2e/first-minute.spec.ts on the touch project: card 0's host is 'running' under 1.5 s from a cold context on the no-backend build, the meta strip's box never intersects the surface, BLITZ and the chevron hit areas are disjoint. Deploy 6 carries it with a phone verdict line. Funded by: the strip (0.75), the fix-day filler (0.5), the hourly fit schedule (0.25, dispatch after each bench pass instead). Founder: four pages instead of six to eight (two that remember), 2 h re-reviewing every deck card for fit at 375x667 and hiding by hand what fails, the makers-already-posting hunt shrinks to five and the Day-11 send is skipped.
Holds under
a
Touches
supabase/migrations/20260930090000_card_zero.sql (new), src/api/mockGames.ts, public feed.json, tests/e2e/helpers.ts, tests/e2e/first-minute.spec.ts (new), tools/pg/verify.sh, src/components/TelemetryDisplay.tsx or Carousel.tsx (the named P0), docs/RETENTION.md
Depends on
Sprint 1's Day-14 read = (a); the Day-1 fit run; ruling 9
Item 10
Fork (b) swap — the founder's pages get 'New today': actor.house and the audition pick over house uploads, riding migration 0021
0.75 days
Why
Under (b) people finish and do not return: the deck is too short, and the supply is the founder's own pages through the brief plus the concierge. STRATEGY section 6 names the fault that makes that supply invisible: card_rank's explore_pool requires the uploader not to be internal and holds one ticket per uploader, so the founder's account — internal since Sprint 1 Day 1 — has zero tickets and every founder page sits at the tail. 'New today' is the only distribution a bench graduate gets; composition reads editorial data only, no rate, so it opens no fabrication path. Kept to (b) (two of three judges): elsewhere it touches the fabrication-proof ranker for no reader.
Done when
Inside 20260928090000_card_stats.sql (or a 0022 the same day): actor gains house boolean not null default false and @rand's row is set; card_rank re-created from 0008's body so explore_pool applies the is_internal test to voters only (the session CTE already does), skips k_uploader_holds and k_uploader_age for house uploaders, and draws one ticket per house card with the day's pick md5(current_date || card_id) over eligible live house cards under 30 votes and 30 days; outside uploaders keep the existing rule; k_explore_grad and the slot stay. tools/pg/check-audition.sql: a seeded house upload with zero votes appears at slot 'explore' in card_rank(28) with the uploader marked internal; a visitor upload with no held cards does not; deepstate 0 / chess 1 unchanged. Production: after one house page is flipped live, select card_id, slot from card_rank(28) where slot='explore' returns a house card the next UTC day — if it returns nothing, nothing about 'New today' is true and it is fixed before Deploy 4. docs/RANKING.md's audition paragraph updated. Funded by: the hourly fit schedule (0.25, dispatch after each bench pass) and the fix day shrinking to 0.5 d. Founder: ten to twelve pages instead of six to eight (+3 h), five known or already-posting makers DMed the brief by Day 6 (+2 h), the image and clip dropped and the Day-11 send folded into Day 8 (−1.75 h).
Holds under
b
Touches
supabase/migrations/20260928090000_card_stats.sql (the actor.house and card_rank blocks), tools/pg/check-audition.sql (new), docs/RANKING.md
Depends on
Sprint 1's Day-14 read = (b); item 1 deployed
Item 11
Fork (c) swap — composition-lite for a short deck, two-sided: feed(n, seed, exclude) with one hard rule, the client twin, one fixture asserted on both sides, and the Day-10 sequencer gate
1.75 days
Why
Under (c) D1 holds provisionally and Sprint 1 says composition rescoped for a short deck (bet 4) plus supply (bet 6), still no launch post. On a deck of twelve to twenty the shuffle is real; verb families belonged to the hidden toys, so the one rule the live deck can satisfy is kind (page, image, clip, site). Two-sided (all three judges): a feed that differs by path cannot be read, so the no-backend fallback must produce the server's order for the same seed, and the hash must be synchronous on the client because helpers.ts derives order at module load. Reads tiers and a seed, never a rate; demote-only untouched.
Done when
supabase/migrations/20260930090000_feed_seed.sql (chain x3): card_public gains kind ('site' when source='external', 'clip'/'image' by class, else 'page'); feed(n default 60, seed text default null, exclude text[] default '{}') keeps slots 0–1 from card_rank and orders the live tail by md5(coalesce(seed,'') || current_date || card_id) under one hard rule — no two consecutive cards of the same kind — in a plpgsql greedy with an explicit fallback flag; seed accepted only if ~ '^[a-z0-9]{8,32}$', else null; exclude capped at 200 ids, applied then discarded, never stored or logged; the old feed(integer) dropped first so the file re-runs. src/api/sequence.ts is the client twin with an inlined synchronous md5 (no crypto.subtle); tests/fixtures/sequencer.json (9 cards: 2 sites, 4 pages, 2 clips, 1 image) is asserted by both tools/pg/check-sequencer.sql and tests/unit/sequence.test.ts for seed 'abcdef12' on '2026-09-30'; src/lib/deck.ts mints the device seed once into localStorage in try/catch; fetchFeed sends seed and exclude and the no-backend build sequences MOCK_GAMES with ?seed=; the end card's BLITZ re-reads feed with the ids held this visit in exclude and says 'Nothing new since you looked' when the result is empty (Again reshuffles on device). tests/e2e/feed-seed.spec.ts: two seeds differ past slot 1 with the same card 0; no two adjacent kinds when the pool allows; the end card's re-fetch carries exclude (through __blitz.getState().lastFeedRequest). Day-10 SEQUENCER GATE on production before Deploy 6: feed() for two seeds in the SQL editor — same card 0, different past it, no adjacent kinds. Still no launch post: the day-21 bar on >= 200 browser arrivals is not met by a 100-person pilot. Funded by: the strip (0.75), the fix-day filler (0.5), the /stats loop panel (0.25 — loop_funnel read in the SQL editor), the hourly fit schedule (0.25).
Holds under
c
Touches
supabase/migrations/20260930090000_feed_seed.sql (new), tools/pg/check-sequencer.sql (new), tests/fixtures/sequencer.json (new), src/api/sequence.ts (new), src/lib/deck.ts (new), src/api/cards.ts, src/api/mockApi.ts, src/App.tsx, src/components/Carousel.tsx, tests/unit/sequence.test.ts (new), tests/e2e/feed-seed.spec.ts (new), tests/e2e/helpers.ts, docs/RANKING.md
Depends on
Sprint 1's Day-14 read = (c); Deploy 3 live by Day 6; ruling 12
Item 12
Fork (d) swap — one more founder-network wave into the Sprint 1 build on Day 2, the day-21 block, and the Day-7 read that selects week 2
0.25 days
Why
Under (d) D1 sits in the gray zone at n≈100 and Sprint 1 prescribes one more founder-network wave with the day-21 read deciding — nothing more, and not into a changed feed (two judges dropped C's wave into a composed deck for confounding the read). Deploy 1 changes nothing a visitor sees (feed() still serves only live rows; the chrome is untouched; only the + sheet's copy and the landing line changed), so wave 3 lands on the Sprint 1 build in all but name, and every visible client change waits for the read.
Done when
Day 1: tools/sql/retention-readout.sql gains the day-21 block (D1 with its interval on pilot plus wave-3 browser arrivals, first sessions Sprint 1 Days 9–12 and Sprint 2 Days 2–3, r=-referred arrivals excluded from share-to-arrival as Sprint 1 did) and it is rehearsed on Sprint 1's rows. Day 2: wave 3 of ~50 goes out 1:1 as /feed?via=pilot&r=<the founder's u_ id> after Deploy 1's phone check, nobody from waves 1 or 2 re-sent. Deploy 2 (the save channel) changes nothing for a visitor whose deck has no page that saves; Deploy 3 (the Make tab, the end card's count line) waits until after the Day-7 read and ships that afternoon with Deploy 4. Day 7: the read with n near 150 selects (a), (b) or (c) for Days 8–14 and that fork's add and drop lists apply from Day 8; if D1 is still in the gray zone at n≈150, week 2 takes the (a) swap, because another wave cannot narrow it and the first minute is the cheapest place to look. The pilot-replier 'make one' send waits for the Day-7 read so the wave-3 cohort's first card is not changed under it.
Holds under
d
Touches
tools/sql/retention-readout.sql, docs/RETENTION.md, the founder's list
Depends on
Sprint 1's Day-14 read = (d); Deploy 1's phone check
Total: about 10 engineering days for the core (items 1–8) plus the one fork swap the read selects (items 9–12, between a quarter of a day and 1.75 days).
Rulings by noon and the Sprint 2 baseline column before any code (1 h)
Why. Every number in the sprint is read against a row that exists before code ships, and rulings 1 and 2 shape migration 0020, which is written on Day 1.
Done when. Day 1 by noon: rulings 1–12 answered or explicitly defaulted in docs/RETENTION.md (0.5 h). Day 1 morning with the agent: the 'Sprint 2 baseline' column in docs/RETENTION.md — Sprint 1's Day-14 row (pos-0 hold, reached-end, back taps, D1 with interval, n, by via) beside the loop's zeros (distinct non-founder uploaders 0, cards with fit 0, makers who saw a number 0, cards that waited on a bench 0, logged status flips 0) — and Sprint 1's carried-to-sprint-2 list read and its first chrome P0 named (0.5 h).
Founder 1
The founder is the first stranger: six to eight pages through the brief, two that remember, one image and one clip
7.5 h
Why
The brief is only as good as the pages it produces in the hands of a person with fifteen minutes, and the founder is the first stranger. The deck grows by the pages the loop makes, which is what the readout's guard rails are read against; the goal names pages, images and clips, so one of each of the latter goes through the same door.
Done when
Six to eight pages by Day 8 (stretch ten), each made by pasting the brief into a model (from src/lib/brief.ts by hand until Deploy 3, the Make tab after), pasting the answer into the sheet, adding, and reviewing on the phone against the three questions — does it respond to the first touch, does it fit 375x667, would you show it to someone — with a one-line verdict in docs/RETENTION.md's review table, about 45 min each (6 h); at least two use window.blitz.save per the docs sentence and are confirmed on the phone after Deploy 2 to keep state across BLITZ and back; one image and one clip through the same door with the brief's variants (0.75 h); every failure that teaches a brief edit is noted for a BRIEF_VERSION bump (0.75 h). Under (a) four pages; under (b) ten to twelve and no image or clip.
Founder 2
The daily bench on the phone, emptied every day
2.5 h
Why
The bench only means anything if it is emptied daily; nothing goes live unreviewed on a phone, and the median bench->live is one of the readout's numbers.
Done when
Days 2–14 the bench opened on the phone every day (bench_feed() in the SQL editor with card_set_status until Deploy 4, the strip after) and emptied — nothing older than 24 h — with a phone play before every Live and a reason on every Hide, five to fifteen minutes a day. Verified at each day's end by select max(extract(epoch from now() - created_at) / 3600) from public.card where status = 'bench' < 24 and select count(*) from card_status_log where to_status = 'live' and via = 'sql' = 0.
Founder 3
The phone check of every deploy, the FIT GATE, and the brief's versions
4 h
Why
Nothing goes live unreviewed on a phone, including the product itself; the fit tool is trusted to write verdicts only once its verdicts match the phone; the brief is a living document whose version history is read beside the pass rate.
Done when
A one-line phone verdict in docs/RETENTION.md within the hour of each of the six planned deploys (3 h; rollback is Netlify's previous deploy). Day 6 morning: the FIT GATE — the tool's Day-2 verdicts on the live deck read against the Sprint 1 phone verdicts, agreeing on every card but at most one, or the workflow does not write card.fit yet (0.25 h). Every brief edit a page's failure teaches is committed as a BRIEF_VERSION bump with the date beside it (0.75 h).
Founder 4
The makers, in order: the known makers, the makers already posting AI-made pages, the pilot's repliers, the rest of the list
7.5 h
Why
Three complete loops need twenty or so strangers who try. The pilot's repliers are a favour; people already posting model-made pages on r/vibecoding, r/ClaudeAI 'I built', Bolt and Lovable showcases, X or Bluesky already hold the page and the brief is natural to them — the single change most likely to lift non-founder uploads toward twenty (all three judges). DM only, one to one, nothing posted anywhere, nobody's page linked before a yes.
Done when
Day 5: the two or three makers the founder knows DMed the brief itself, before anything of theirs is linked (1 h). Days 5–7: ten makers already posting AI-made pages found where they post and listed in concierge.csv (who, where, date sent, reply, uploaded, live) (1.5 h). Day 8: the three-sentence 'make one' message — here is a brief, ask any model, paste it in; you will see it on a phone next and your numbers after — to those ten and to the pilot's ~30 repliers, one to one, by DM only, never a post, never where a community forbids it, the link /feed?via=make&r=<the founder's u_ id>&make=1 (2.5 h). Day 11: the second send to the rest of the pilot list only if the freeze gate passed (1 h). Replies answered the same day and any maker who asks handed their numbers (1 h); any takedown honoured the same day by Hide with reason 'takedown' (0.5 h). Verified: concierge.csv complete with dates; >= 20 non-founder uploads by Day 12 or the shortfall written down with n. Under (d) the Day-8 send waits for the Day-7 read; under (a) the hunt shrinks to five and the Day-11 send is skipped; under (b) five makers are DMed by Day 6.
Founder 5
The reads: Day 7 mid-read, the Day-10 loop row, the Day-14 readout
3 h
Why
Every number is read against a row that exists before code ships, by the person who decides the next fortnight; under (d) the Day-7 read is Sprint 1's day-21 read and selects week 2.
Done when
Day 7 before any deploy: loop_funnel(7) and bench_summary() written into docs/RETENTION.md and, under (d), Sprint 1's day-21 D1 with its interval on pilot plus wave-3 browser arrivals, with the week-2 swap named (1 h). Day 10: the loop row written before the fix day starts (0.5 h). Day 14: the readout from /stats and tools/sql/loop-readout.sql into docs/RETENTION.md with n at every funnel step and the interval on every rate, the kill criteria answered one by one, Sprint 3 named (1.5 h).
Founder 6
Fork (d) only: wave 3 of ~50 on Day 2 into the Sprint 1 build
2 h
Why
Sprint 1's gray-zone prescription is one more founder-network wave and the day-21 read; it goes into a build whose visitor path is unchanged so the read is clean.
Done when
Day 2 after Deploy 1's phone check: ~50 sends 1:1 as /feed?via=pilot&r=<uid>, nobody from waves 1 or 2 re-sent, the Sprint 1 message reused (1.5 h); replies answered (0.5 h); >= 40 browser actors with entry_source='pilot' whose first session is Days 2–3, read on Day 7 with n near 150.
Total: about 27.5 hours.
08The readout and the kill criteria
Day 14, Tue 6 Oct, from /stats (loop_funnel(14) and bench_summary() beside retention_daily(14,'make'), (14,'pilot') and (14,null)) and tools/sql/loop-readout.sql, written into docs/RETENTION.md beside the Sprint 2 baseline column and the Day-10 row, n at every funnel step and a binomial interval on every rate. Read in this order: (1) the rule — select count(*) from public.card c where c.status='live' and c.creator_id is not null and c.creator_id <> @rand's actor and not exists (select 1 from card_status_log l join actor a on a.actor_id = l.actor_id where l.card_id = c.card_id and l.to_status='live' and l.via='rpc' and a.is_admin) must be 0, and select count(*) from card_status_log where to_status='live' and via='sql' must be 0; the trigger makes both facts rather than conventions, and they are read before any number; (2) the funnel: brief copies -> make_open -> uploads -> fit ok -> live -> 10 sessions from 3 networks -> stats opened, by non-founder actors, complete loops against the >= 3 target (stretch 5) and non-founder uploads against >= 20; (3) fit pass rate on stranger pages, split by brief_version present versus absent and by made_with; (4) median hours bench->live and the oldest bench card at each day's end from card_status_log and bench_summary, with the cap reported beside the live count; (5) the guard rails carried from Sprint 1 on the same via filters — position-0 hold, reached-end, back taps — not below the Sprint 1 Day-14 row by more than the interval, with D1 by via reported with its interval and not targeted this sprint; (6) diagnostics, reported never targeted: save-channel keys on the founder's phone, uploads per brief copy, Hide reasons by kind, card_stats reads per maker, the fork's own number (a: card 0's hold from first-minute.spec and card_rank; b: whether the audition slot showed a house card each day; c: two seeds differing in production and the end card's exclude working; d: the Day-7 D1 with its interval), DAU with internal excluded, card_rank(14) for the deck (whether any founder page has 20 votes yet). Not read: any ranking number as a verdict under 20 votes, any promotion, any A/B, D7, anything a card self-reported. Demote-only stays exactly as written; the only thing this readout decides is where Sprint 3's engineering days and content hours go, and there is still no launch post until D1 holds on >= 200 browser arrivals at a day-21 read.
- Any non-founder card live in feed() without an admin card_set_status log row, or any card_status_log row with to_status='live' and via='sql', or any status change by anything but card_set_status or a hand Hide: the section-9 rule broke in code — nothing else ships until the path that skipped it is closed, and Sprint 3 starts there
- The FIT GATE fails on Day 6 (the tool disagrees with the founder's phone on two or more deck cards): the workflow does not write card.fit until it matches; the strip and my_cards say 'not checked yet' honestly and the founder's review is the only fit verdict that fortnight
- Fewer than 20 brief copies by Day 14 with both sends made: the door was never seen — the end-card 'Make one' door returns first in Sprint 3, and the sprint opens with where makers actually are, not with more supply tooling
- Twenty or more brief copies and zero complete loops: the loop is too long for a stranger — read where it drops (copy -> upload: the model's answers do not paste; upload -> fit ok: the brief is wrong; fit ok -> live: the founder's review hours; live -> 10 sessions: the deck has no traffic, a Sprint 1 problem again; 10 -> stats opened: the numbers are not found) and Sprint 3 fixes the single largest drop, nothing else
- Fit pass rate on stranger pages under 30%: the brief is wrong, not the makers — it is rewritten before another DM goes out, and BRIEF_VERSION 2's pass rate is read separately
- Median bench->live over 24 h or any bench card older than 48 h: the founder cannot review at this rate; the cap is real and honest, and Sprint 3 adds no supply channel until review hours exist — no content team, so the cap is the answer, not a hire
- A saved-state page fails to restore on the phone after BLITZ-away-and-back, or save-channel.spec.ts flakes twice: the channel ships dark the same day — the host path kept, window.blitz and the brief's save lines pulled — rather than half-remembering; a key over 4 KB accepted, or one card's state readable by another (audited on the phone and in the spec): the viewer is rolled back to Deploy 1 the same day
- Position-0 hold or reached-end below Sprint 1's Day-14 row by more than the interval, on the same via filter, after the founder's pages entered the deck: the pages the loop produces are worse than the linked sites for a stranger's first minute — the three questions become hard lines in the brief and card 0 is re-decided from card_rank, an editorial act
- The founder's own pages take over 45 minutes each through the loop: the brief is too long for a person; it is cut to a screen before the Day-8 send
- Fewer than 20 non-founder uploads with both sends made: the D1-style n problem — the loop is reported on the makers there were, the funnel with n, and no rate is spun into a verdict
- Fork (d): D1 still in the gray zone at the Day-7 read on ~150 browser arrivals — week 2 goes to the first minute (the (a) swap), because another founder wave cannot narrow it; no seed cohort, no post
- A takedown not honoured the same day, or a stranger's page found live that was never opened on a phone: the trust floor (Report / Remove it) becomes Sprint 3's first item regardless of the numbers