Skip to content

SESSION-PRIME: paste body's fenced block into a fresh Claude session to orient fast #115

Description

@TortoiseWolfe

2026-08-21 — five merged, and one theme: gates that could not fail

PR what it was
#891 #265 C13 — the .NET side owned NO schema; it inserted sequence_number = 0 and leaned on Supabase's trigger. Off that shared Postgres the 2nd message in any conversation dies on unique_sequence. Now ships its own artifact + a test that demonstrates the 23505 collision BEFORE applying the fix
#893 all twelve clause citations in AUTHORIZATION-CONTRACT.md pointed at the wrong line, two at blank lines. Cites by NAME now — line numbers into an append-only 3,535-line migration cannot be kept correct by discipline
#894 #575's exit criterion (production service-role key out of CI) had no guard and, as written, could never pass. Now a ratchet on TRIGGER, not presence
#896 #895 — the offline payment queue's auto-drain had zero callers and zero tests. Root cause was the guard order: it probed the network before checking whether anything was queued, so mounting it anywhere cost a Supabase query every 30s
#899 #559create-order claimed the idempotency key but never wrote it to the row, so idx_payment_intents_idempotency_key has never applied to a single catalog purchase

Four guards failed their own mutation tests before shipping — three by matching their own
explanatory comments rather than code. If you write a guard this session, break it on purpose
before believing it.

NEEDS THE OWNER — do not proceed on these without a decision

Purpose: this issue is the durable roadmap for TortoiseWolfe/ScriptHammer. The body below is always-current — active arcs, next 3 sessions queued, backlog. Comments are the audit trail (what shipped + what changed in the roadmap, per session). The prime prompt at the top tells a fresh Claude session to read this body first.


Prime prompt (copy from here into a fresh Claude session)

/prep

Then check this issue (TortoiseWolfe/ScriptHammer#115) — its BODY is the current roadmap. Read everything below the prime prompt: active arcs, next 3 sessions queued, backlog. The most recent COMMENT on this issue is the audit trail of what changed since the previous session.

Supplementary context (read if the body's roadmap references them or if the user's first message takes us off-roadmap):
1. /home/TurtleWolfe/.claude/projects/-home-TurtleWolfe-repos-ScriptHammer/memory/MEMORY.md — top of the index has the most-recently-written memories.
2. `git log --oneline -20` — what shipped in the last couple of sessions.
3. Open issues: `gh issue list --repo TortoiseWolfe/ScriptHammer --state open`
4. Open PRs: `gh pr list --repo TortoiseWolfe/ScriptHammer --state open`

**Staleness is measured, not asserted.** This body ends with a generation stamp recording the commit it was written against, and `~/.claude/hooks/roadmap-drift.sh` reports at session start when commits have landed since. No drift warning means this body is current. If you saw one, believe the number — and note a *failed* check says "UNKNOWN", never zero.

Operational reminders you WILL trip over otherwise:

- **PRODUCTION STILL CANNOT TAKE MONEY (#629) — but the reason CHANGED on 2026-08-16.** The Stripe account is **verified**: `details_submitted`, `charges_enabled`, `payouts_enabled` all true, `currently_due` empty. Live keys EXIST. What remains is three changes plus a ceremony, and #629's body carries a 2026-08-17 audit of exactly which are done. **Two traps live in that audit:** step 4 is ALREADY DONE (all three recurring SKUs carry live `stripe_price_id`s — following the ticket literally creates duplicate live prices), and the DB holds LIVE price ids while the app runs in TEST mode, so activating those SKUs before the key swap breaks recurring checkout for real visitors. `active=false` is the only thing preventing that.

- **THE 2018-ERA STRIPE ACCOUNT LIES TWICE IF YOU LET IT.** (1) An UNPINNED read returns `null` for every `business_profile` field — always send `Stripe-Version: 2024-06-20`. (2) `business_profile` is **PER-MODE**: reading with the TEST key returns an empty profile that looks like "nothing is configured", while the LIVE key shows the real values. Both produced false "unconfigured" reports in one session.

- **THREE REQUIRED CHECKS, NOT TWO: `Test (20.x)`, `accessibility`, AND `E2E (local) result`.** The local lane was promoted to required once #739 closed. CLAUDE.md is now correct and guarded by `scripts/__tests__/required-checks-documented.test.js` (#782). A `gh pr merge` refusing with "base branch policy prohibits the merge" while the two documented checks are green is this, not a broken rule.

- **CLOUDFLARE NOW FRONTS THE SITE (#635 fixed 2026-08-17).** HTML serves `cache-control: no-cache`, `/_next/static/*` serves `max-age=31536000`, both with `cf-ray`. Guarded post-deploy by `scripts/ci/check-cache-headers.mjs`. **Do NOT try to do the HTML half with a Cache Rule** — Browser TTL only RAISES a value, will not go below the origin's own `max-age`, and ignores such values with NO API error (0/1/300 dropped, 700/12345 applied). It needs a Response Header Transform Rule. **Cloudflare ruleset edits take ~45s to propagate**; probing immediately reads the PREVIOUS rule and manufactures convincing wrong conclusions — it produced three in one session.

- **MAIL WORKS NOW, AND IT IS PROVEN, NOT INFERRED.** `admin@scripthammer.com` receives via Cloudflare Email Routing → `jonpohlner@gmail.com` (verified by a delivered message, and again by Google's own send-as confirmation arriving). Gmail *Send mail as* is configured through `smtp.resend.com:587`. Outbound is Resend; DKIM/SPF/`send` records survived the migration. Registrar is still Squarespace (#781).

- **GITHUB'S GRAPHQL ENDPOINT FLAKES; REST DOES NOT.** On 2026-08-17 `gh pr view --json`, `gh pr merge` and `gh issue view --json` returned 503s and a spurious "Bad credentials" for an hour while `gh api` (REST) stayed 200 throughout. When `gh` behaves impossibly, switch to `gh api` before believing the error. `mergeable_state` from REST is also the cheapest way to spot a conflict — it reads `dirty` long before any check reports.

- **A PUSH DURING A GITHUB INCIDENT CAN SILENTLY NOT TRIGGER CI.** A commit sat with ZERO workflow runs and a PR that looked merely "pending" forever. `gh api repos/OWNER/REPO/actions/runs?per_page=100 --jq '[.workflow_runs[]|select(.head_sha=="<sha>")]|length'` distinguishes "still running" from "never started".

- **`docker compose restart` DOES NOT RE-READ `.env`.** Interpolation happens at container CREATION; use `docker compose up -d`. This once ran the dev server for hours against a retired anon key, producing 401s that looked like an app bug.

- **A MIGRATION FILE IS NOT A MIGRATION.** Applying schema to prod is a deliberate step via `POST /v1/projects/{ref}/database/query` with the SQL from a FILE. Prod DRIFTS from the monolithic file — verify against the LIVE DB.

- **TICKETS CAN BE WRONG ABOUT THE SYMPTOM ITSELF, not just the fix.** #497 was titled "zero `postgres_changes` events"; the same shard delivered them seconds later. #391 asserted main was red on a gate that was green. #629 told you to create prices that already existed. **Verify the symptom before accepting the diagnosis.**

- **A PROBE THAT CANNOT REPORT FAILURE PROVES NOTHING.** The most expensive recurring lesson here. Make every probe fail on purpose before trusting a pass — **and confirm the mutation actually applied**: a `sed` whose `|` delimiter collided with `||` silently did not apply, the suite went green, and that green "proved" nothing.

- **GREEN JOBS CAN MEAN NOTHING RAN**, and a gate can fail for reasons that are not your code. 24 green jobs once hid Firefox and WebKit executing ZERO tests. Separately, `E2E (local) result` failed with **0 test failures** because only 23 of 24 shards uploaded an artifact — the gate correctly refusing to score partial data. Compare counts, never green/green.

- **ANOTHER AGENT — OR THE OWNER — MAY BE WORKING THIS SAME CHECKOUT.** Before `checkout -b`: confirm `git status` and HEAD. **If a PR shows commits you did not author, STOP.** And **run `git stash list` BEFORE any `git stash pop`** — `git stash push <pathspec>` does not match untracked files, so it can create nothing, and the following `pop` then applies SOMEONE ELSE'S stash into your branch. That happened on 2026-08-17; a conflicted pop keeps the entry, so nothing was lost, but only by luck.

- **`BASE_URL=…/ScriptHammer` + `page.goto('/docs')` measures the 404 page.** Use a real root build (`DISABLE_BASE_PATH=true`, then `serve out`). That build rewrites `public/manifest.json` (#392) — never `git add -A` after one. **`git push` REBUILDS `out/` with the default basePath**, silently invalidating a root build you are serving.

- **Canvas `fillStyle` DROPS THE ALPHA on a dimmed colour**, and `getComputedStyle` returns `oklch()` unparsed — read colours through a canvas.

- **Tailwind scans `tests/`**, so a spec's own string literals make Tailwind emit those classes (#396).

- Docker-first. Never `pnpm install`/`npx` on the host, never sudo. Commit from INSIDE the container so husky/lint-staged/gitleaks run; push from the host. **Builds go in their OWN container: `docker compose run --rm builder pnpm build`.**

- **`git push` RUNS A FULL CI GATE** (`.husky/pre-push`) and takes MINUTES. Never two at once; never `git push … | tail`.

- **CAPTCHA IS LIVE ON PROD (#353) and gates SIGN-IN AND RECOVERY.** E2E is unaffected — it authenticates with the SERVICE-ROLE key, which GoTrue exempts. Do NOT "fix" a spec by typing into the real form.

- **scripthammer.com RESOLUTION IS FLAKY ON THIS NETWORK, AND IT MASQUERADES AS BROKEN PRODUCTION.** `ERR_NAME_NOT_RESOLVED`/`NXDOMAIN` on every asset = **local DNS**, the site is fine; 404s on content-hashed CSS = a real retention bug. Measured 2026-08-17: Windows DNS points at the Belkin router (`192.168.2.1`), which took >300ms on 7 of 30 lookups while a public resolver failed none. Windows has **no global IPv6 address**, so IPv6 is not provisioned rather than broken. Cross-check with DoH before ever saying "prod is down". **Never persist an `/etc/hosts` entry** — the owner objected in the strongest terms and was right: it hides the real failure.

- **PUBLIC VALUES BELONG IN VARIABLES, NOT SECRETS (#787).** 21 of 24 variables had a same-named secret shadowing them; 20 were deleted 2026-08-17 (33 → 13). A write-only copy of a value that ships in the bundle buys nothing and cost an outage — the deploy read `vars.*` while the operator set `gh secret set`.

- **The FORK (RescueDogs → RaisedPaws) needs our non-3D fixes** → RescueDogs#56. NEVER port Cesium/Three.js. Always pass `--repo`.

- **`gh pr edit` is broken in this repo (#397)** — use `gh api -X PATCH repos/OWNER/REPO/pulls/N -F body=@body.md`. `gh issue edit` is fine.

- **`allow_auto_merge` is ON**; merge queue is unavailable (user-owned repo). Use `gh pr merge --auto --squash` — or REST when GraphQL is flaking.

Then either start on the roadmap's "next session should" item, or ask the user if they want to deviate.

Stop reading here when priming — everything below is the roadmap


Roadmap

Body regenerated 2026-08-22 (evening) against 992a1d58. #758 is CLOSED — not the ordering source.

#945 MERGED at 992a1d58 (2026-08-22 ~17:05Z) — 24/24 shards, all seven required green.
#944 is closed. The stamp below is against origin/main, not HEAD, which stays the right call.

🚨 Owner-only blockers (8, and this is now the binding constraint)

The unblocked backlog is thinning faster than the owner-decision queue. Four of these are new
since yesterday.

#629 — the money steps only: sk_live_ + pk_live_ + PAYMENT_MODE_EXPECTED=live +
activate the 3 SKUs last + one real card. #621 — rotate three credentials with no API.
#714 — 3D Warehouse licensing, inherited by every fork. #769 — receipt address,
MCC 5734→7372.

NEW: #393 — the CSP now ships as a real header in report-only; flipping it to
enforcing is a dashboard change and an acceptance of risk. #822 — DMARC is p=none, so the
domain that sends payment receipts can be spoofed. #462 — 181 /80 and /85 text
instances: needs a policy call (ban opacity for de-emphasis, or audit 94 files per surface),
not a sweep. #442 — the privacy policy must disclose Cloudflare Turnstile; that is legal
copy an agent should not author.


The arc that closed: CI reliability

Promoted on 2026-08-17 because every PR that day needed a re-run and not once for a failing
test
. It is done. Since then 14 PRs landed, every one green on its first attempt, with a
single re-run spent all session
— and that one was a genuine Azure apt-mirror outage, not a
defect.

Every fix up to 2026-08-18 hardened the install; it stayed fallible, so the arc stayed open.
#762 and #798 (a wall-clock timeout cannot tell slow from stalled — bound the silence instead),
#801/#802 (cache the apt debs; 17.7m → 1.0m), #809 (the idle bound, whose catch was then
demonstrated for real during the mirror outage), #766 (upload the traces the lane already pays to
capture), #726 (the budget guard can now see which backend it meters). Each lowered the odds or
the cost of a failure. None removed it: 24 network fetches per run, every one able to go dark on
its own.

2026-08-19 closed the arc by deleting the operation (#829, PRs #830 + #833) — a different kind
of change, and the reason this section moves from "active" to "closed" rather than just getting a
better number. There is no fetch to retry, no mirror to be down, no dpkg lock to orphan, no retry
loop to fall through. The failure class is unreachable rather than damped, which is what makes it
finishable at all.

The clearest illustration is a branch deleted the same night. fix/795-install-failure-is-loud
(412fc86c, never merged) made a failed install fail its job loudly at all ten sites — correct
work, and instantly moot: nothing can fall through a step that no longer exists.

No workflow installs browsers on a runner any more — every lane runs from
mcr.microsoft.com/playwright:v1.55.0-noble, which already ships them. The payoff is measured on
two consecutive commits of main: 6a4e8f57 (installs present) failed 8 jobs, every one in
Install Playwright, with zero test failures
; d45cfdc2 ran 26 jobs green, 0 install steps.
Before, each shard spent a median 11.6 min installing — ~158 runner-minutes per run across 24
independent network fetches.

Three of the four lanes are PROVEN: e2e-local 26/26 across chromium/firefox/webkit,
signup-mailer green with a real Mailpit round-trip, and smoke green against live
production
. e2e.yml's seven sites are migrated but unexercised — the cloud-quota guard
skips every downstream job until the window reopens (~Sept 2). Treat it as unproven.

#795 and #819 closed against that evidence rather than being fixed.

The recurring theme, again: controls whose presence was asserted and whose effect never was
— a CSP delivered as <meta name> and therefore inert, an email-health gate stuck in
annotate, a keepalive listing two projects that could never answer, a guard reading an env var
nothing passed.


The arc that opened and mostly closed in one night: gates that could not fail (#396)

Started 2026-08-19 as routine backlog work and turned into the most productive thread of the
session. Nine PRs, and the mechanism matters more than the count.

#846 added a reporter that names any test finishing with zero assertions. #847 fixed it,
because the first version printed nothing in CI: playwright test --reporter=X REPLACES the
config's reporter array rather than appending, so it ran in the two lanes that do not override
and in none of the 25 required-lane shards. That is the catalogue's own pattern, committed by
the tool built to detect it, and it was caught only by grepping a real run for the reporter's
own output.

Its first real output named 31 tests. Triage is at 15, recorded in #850, and the split
is the point: roughly half are not defects. checkA11y() throws instead of calling expect,
so four names assert without a countable step; one says "if present" in its own title. What
the other half found:

what where
a live 139x40px submit button — the gate only ever looked at / #848
expect(alt).toBeDefined()getAttribute returns null, and null IS defined #849
the tables gate visited the blog INDEX; zero <table> on any public route #851
a test whose body only called console.warn; another that asserted nothing at all #852
button[aria-label="Navigation menu"] — the hamburger is a <label>, so the whole body never ran #853

Also closed in the same thread: validate:breakpoints ran in CI and could not fail (#841 — a
lost coverage floor was a warning, and the exit code keys off errors), and the required lane's
own header still called itself a PROTOTYPE (#840).

The rule that came out of it: measure what a selector finds on the route the test visits,
BEFORE reading the test. That turned eight candidates into two defects and six acquittals in a
single pass.


The arc that opened 2026-08-22: the fork path, proven by forking it

This did not exist yesterday because nobody had forked the template end-to-end before. Doing it
for real — TortoiseWolfe/geolarp, from rebrand.sh to a live site on its own domain with its own
Supabase — surfaced ten defects in the one workflow this repo exists to provide. They are not
cosmetic and they are not hypothetical; each was found by the fork failing, not by reading code.

The keystone is #922. rebrand.sh discovers files with find plus an extension allowlist.
That single choice is upstream of most of the rest: the allowlist excludes .mjs/.cjs, and the
comment in the script explains it cannot widen safely because find has no notion of what git
tracks (adding three suffixes pulls in 1,746 files, 1,581 of them in caches and a vendored
virtualenv). Its own comment already proposes git ls-files. Fix that and the widening becomes
safe, which is what #939 and part of #926 actually need.

That comment also judged the residual .mjs gap "cosmetic — ~8 doc strings". #939 is the
counter-example, and it is the most serious item here:
a fork's service worker stamped
scripthammer- caches while purging geolarp- ones, so every deploy left caches that could never
be collected. Live on geolarp.com. Unbounded storage growth in visitors' browsers.

#922 findgit ls-filesthe keystone
#939 fork service workers never purge their caches (fixed downstream, latent here)
#926 brand token rewritten inside GitHub URLs → 404s (#937 closed as its duplicate)
#933 case variants survive, so every fork's intro post is still titled Scripthammer …
#936 every fork republishes this template's blog under its own name
#938 deleting or editing a blog post is a silent no-op
#923 DESCRIPTION applied to package.json only
#931 dev server rewrites tracked manifest.json/robots.txt — caught 3× in one session
#932 the container crashloops in a git worktree, the workflow CLAUDE.md recommends

PROGRESS 2026-08-22 (evening). Three of these are fixed and in review, in the order the
keystone argument predicted:

#922 findgit ls-files PR #946, 6/7, queued on E2E (local)
#939 derived cache prefix PR #947, 6/7 — and #922 had already killed the live bug, so this is hardening; the ticket's severity was revised rather than left overstated
#931 manifest fossil PR #948, 6/7 — the reported dirt was the smaller defect; the test's cleanup ran git checkout -- and silently discarded uncommitted work
#937 closed as a duplicate of #926, with its extra evidence folded into #926's body rather than lost

Two NEW issues came out of the same work, both about the fork/parent split:

geoLARP is now a live proving ground rather than a hypothetical. Every defect above came from
it; several are already fixed downstream with mutation-tested guards that can be ported straight
back (#929 was, as #930). Expect the next fork-shaped defect to be found the same way.


Active arc — #559, the payment-write lockdown (UNBLOCKED 2026-08-19)

Corrected the same day it was written: this issue was listed as blocked, and it is not. Both
stated blockers (#556, #558) are closed and Phase 2b shipped as #560.

T024 is already done — the recurring-price allowlist landed as #772 (Stripe) and #774
(PayPal); both refuse a client-sent identifier outright and resolve from the catalog.

Remaining, and ordering is load-bearing:

  • T025 — migrate three client write sites behind create-order:
    payment-service.ts:179 (insert), :371 (upsert), offline-queue/payment-adapter.ts:166
    (upsert). The queue drain is the awkward one — it replays with no live user gesture,
    so create-order needs an idempotency-keyed path. Do not discover this at the end.
  • T026 — re-enumerate .from('payment_intents'); confirm zero writes remain.
  • T027 — drop the client INSERT policy and INSERT from the GRANT. UPDATE is
    already revoked (Of the three 050-audit findings, only the dead UPDATE grant was still real — now fixed and generalised into a cross-check #565), so this is one privilege, not two.
  • T028 — E2E: browser INSERT refused with a pinned status; off-catalog price 4xx.

T027 must not run before T026 passes, or dropping the grant with an unmigrated write site
breaks payments silently rather than loudly.

Multi-session. The other unblocked items below are smaller and can interleave.

The other constraint is the owner-decision queue above, which now outnumbers the unblocked
engineering work.


Next 3 sessions queued

Session +1: #950 — stop e2e-local gating every merge

  • Why: it is the required check, it is queue-bound, and three PRs sat at 6/7 for over an hour
    today waiting on it. Highest leverage available: e2e.yml already gates firefox/webkit behind
    a full-e2e label, and applying the same gate here removes 16 of 26 jobs from the common path
    at no backend cost, because the local lane has no shared backend to protect.
  • First action: read .github/workflows/e2e-local.yml:25-45 before changing anything. The header
    argues its shape deliberately and the argument is right — the caps in e2e.yml exist to protect
    one shared cloud project. Do NOT copy those. The lever that applies here is the label gate,
    which is about latency, not contention, and the header does not address it.

Session +2: the rest of the fork arc — #926, #933, #936, #938, #923

Session +3: #850 — finish triaging the 31 zero-assertion tests

  • Why: carried forward untouched across three sessions. Still the best yield-per-hour available;
    16 remain and the reporter cannot become a GATE until the list is empty.
  • First action: measure what each remaining selector finds on the route its test visits, before
    reading the test.

Standing p1, not displaced: #559 T025–T028, the payment-write lockdown. It has not moved in
three sessions. That is worth noticing rather than repeating — if it keeps not moving, it should
be re-scoped or explicitly parked, not silently carried.


Backlog (no scheduled position)


Next session should

Take #950. e2e-local is a required check that is now queue-bound, so it gates every merge in
the repo — three PRs were stuck at 6/7 behind it today. The lever already exists in the other
lane: gate firefox and webkit behind a full-e2e label, chromium-only on PRs.

State at hand-off: main is green at 992a1d58 (#945 merged). PRs #946, #947 and #948 are
open, each 6 of 7 required green, all waiting on the same E2E (local) result.
Nothing has
failed. A local merge sequence was running them; if it is not still alive, merge them by hand
after verifying 7/7 — they are independent and touch no overlapping files.

Cautions carried forward, all learned by being wrong first:

  1. gh run view <id> --log truncates, and sometimes returns zero bytes. Fetch per job via
    gh api repos/OWNER/REPO/actions/jobs/<jid>/logs, or reproduce locally.
  2. Never put a closing keyword next to an issue number in a PR body, even to deny it.
  3. A probe that reports success is not evidence of success. Three written on 2026-08-22 were
    wrong in the same direction: one counted an unreported check as passing, one printed "all 4
    gates green" while listing a failure, and one fell through a timeout silently so nothing merged
    and nothing said so. Make every probe fail on purpose before trusting a pass, and verify a
    gate independently before merging on it.
  4. Run what CI runs. pnpm test:coverage is ~4,700 tests; a blog-subset run passed locally and
    failed CI. And never put a formatter and a test run in the same command — three false failures
    came from prettier rewriting files while tests read them.
  5. Measure before attributing. "geoLARP's E2E is starving the runners" was asserted from job
    counts and was backwards: ScriptHammer's own e2e-local is the 89-minute, 24-shard lane.

Why this exists

Sessions end. The next session starts cold. Without a single canonical "what was the world like when we last stopped + here's what to do next" pointer, the new model wastes context wandering. This issue is the one URL to paste into a fresh chat. The BODY carries the plan; comments form the audit trail.

How to maintain

Run /session-prime at session-end. The skill rewrites the body to reflect what changed in the roadmap and appends a comment with the audit trail.

If the prime prompt itself becomes stale (memory file renamed, project changed shape), edit the body's prime-prompt block directly.

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationImprovements or additions to documentationnext-sessionRolling priming prompt for new sessionspriority:p3Low — defer (heavy work, polish, nice-to-haves)

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions