Runbook: harness upgrades and version-ramped checks
What to do when a gate prints a NOTE — … (ramp: …) line after a harness
update. Short version: nothing broke, a NEW check is running in advisory mode
because your project's seeded content predates it; you sweep, then graduate —
deliberately, by hand.
Two versions in .harness/manifest.json
Section titled “Two versions in .harness/manifest.json”harnessVersion— the installer release that last ran against this tree.updatealways advances it.baseVersion— the release vintage of the SEEDED starting content this tree actually carries.initstamps it equal toharnessVersion;updatepreserves it (owned gate scripts refresh, but your seeded exemplars, docs lists, and locally-tuned surfaces do not), so it only moves when a human moves it.
What a ramp NOTE means
Section titled “What a ramp NOTE means”Gates never ambush an update: a check added in a newer release than your
baseVersion runs NOTE-only (rampNote in tools/lib/gate.mjs). The line
<gate>: NOTE — <check> (ramp: live from baseVersion X.Y.Z; this install's baseVersion is A.B.C; expires in E.F.G). …says: the check executed, found what it found, and withheld the red. On a FRESH install the same check hard-fails — projects grow into gates; fresh scaffolds start already grown.
RAMPS EXPIRE (0.3.0) — expires in, and what happens when you reach it
Section titled “RAMPS EXPIRE (0.3.0) — expires in, and what happens when you reach it”Before 0.3.0 a ramp had no deadline, which meant "shipped ramped" meant "shipped
disabled, indefinitely": the check printed an advisory NOTE — in CI too — and the
only thing that ever re-armed it was a human running graduate, which nothing
nagged. A control whose expiry date is optional has no expiry date.
Every ramp now carries an until, and it is measured against harnessVersion,
not baseVersion. That distinction is the whole mechanism: baseVersion only moves
when the ramp's own beneficiary graduates, so a deadline measured against it is a
deadline you hold open by never graduating. harnessVersion advances on every
update.
When you reach the deadline the NOTE becomes a FAIL, and the line above it says so:
<gate>: RAMP EXPIRED — <check> was ramped from baseVersion X.Y.Z with a deadline of E.F.G,and this install runs harness E.F.G. The escape is over: the finding below is a hard failure now.One remediation path, printed by every layer. Whether you hit this through
pnpm validate, a red Stop block, doctor, or update, the answer is the same
three steps: sweep the finding, then graduate, then re-run validate. There is no
flag that extends a deadline — extending one is a harness release, deliberately.
A dormant install jumping several versions meets several deadlines at once, and
that is the designed outcome rather than an accident: the alternative is an install
that skipped four releases and still reports every one of their checks as advisory.
Upgrade one minor at a time (update, sweep, graduate, repeat) if the pile is
large — each graduate is cheap and each one shrinks the next.
0.4.0 IS THE ALARM — read this before you upgrade
Section titled “0.4.0 IS THE ALARM — read this before you upgrade”0.3.0 shipped the clock: every pre-existing ramp was dated 0.4.0, every ramp it
introduced was dated 0.5.0, and nothing reds on a deadline in that release. 0.4.0 is
the first release where a deadline arrives.
Who this affects, exactly. Every expiring ramp opens at minVersion 0.2.0, and
rampNote is inert once baseVersion >= minVersion. So the affected population is
installs whose baseVersion is below 0.2.0 — among released vintages, only 0.1.3.
your baseVersion |
what update to 0.4.0 does |
|---|---|
0.1.3 |
12 escapes close at once. The checks below stop withholding their findings. Sweep, then graduate. |
0.2.0, 0.2.1, 0.3.0 |
Nothing expires. Those checks have been live on your install all along — you are already past them. |
fresh init at 0.4.0 |
Nothing ever ramped. Every check has been strict since your first run. |
Do not take the count on faith and do not take it from these notes:
node tools/check-gate-integrity.mjs # prints your baseVersionpnpm validate 2>&1 | grep 'RAMP EXPIRED' # the findings that just went hard, on YOUR treeMost installs at 0.1.3 will see far fewer than 12, and the difference is not optimism: a gate calls the ramp only when it actually has a finding to withhold, so a deadline you meet fires only if the finding also exists on your tree. Nine of the twelve sites are adoption seams that fire only when the surface is genuinely absent, so an install that has been pulling seeded content along the way meets almost none.
Measured, not estimated — the reference scaffold taken from v0.1.3 straight to 0.4.0
(scripts/ci/upgrade-lane.sh --from v0.1.3, the release's own proof) reds six gates:
db-limits gate-integrity query-shapes rate-limits security-headers tenancyand three more — migrations, prompts, schema-rls — meet the deadline but stay
silent, because that tree carries nothing for them to report. Yours will differ. Run the
grep.
If the pile is large, do not fight it head-on. Upgrade one minor at a time — update
to 0.2.0, sweep, graduate, then 0.3.0, then 0.4.0. Each graduate moves baseVersion
forward, and every ramp at or below it goes inert, so each step shrinks the next. Jumping
straight from 0.1.3 to 0.4.0 is the one path that meets all twelve simultaneously.
The twelve, and the cheapest sweep for each
Section titled “The twelve, and the cheapest sweep for each”Grouped by what the finding actually is. A: the surface is missing — the fix is to
adopt it, and update --refresh-seeded <path> does most of the work. B: the surface is
there and something in it is wrong — a real fix. C: applied history — cannot be
edited; see the escape.
A — adoption seams (the surface is absent)
Section titled “A — adoption seams (the surface is absent)”| Gate | The finding | Sweep |
|---|---|---|
tenancy |
no tenant column in any migration | Follow docs/runbooks/tenancy-adoption.md. The spine is a migration you write; there is no file to pull. |
tenancy |
no audit.events table |
Write a new migration. Do NOT --refresh-seeded a migration — supabase/ is seeded because a migration is applied history, and planting one describes DDL your database has not run. docs/adr/20260202-audit-trail.md carries the required schema and trigger shape; copy from it deliberately. |
db-limits |
no ALTER ROLE … SET in any migration |
Same rule — a new migration, from docs/adr/20260203-resource-limits.md. Then reconcile tools/db-limits.json to what you actually applied; the gate compares the two by value. |
db-limits |
no org_usage table |
Same ADR — the quota trigger pair. AFTER INSERT … FOR EACH STATEMENT, never FOR EACH ROW, and never a RESTRICTIVE policy over a STABLE count (it fails OPEN). |
rate-limits |
tools/rate-limit-budget.json missing |
update --refresh-seeded tools/rate-limit-budget.json, then reconcile it against your own procedures — a budget that names actions you do not have reds for a different reason. |
security-headers |
apps/web/lib/security-headers.ts missing |
update --refresh-seeded apps/web/lib/security-headers.ts plus tools/security-headers.json. The gate asserts the module BY VALUE against the JSON, so pull both or neither. |
query-shapes |
no src/data/query-probes.ts in any vertical |
update --refresh-seeded packages/verticals/<name>/src/data/query-probes.ts for the exemplar shape, rewrite it to drive YOUR data functions, then pnpm gen. Order matters: the manifest is a recording of what your DAL executed, so generate it from your own probes — never pull tools/generated/query-shapes.json, whose regen-diff against a different DAL can never converge. |
prompts-lock |
.claude/{agents,commands,skills} not covered by the lock |
One command: HARNESS_ALLOW_SELF_EDIT=1 node tools/gen-agents-lock.mjs --write. Read the diff before committing — you are signing off on the instructions your agent runs under. |
gate-integrity |
.claude/rules/ and .claude/statusline.mjs not hashed |
The manifest gained hash coverage of these in 0.2.0. update re-records them; if the gate still reds, a file has been hand-edited since — review that diff, then re-run update. |
B — real findings (the surface is present and wrong)
Section titled “B — real findings (the surface is present and wrong)”| Gate | The finding | Sweep |
|---|---|---|
query-shapes |
index-service and boundedness over the generated manifest | Each finding names a query and what it lacks. The usual two: a list with no unconditional LIMIT, and an owner index that carries the filter but not the ORDER BY. packages/verticals/notes is the worked pattern. |
rls-manifest |
correlated policy predicates, SECURITY DEFINER discipline |
Replace auth.uid() with (SELECT auth.uid()) in policy predicates — the scalar sub-select the planner hoists to one evaluation per statement instead of one per row. Definer functions need an entry in tools/security-definer-allow.json or a SET search_path. |
These two are the ones worth the time. Neither is cosmetic: an unbounded list is a
denial-of-service you ship, and a per-row auth.uid() is why a table gets slow at exactly
the moment it gets popular.
C — applied history (migrations)
Section titled “C — applied history (migrations)”The migrations ramp covers two 0.2.0 rules: authorization-destructive DDL needs an
-- adr: reference, and ACCESS EXCLUSIVE needs a SET lock_timeout = '3s'; preamble.
If the finding is on a migration you have not committed yet, just fix it in the file.
If it is on applied history, you cannot: both remedies live inside the migration, and
the append-only rule reds any edit to a committed one. Editing is also pointless — a lock
preamble on a migration that ran last quarter governs a lock already released. Record the
acknowledgement instead, in tools/migrations-allow.json:
{ "allow": [ { "file": "20260114093000_add_archived_at.sql", "rule": "lock-timeout", "reason": "Applied to production 2026-01-14 during the maintenance window; the lock was taken and released then. The file cannot be edited (append-only) and a preamble now would govern nothing." } ]}rule is "lock-timeout" or "authz-adr". The gate refuses the entry if the migration
does not already exist at the diff base — so this covers history, never a migration you
are writing now — and reds a stale entry whose finding is gone. It is an escape list:
commit it, so the widening lands in the PR diff under CODEOWNERS.
Then graduate
Section titled “Then graduate”Once pnpm validate is green, npx next-expo-supabase-agent-harness@latest graduate advances
baseVersion to 0.4.0 and prints the failing gate with its detail bullets if anything
still holds it back. Re-run validate: the NOTEs are gone and the checks are live.
0.5.0 — THE SECOND ALARM, and it reaches further than the first
Section titled “0.5.0 — THE SECOND ALARM, and it reaches further than the first”0.4.0's expiring escapes all opened at minVersion 0.2.0, so only baseVersion 0.1.3
met a deadline. 0.5.0's eight open at 0.3.0 and 0.4.0, so the population is every
released vintage below 0.4.0.
your baseVersion |
what update to 0.5.0 does |
|---|---|
0.1.3 |
Everything 0.4.0 closed is still closed, plus these eight. If you are still here, upgrade one minor at a time rather than head-on. |
0.2.0, 0.2.1 |
8 escapes close. All eight, since every one of them opens at 0.3.0 or 0.4.0. |
0.3.0 |
2 escapes close — diff-coverage's per-file floors on the surface 0.4.0 added, and wiring's web a11y plugin seam. The other six opened at 0.3.0 and have been live on your install all along. |
0.4.0 |
No ramp expires. It is the one released vintage whose escapes this release leaves alone — but see the security floor below, which reds every vintage. |
fresh init at 0.5.0 |
Nothing ever ramped. |
Expect version-sync to red on the security floor, whatever your baseline
Section titled “Expect version-sync to red on the security floor, whatever your baseline”This one is not a ramp and no vintage is exempt. tools/framework-floor.json is
harness-owned, so update refreshes it into your install — that is the point, a new
advisory has to reach trees that already exist. pnpm-workspace.yaml is seeded, so
update deliberately does not touch your pins. The result is that the first pnpm validate
after upgrading reports any catalog pin now sitting below the reviewed floor, naming the
package, your resolved version, the floor and the advisory ids.
For 0.5.0 that is next, which moves to 16.2.11 (or 15.5.21 if you are on the 15
line — the floor is keyed by major, so a patched older line is left where it is). Apply it
the way the failure says:
# raise the pin in the pnpm-workspace.yaml catalog, thenpnpm install && git add pnpm-lock.yaml pnpm-workspace.yamlpnpm validateThere is no flag that lowers the floor. tools/framework-floor.json is sha-pinned by
gate-integrity, so editing it down reds step 2 instead of step 11.
Same rule as last time — do not take the count from these notes:
pnpm validate 2>&1 | grep 'RAMP EXPIRED' # the findings that just went hard, on YOUR treeThe population above is not prose either: template/migrations.json's 0.5.0.rampExpiry
record states it as data, and scripts/check-ramp-ledger.mjs reds if it disagrees with what
the shipped call sites actually compute.
The eight, and the cheapest sweep for each
Section titled “The eight, and the cheapest sweep for each”| Gate | The finding | Sweep |
|---|---|---|
wiring |
eslint-plugin-jsx-a11y is not a declared dependency, so eslint.config.mjs omits the apps/web accessibility block and the web half of the a11y floor runs nothing |
This is the one expiry whose remedy the harness owes you, and 0.5.0 delivers it: update parks a dependencyObligations record at .harness/pending/dependencies.json and doctor reds until it is met. Add the pin to your pnpm-workspace.yaml catalog and the devDependency to package.json exactly as the parked file states, run pnpm install, and commit pnpm-lock.yaml. The obligation file deletes itself on the next update. |
wiring |
CODEOWNERS does not cover the enforcement surface | The whole gate was ramped for one release. Each finding names a path and the owner it lacks; {{SECURITY_OWNERS}} is the seeded answer. A retrofit that deliberately kept a different posture is a real decision — make it explicitly rather than by expiry. |
diff-coverage |
per-file floors on apps/web/lib and the layered packages/*/*/src |
Write the tests. These files were never held to a floor before 0.4.0 and now are; apps/web/__tests__/ ships six seed suites (seedOnInitOnly) you can pull as shape references with update --refresh-seeded, but the coverage has to come from tests over YOUR modules. |
gate-integrity |
the enforcement CONFIGS are not hash-covered, and the threshold-bearing configs are dirty | update re-records the hashes. If it still reds afterwards, a covered file has been hand-edited since — read that diff, then re-run update. For the commit-not-dirty half: commit the config change, which is the whole point (a widened threshold belongs in a PR under CODEOWNERS, not in a working tree at gate time). |
docs-sync |
AGENTS.md's gate list drifted after an injected chain step |
Paste the gate names the NOTE prints into AGENTS.md's "The N gates, in order:" sentence and its "N-step chain" line. The ramp only ever covered ADDITIVE drift — a documented step that no longer exists, or a reordering, has always been a hard red. |
docs-sync |
the approved-tools registry and docs/security/approved-tools.md disagree |
Reconcile the doc against tools/approved-tools.json (and .claude/settings.json). Adding an MCP server is granting reach; the three-corner lockstep is what stops one corner granting it quietly. |
docs-sync |
the doctrine token map names a symbol its module no longer contains | Update tools/doctrine-symbols.json in the same commit as the rename. A map that outlives its module is a second, stale doctrine. |
docs-sync |
docs/harness/enforcement-tiers.md's shape — a Compensated by naming a control that is neither a chain step nor a CI job |
Name a live one, or write — and raise the row's Target. 0.5.0 also makes Target itself a control, so a row whose Target has arrived must have closed its gap or moved the date in a reviewed diff. |
Nothing was deleted to make this release green. All eight rampNote wrappers stay in
the tree; they expire by version comparison, which is the mechanism working rather than
being removed. And there is still no flag that extends a deadline — as of this release that
sentence is enforced: scripts/check-ramp-ledger.mjs compares every until against the
previous release TAG's tree and reds on any date that moved later, unless a rampExtensions
record in template/migrations.json names the file, the escape, the versions, and the
reason. (0.6.0 corrected how a site is IDENTIFIED across releases — see its section below.
Through 0.5.0 the comparison keyed on minVersion, so re-opening a ramp changed the key and
moved the deadline unseen.)
Then graduate
Section titled “Then graduate”npx next-expo-supabase-agent-harness@latest graduate advances baseVersion to 0.5.0 once
pnpm validate is green.
0.6.0 — NOTHING NEW EXPIRES, and one deadline moves LATER
Section titled “0.6.0 — NOTHING NEW EXPIRES, and one deadline moves LATER”Read this one if you are upgrading to 0.6.0. Nothing newly expires here. The
release opens seven ramps — auth-posture, data-flow, reviewer-verdicts, the
web half of route-manifest, the browser lane's authenticated-render axis, the
schema-rls policy→grant closure, and a re-opened docs-sync — and all seven fall due
at 0.7.0, so every alarm you meet crossing this release is one you already owed.
Two of the seven are worth naming here because their subject is content you must author, not a switch you flip:
schema-rls's policy→grant closure. EveryCREATE POLICYneeds a matching tableGRANTbehind it, because PostgreSQL checks privileges before row security — a policy naming a role that holds nothing never runs. It works on your project today only because Supabase's default privileges grantedanon/authenticated/service_roleon every new table inpublic, and those defaults stop applying to projects created on or after 2026-10-30. The NOTE prints the exactGRANTstatement for each finding; put them in a new migration. Do this before that date, not before 0.7.0.- The browser lane's authenticated-render axis.
updatedoes not hand you the seededauthenticated.spec.ts, deliberately: its assertions name this template's routes and test ids, and planting it would red your lane about your own app. Write one against your routes — sign in through the form, thenpage.reload()and assert a protected page still renders. That reload is the whole point; a client-side navigation renders from state the tab already holds.
One deadline moves later, and it is recorded rather than quiet. docs-sync's
AGENTS.md gate-list ramp expired at 0.5.0. This release injects a new chain step
(auth-posture) into your tools/harness.config.mjs, which takes your chain to 32
steps while your AGENTS.md still documents 31 — and AGENTS.md is seeded, so
update will not rewrite your project memory and only you can fix it. Redding you
for that on an upgrade you did not ask for is the ambush the ramp mechanism exists to
prevent, so the escape re-opens at 0.7.0 and the NOTE prints the exact list of
gate names to paste. template/migrations.json's 0.6.0.rampExtensions records the
move, the reason, and the escape it applies to.
What that means for you depends only on where you are coming from:
Your baseVersion |
What 0.6.0 does to you |
|---|---|
| 0.4.0 or 0.5.0 | Nothing expires. Seven advisory NOTEs. update, sweep them, graduate. |
| 0.3.0 | You meet the two 0.5.0 deadlines you have not met yet (diff-coverage, wiring) — on the way through, not because of this release. Follow the 0.5.0 section above. |
| 0.2.0 / 0.2.1 | Seven of the 0.5.0 section's eight. The AGENTS.md gate-list one is the extension above: it is a NOTE now and a red at 0.7.0. |
| 0.1.3 | Nineteen. Follow 0.4.0's section, then 0.5.0's. |
If you skipped 0.5.0, its section above is still your section. A deadline is
measured against harnessVersion, and update advances that to 0.6.0 in one step
regardless of how many releases you crossed — so skipping a release does not skip its
alarms, it batches them. This is the case the "upgrade one minor at a time" advice
at the top of this file exists for: each graduate makes every ramp at or below it
inert, so each step shrinks the next.
The honest count is still the command, never this table:
pnpm validate 2>&1 | grep 'RAMP EXPIRED'Several of the twenty-six sites are adoption seams that fire only when the surface is genuinely absent from your tree, so a list written in prose will always over-state what YOUR install meets. (The count in that sentence was "nine of twenty-two" through 0.6.0 and had been wrong since the fleet grew — which is the argument for the command, not for a better-maintained number.)
Sweeping the web route seam has a second half, and it is yours
Section titled “Sweeping the web route seam has a second half, and it is yours”The web half of route-manifest is the one adoption seam in this release that update
cannot finish for you, and the reason is worth understanding rather than working around.
update delivers the new files — apps/web/lib/routes.generated.ts, a page.meta.ts
beside each page, not-found.tsx, the lib/i18n/ seam. It does not touch your page
bodies, because those are yours.
But a page.meta.ts declares state test ids, and the gate requires the page to
render them: a declared-but-unrendered state is a claim nothing checks. So adopting
the meta file alone leaves a finding the meta file itself created. For each page you
adopt, render its ids in that page:
import { meta } from './page.meta'// …<Card data-testid={meta.states.empty}>…</Card>data-testid={meta.states.<key>} rather than the literal string is the form that cannot
drift — the declaration and the render read the same value.
If a state genuinely cannot occur on a route, declare it null with a reason in
tools/web-route-allowlist.json unreachableStates instead. The shipped orgs route is
the worked example of that judgement in the other direction: resolveOrgs() returns an
empty list rather than throwing, so the route has no error branch to put a test id on.
Adopting apps/web/lib/i18n/ adds one accepted clone — add the entry with it
Section titled “Adopting apps/web/lib/i18n/ adds one accepted clone — add the entry with it”The web and mobile catalogs each declare the same eight-line PluralMessage interface (the
CLDR categories Intl.PluralRules selects between). Both resolve() implementations need
it, and it cannot be extracted: every shared package source root is seeded, so update
can never deliver a new export into an existing install — extracting the type would compile
on a fresh scaffold and break types on yours.
So the moment you adopt the web seam, duplication reports one clone. It is accepted in the
shipped tools/duplication-allow.json, but that file is yours — update will not touch
it, because it also holds the clones you have accepted. Add the entry by hand; the gate
prints the fingerprint you need:
pnpm exec node tools/check-duplication.mjs{ "fingerprint": "e83e21400fb2", "reason": "mobile/web i18n catalogs — the PluralMessage type preamble; see docs/runbooks/harness-upgrade.md" }This is a Stop-chain step, not one of the 33 chain gates, so pnpm validate alone will
not show it — it appears when your turn tries to end.
THE ONE THAT IS A BUG FIX, NOT AN ADOPTION: your web sign-in is broken
Section titled “THE ONE THAT IS A BUG FIX, NOT AN ADOPTION: your web sign-in is broken”Every other item on this page is a new surface you are choosing to adopt. This one is
different: your install carries a functional defect that 0.6.0 fixed, and update
cannot hand you the fix.
The seeded browser Supabase client was constructed without a storage, so
@supabase/supabase-js persisted the session to localStorage — while every server
render reads the cookie jar. localStorage is never sent with a request. So a
correct sign-in succeeds, the server sees no session, and the protected route redirects
straight back to /sign-in: a sign-in loop, on the shipped scaffold, invisible to any
test that stops at "the credentials were accepted". The same wave removed four comments
claiming httpOnly on a cookie a browser-side sign-in cannot set it on — a user agent
ignores that attribute on a document.cookie write, so those comments named a control
that was never there.
apps/web and packages/platform/* are yours — update does not overwrite them, by
design. So this is an edit you make. auth-posture names each site and the exact fix, and
withholds the findings as NOTEs until 0.7.0:
pnpm validate 2>&1 | grep -A2 'auth-posture: NOTE'The nine files move as one set, because the fix does not decompose: the browser client takes a cookie-backed storage adapter that the platform package must export, and the server client takes the reviewed cookie attributes that the same module defines.
| Where | What changed |
|---|---|
apps/web/lib/supabase/client.ts |
pass storage: cookieSessionStorage(jar, { secure }) |
apps/web/lib/supabase/server.ts |
pass the reviewed cookieOptions — this client REWRITES the cookie, so an omitted attribute is one it strips |
apps/web/app/sign-in/page.tsx, sign-in-form.tsx |
the sign-in path, and the httpOnly comment that claimed a control it cannot have |
packages/platform/supabase/src/{client,cookies,cookies.test,cookie-server,index}.ts |
the cookie session adapter and its export |
If you have not modified these files, taking 0.6.0's copies wholesale is the whole
migration. If you have, apply the change the gate names in each — it is small in every
one. The set is recorded as 0.6.0.seededSourceFixes in the harness's
template/migrations.json, which is also what the upgrade lane's sweep leg executes, so
this table cannot drift from what is actually required.
Then graduate
Section titled “Then graduate”npx next-expo-supabase-agent-harness@latest graduate advances baseVersion to 0.6.0 once
pnpm validate is green — and it still refuses while any ramp NOTE stands, which is
how you know the sweep was real.
This is executed, not just written. The upgrade lane's --sweep leg performs exactly
the steps above on a 0.3.0 install and then requires graduate to SUCCEED — so if the
sweep on this page ever stops being sufficient, that leg reds rather than a consumer
discovering it. It is also the only thing in this repository that has ever run graduate's
success branch: every other leg ends with it correctly refusing.
0.7.0 — THE THIRD ALARM, AND THE WIDEST
Section titled “0.7.0 — THE THIRD ALARM, AND THE WIDEST”Nothing in this section is new work. The seven ramps 0.6.0 opened all fall due
here — auth-posture, data-flow, docs-sync, reviewer-verdicts, the web half
of route-manifest, schema-rls, web-e2e — so every advisory NOTE the 0.6.0
section describes is now a red, and the sweep for each is the one that section
already wrote down. What IS new is who this reaches: this is the first release
that reds its own recent predecessors. 0.4.0 — the one released vintage 0.5.0
left alone — and 0.5.0 both meet a deadline for the first time; the affected
population is every released vintage below 0.6.0.
Two of the seven never appear in pnpm validate output, so the count command
below under-reports them by construction: reviewer-verdicts is a Stop-chain
step (it fires when a turn tries to end), and web-e2e runs only in the
path-filtered browser-lane CI job — both have their caveat spelled out in their
rows below.
Your baseVersion |
What 0.7.0 does to you |
|---|---|
| 0.6.0 | None of the seven touches you — every one has been live on your install since you graduated. Instead you meet this release's new ramps as NOTEs: version-sync's iOS toolchain floor and data-flow's export-target deadline fire on the seeded files exactly as they shipped, so expect both; the other two (docs-sync's deferral ledger, reviewer-verdicts' verdict-to-diff binding) fire only if your tree or turn carries the finding. All four expire in 0.8.0 — and graduate refuses while any chain NOTE stands, so graduating to 0.7.0 sweeps the first three anyway. The fourth is a Stop-chain matter: it fires inside a live turn, where only the sweep in its row below clears it. |
| 0.5.0 / 0.4.0 | The seven close at once — your first deadline ever. The 0.6.0 section above IS your sweep list; this section adds only the parked-fix channel and the new-ramp NOTEs below. A 0.5.0 install meets all seven with no older debt, which makes it the cleanest sweep in the lineage — and the parked artifact plus the doctor warning is how you discover the auth-posture half without reading anything. |
| 0.3.0 | Nine: the seven, plus the two 0.5.0 deadlines you have not met yet (diff-coverage, wiring) — on the way through, not because of this release. Follow the 0.5.0 section for those two. |
| 0.2.0 / 0.2.1 | Ten. Do not fight it head-on: follow 0.4.0's section, then 0.5.0's, then 0.6.0's, one graduate per hop — each hop shrinks the next. |
| 0.1.3 | Seventeen. Same advice, more so. |
fresh init at 0.7.0 |
Nothing ever ramped. |
The honest count is still the command, never this table — minus the Stop-side and browser-lane caveat above:
pnpm validate 2>&1 | grep 'RAMP EXPIRED'And the population is not prose either: template/migrations.json's
0.7.0.rampExpiry record states it as data, and scripts/check-ramp-ledger.mjs
reds if it disagrees with what the shipped call sites actually compute.
The seven, and where their sweeps already live
Section titled “The seven, and where their sweeps already live”Every remedy was already written on this page; the rows point INTO it rather than restate it.
| Gate | The finding | Sweep |
|---|---|---|
auth-posture |
the sign-in loop — the browser session in localStorage while every server read takes the cookie jar |
The nine-file set in "THE ONE THAT IS A BUG FIX" above, unchanged. New in 0.7.0: update also parks the instruction as data — see the parked-fix channel below. |
route-manifest (web) |
the seam files absent, or a page.meta.ts declaring state ids the page never renders |
"Sweeping the web route seam has a second half" above: --refresh-seeded the seam, then render meta.states.* in each adopted page. Adopting lib/i18n/ adds the accepted clone — its own subsection above carries the entry. |
schema-rls |
a CREATE POLICY with no table GRANT behind it |
The policy→grant bullet at the top of the 0.6.0 section: the finding prints the exact GRANT statement; put them in a new migration. Supabase's default-privilege change lands 2026-10-30 — closer than another release cycle, so do this for the date, not for the deadline. |
docs-sync |
AGENTS.md still documents the gate list from before update injected this release's chain steps |
Paste the gate names the finding prints. This is the one deadline 0.6.0 moved LATER — recorded as its rampExtensions entry — and the escape ends here. |
web-e2e |
no spec has ever completed a real sign-in | Author authenticated.spec.ts against YOUR routes, per the 0.6.0 section's authenticated-render bullet: sign in through the form — never context.addCookies, a planted session proves only that the server reads a cookie — then page.reload(). This red is invisible to pnpm validate: tools/check-web-e2e.mjs runs only in the path-filtered web-e2e CI job (plus the nightly/dispatch net), so the banner arrives on your first web-touching PR after the upgrade. Do not read its absence as a pass. |
reviewer-verdicts |
the turn's diff owed a reviewer and no verdict answers for it | update wires the machinery itself — .claude/settings.json and the hooks are owned, and the SubagentStop hook records each reviewer's verdict into .harness/reviewer-ledger.jsonl. The red fires only inside a live agent turn whose diff matches a tools/reviewer-triggers.json pattern, so the sweep is RUNNING the owed reviewers to a VERDICT: PASS — not editing files. |
data-flow |
the erasure/portability closure over your schema | Review tools/data-flow.json against YOUR schema — update plants the file when absent, and a planted or stale file's severed[]/retained[] rows are claims about tables you own. Its export half is the second new-ramp sweep below. |
The parked-fix channel: .harness/pending/source-fixes.json
Section titled “The parked-fix channel: .harness/pending/source-fixes.json”0.5.0 introduced the pattern for dependencies; 0.7.0 extends it to seeded source.
When a release CORRECTS harness-authored content inside files that are yours
(so update cannot write them), update now parks the instruction as data at
.harness/pending/source-fixes.json and prints one SEEDED SOURCE FIX note per
set, naming the gate, the files, and the release section on this page that
carries the fix. doctor warns while the file stands. It clears itself: each
set carries probes describing the broken shape, and once your tree no longer
matches it — you applied the fix, or you rewrote the files your own way — the
next update or doctor removes the artifact. An absent file is never "broken";
the gate, not the probe, stays the authority on the finding.
On an upgrade to 0.7.0, up to four sets can park: 0.6.0's auth-posture
nine-file sign-in set, the two 0.7.0 corrections the next section sweeps, and
one line of .gitignore: supabase/.temp/ — the local stack writes minted
service-role keys and credentialed DSNs there, the pre-0.7.0 seeded ignore
file never covered it, and the secrets gate reds on the working tree the
first time validate runs with the stack up. Add the line (however your own
ignore file is organized); the parked set self-clears when it appears.
The new ramps — the debt this release opens, expiring in 0.8.0
Section titled “The new ramps — the debt this release opens, expiring in 0.8.0”Four new checks arrive ramped, on the same terms every ramp on this page has
carried: NOTE-only while your baseVersion predates 0.7.0. Three are chain
gates, so graduate refuses while their NOTEs stand; the fourth
(reviewer-verdicts) is a Stop-chain step and surfaces only inside a live
turn.
version-sync: the iOS build-toolchain floor over eas.json — expect it,
whatever your vintage. Apple requires uploads built against Xcode 26 / iOS 26
SDK, in force since 2026-04-28 (tools/store-policy.json iosToolchain), and
every seeded eas.json before 0.7.0 pins no build image at all — no pin means
nothing can red, and a too-old toolchain burns a whole build-and-submit cycle
with no gate output. Pin a concrete image on the production iOS profile:
"image": "macos-tahoe-26.5-xcode-26.6" is the template's pin (the concrete name
behind the sdk-57 alias when 0.7.0 shipped), and any image whose -xcode-
major is >= 26 satisfies the gate. auto, latest, and sdk-NN do not count
— an alias moves under the build, so it is unverifiable offline. If you have
never modified eas.json, update --refresh-seeded apps/mobile/eas.json pulls
the whole file.
data-flow: the export-target deadline. If your tools/data-flow.json
export.surface still reads { "kind": "none", "target": "0.7.0" } — the
harness's own dated absence, seeded into every 0.6.0 install — the date has
ARRIVED, and the finding names your two legitimate moves. Either adopt the
delivered surface: pull the three withheld files
(update --refresh-seeded <path>, once per path) —
packages/api/src/export.ts (the exportMyData assembly: runs AS THE CALLER
under RLS, notes filtered authored-only in the query), its colocated test
packages/api/src/routers/system.export.test.ts, and the covering-index
migration supabase/migrations/20260808000000_notes_export_index.sql — then
mount exportMyData on your system router (the template's
packages/api/src/routers/system.ts is the worked pattern), merge the export
DTOs and row schemas into your contracts barrel (the template's
packages/contracts/src/index.ts — the row schemas live there so the adoption
adds no dependency to your api package), and set
export.surface to { "kind": "procedure", "procedure": "<your router file>" }
(--refresh-seeded tools/data-flow.json does that wholesale, but only if the
file carries no reviews of your own). The mount has two trailing edits leg E
found the hard way: add the system.exportMyData row to PARITY.md (the
parity gate closes the ledger both ways) and run pnpm gen so
tools/generated/action-inventory.json — generated from YOUR router, seeded as
of 0.7.0 for exactly that reason — names the procedure (contracts regen-diffs
it). The parked fix set at .harness/pending/source-fixes.json lists all four
files and self-clears on either move. The index file is a NEW migration, not
history: read it, re-timestamp it to the tail of your own history if you have
applied later ones, then apply it like a migration you wrote — the 0.4.0 rule
forbids planting DDL into the MIDDLE of applied history, and this lands at the
end. Or, second move: re-review YOUR target to a release you mean, in a reviewed
diff — the file is git-clean-enforced, so moving the date shows in the PR.
docs-sync: the deferral ledger over the owned prose surfaces. The gate now
scans docs/harness/gates-catalog.md, tools/auth-posture.json, and every
top-level tools/*.mjs for sentences that defer work to a named release, and
closes them both ways against tools/deferrals.json. The harness-owned surfaces
ship clean, so this NOTEs only if YOUR OWN tools/*.mjs carry a dated sentence.
The moves are the finding's: add the reviewed entry
({ id, file, target, reason, reviewedOn }) to tools/deferrals.json, or make
the sentence dateless if it states a permanent scoping condition rather than a
plan.
reviewer-verdicts: the verdict-to-diff binding. A PASS now carries a
path_state binding — the digest, at the moment the reviewer passed, of the
paths that summoned it. A PASS that pre-dates the last edit to those paths, or
carries no binding at all (a pre-0.7.0 hook wrote it), blocks toward re-review:
"a reviewer ran" and "a reviewer reviewed THIS" are different claims, and the
difference is exactly the files that moved after the PASS. The sweep is
behavioral, like the gate itself: run the owed reviewer again AFTER the last
edit to the paths it covers.
Then graduate
Section titled “Then graduate”npx next-expo-supabase-agent-harness@latest graduate advances baseVersion to 0.7.0
once pnpm validate is green — and it still refuses while any ramp NOTE stands,
including the three chain-side ones this release opens.
This section is executed, not reviewed. The upgrade lane's --sweep leg
performs exactly this page's steps on a v0.3.0 install — crossing 0.4.0, 0.5.0,
0.6.0, and 0.7.0 in a single update, sweeping every crossed release's seams,
fixes, and NOTEs — and then requires graduate to SUCCEED with zero surviving
ramp NOTEs. If what is written here ever stops being sufficient, that leg reds
before a consumer finds out.
0.8.0 — THE FOURTH ALARM: everything 0.7.0 opened falls due
Section titled “0.8.0 — THE FOURTH ALARM: everything 0.7.0 opened falls due”Nothing in this section invents a sweep. The four ramps 0.7.0 opened all fall
due here — version-sync (the iOS toolchain floor), data-flow (the
export-target deadline), docs-sync (the deferral ledger), reviewer-verdicts
(the path_state binding) — and the remedy for each is the one the 0.7.0 section
above already wrote down, in "The new ramps" rows. What IS new is the injected
step: update grows your chain to 34 (observability, right after
boundaries), which re-opens the AGENTS.md gate-list NOTE one more time and
adds this release's own new ramp.
One of the four never appears in pnpm validate output, so the count command
below under-reports it by construction: reviewer-verdicts is a Stop-chain step
— it fires when a turn tries to end, and the sweep is RUNNING the owed reviewers
to a VERDICT: PASS after the last edit to the paths that summoned them, not
editing files.
Your baseVersion |
What 0.8.0 does to you |
|---|---|
| 0.7.0 | None of the four touches you — every one has been live on your install since you graduated. You meet only this release's NOTEs: the AGENTS.md gate-list drift (paste the 34 names the finding prints) and, if your tree hand-wired a vendor telemetry SDK before the gate existed, observability's containment findings. Both expire in 0.9.0. |
| 0.6.0 | The four close at once — your first deadline ever, met with no older debt: the cleanest sweep in the lineage since 0.7.0 said the same of 0.5.0. The 0.7.0 section's "The new ramps" rows ARE your sweep list — the eas.json pin, the export adopt-or-re-review, the deferral ledger, the reviewer re-run — plus the AGENTS.md paste for the injected step. |
| 0.5.0 / 0.4.0 | The four, plus the 0.6.0-era fleet you have not met yet — on the way through, not because of this release. Follow the 0.7.0 section first (it is your biggest pile), then this one. |
| 0.3.0 and below | Follow 0.4.0's section, then 0.5.0's, then 0.6.0's, then 0.7.0's, one graduate per hop — each hop shrinks the next. This page's own CI proof (leg E) crosses 0.3.0 → 0.8.0 by exactly that route, sweeping as it goes. |
fresh init at 0.8.0 |
Nothing ever ramped. |
The honest count is still the command, never this table — minus the Stop-side caveat above:
pnpm validate 2>&1 | grep 'RAMP EXPIRED'And the population is not prose either: template/migrations.json's
0.8.0.rampExpiry record states it as data, and scripts/check-ramp-ledger.mjs
reds if it disagrees with what the shipped call sites actually compute.
The new ramps — the debt this release opens, expiring in 0.9.0
Section titled “The new ramps — the debt this release opens, expiring in 0.9.0”observability: vendor telemetry containment (the injected 34th step). No
telemetry SDK import outside the reviewed tools/observability.json sinks[]
register, and every declared sink referencing its redaction symbol in code — the
seam header's own invariant (packages/platform/observability/src/index.ts,
"NO VENDOR SDK, on purpose"). A fresh 0.8.0 tree is clean by construction; this
NOTEs only if YOUR tree wired a transport by hand (the module patch docs predate
the gate). The moves are the finding's: register the sink —
{ "file": "<path>", "vendors": ["@sentry/"], "redaction": "redactFields", "reason": "<40+ chars>" }, with the file referencing the symbol — or remove the
import and attach the vendor at the seam's LogSink per the module patches. The
register is planted when absent; the detector may be extended, never narrowed.
docs-sync: the AGENTS.md gate-list NOTE, re-opened. Same as 0.6.0, same
reason, recorded the same way (the rampExtensions entry in
template/migrations.json "0.8.0"): the injected step grows your chain while
your seeded AGENTS.md is yours alone to edit. Paste the 34 names the finding
prints into the "The N gates, in order:" sentence and the "N-step chain" line.
The escape ends at 0.9.0.
Then graduate
Section titled “Then graduate”Sweep the reds (the 0.7.0 rows), paste the gate list, clear any containment
NOTEs, then npx next-expo-supabase-agent-harness@latest graduate — it refuses while a
chain NOTE stands, and moving baseVersion to 0.8.0 is what retires every ramp
at or below it. This section is executed, not reviewed: the upgrade lane's leg E
runs exactly this page against a v0.3.0 install and reds the release if
graduate cannot reach its success branch.
0.9.0 — THE FIFTH ALARM: everything 0.8.0 opened falls due
Section titled “0.9.0 — THE FIFTH ALARM: everything 0.8.0 opened falls due”Nothing in this section invents a sweep. The two ramps 0.8.0 opened both fall
due here — docs-sync (the re-opened AGENTS.md gate-list NOTE after the
injected observability step) and observability (the vendor-telemetry
containment closure) — and the remedy for each is the one the 0.8.0 section
above already wrote down in "The new ramps". What is deliberately NOT here: no
step is injected (your chain stays 34, byte-identical), no deadline moves, and
the census deferral fired on schedule a second time (tools/deferrals.json
moved auth-posture-cli-census to 0.10.0 against a still-unchanged upstream).
If you applied the crash-reporting or observability module before 0.9.0,
read this first: the pre-0.9.0 patch docs instructed a "redaction": "redactFields" register row while the code they plant references
redactCrashEvent/redactText — instructions the gate itself reds. The 0.9.0
patch docs carry the corrected rows (append the real symbols to
redactionSymbols, one sink row per vendor-importing file, and the
metro.config.js require registers as "kind": "buildConfig", which needs no
redaction symbol because a bundler plugin registration transports no event).
Re-apply the register rows from the corrected docs; the code the patches planted
is unchanged.
Your baseVersion |
What 0.9.0 does to you |
|---|---|
| 0.8.0 | Neither expiry touches you — both have been live on your install since you graduated. You meet only this release's NOTEs, and only when their findings exist: version-sync reds an ABSENT pnpm-lock.yaml (run pnpm install, commit the lockfile) and wiring reds a committed-but-not-installed lefthook (run pnpm install). A tree with a committed lockfile and installed hooks sees nothing. Both expire in 0.10.0. |
| 0.7.0 | The two close at once — your first deadline ever, met with no older debt (the property the upgrade lane's leg H isolates). The sweep: paste the 34 gate names the docs-sync finding prints into your AGENTS.md, and clear any observability containment findings (register the sink with the CORRECTED symbol rows above, or remove the hand-wired import). |
| 0.6.0 | The two, plus the 0.8.0 pile you have not met yet — follow the 0.8.0 section first, then this one. |
| 0.5.0 and below | Follow each section in order — 0.4.0's, 0.5.0's, 0.6.0's, 0.7.0's, 0.8.0's, then this one — one graduate per hop; each hop shrinks the next. |
fresh init at 0.9.0 |
Nothing ever ramped. |
The honest count is still the command, never this table:
pnpm validate 2>&1 | grep 'RAMP EXPIRED'And the population is not prose either: template/migrations.json's
0.9.0.rampExpiry record states it as data, and scripts/check-ramp-ledger.mjs
reds if it disagrees with what the shipped call sites actually compute.
The new ramps — the debt this release opens, expiring in 0.10.0
Section titled “The new ramps — the debt this release opens, expiring in 0.10.0”version-sync: the committed-lockfile floor. The absent-lockfile NOTE this
replaces claimed "CI always has one" — false: the shipped workflows run
pnpm install --frozen-lockfile, which hard-fails with no committed lockfile,
and a fresh resolution drifts against the live registry (two installs a day
apart were measured 230 lock-lines apart). The move is one command:
pnpm install, then commit pnpm-lock.yaml. doctor names the
committed-but-untracked case too.
wiring: the commit-time layer installed, not dormant. A committed
lefthook.yml with nothing in .git/hooks is layer 2 fully disarmed while
every description of the harness counts it armed. The move is one command:
pnpm install (the prepare script), or pnpm exec lefthook install.
Then graduate
Section titled “Then graduate”Sweep the reds (the 0.8.0 rows), paste the gate list, clear any containment
NOTEs with the corrected register rows, then
npx next-expo-supabase-agent-harness@latest graduate. A baseVersion 0.8.0 install
with a committed lockfile and installed hooks sweeps NOTHING — graduate reaches
its success branch untouched, the first un-swept graduation in the lineage, and
the upgrade lane's leg A holds the release to exactly that.
0.10.0 — THE SIXTH ALARM, and the largest: SIX expiries at once
Section titled “0.10.0 — THE SIXTH ALARM, and the largest: SIX expiries at once”Read this paragraph before anything else in this section, because it is the
remedy and everything below it is detail. Six ramps fall due here. If you are
upgrading from 0.8.0 or older, do not hop straight to 0.10.0 — upgrade one
minor at a time. Every graduate moves your baseVersion forward, and every
ramp at or below it goes inert: it never fires and never expires. A 0.8.0
install that goes 0.9.0 → 0.9.5 → 0.9.9 → 0.10.0 meets 0, then 2, then 2,
then 2 gates instead of six in one run. That is not a trick — it is the
mechanism working as designed, and it is the difference between four short
sweeps and one long one.
There is deliberately no flag that extends a deadline (see the top of this runbook), so staggering the hops is the only lever, and it is a real one.
Why six at once is worse than six spread out. tools/validate.mjs is
FAIL-FAST: it stops at the first red gate. So a human meets these one per
run — five round trips, at roughly 24 s each, before seeing the last one. The
Stop chain does not stop early, so an agent sees all six in a single block. That
is why this release also bounds the Stop gate's output: each failing step's full
output now goes to .harness/stop-output/<gate>.log and the block carries a
head, a tail and the path, because the previous tail-only truncation dropped the
first finding of exactly this kind of flood.
Your baseVersion |
What 0.10.0 does to you |
|---|---|
| 0.9.9 | Nothing expires. You meet three advisory NOTEs for the ramps this release OPENS (the AGENTS.md Stop-chain list, the axe tag ladder, the outage-rung fallback declaration), all due 0.11.0. The upgrade lane's leg A holds the release to exactly that. |
| 0.9.5 | Two: auth-posture (the ten [auth.mfa] keys) and version-sync (the end-of-life register). The first release where a 0.9.x install meets a deadline at all. |
| 0.9.0 | Four: those two plus docs-sync (the AGENTS.md agent-surface list) and boundaries (the vertical-anatomy laws). |
| 0.8.0 | Six — the four above plus version-sync's committed-lockfile floor and wiring's installed-not-dormant lefthook floor. Both of those are one command: pnpm install, then commit pnpm-lock.yaml. (Six EXPIRIES across five gate names: version-sync fires twice, once for the lockfile floor and once for the end-of-life register. grep 'RAMP EXPIRED' prints one line per expiry, so you will see six.) |
| 0.7.0 and below | Six, plus everything the earlier sections describe that you have not met yet. Follow each section in order, one graduate per hop. |
fresh init at 0.10.0 |
Nothing ever ramped. |
The honest count is the command, never this table:
pnpm validate 2>&1 | grep 'RAMP EXPIRED'And the population is data, not prose: template/migrations.json's
0.10.0.rampExpiry states it, and scripts/check-ramp-ledger.mjs reds if that
record disagrees with what the shipped call sites compute.
The six, and the move for each
Section titled “The six, and the move for each”Two are trivial and are listed first so you can clear them before reading on.
wiring— the lefthook floor.pnpm install(the prepare script), orpnpm exec lefthook install.version-sync— the committed-lockfile floor.pnpm install, then commitpnpm-lock.yaml.
The remaining four land on files update cannot rewrite, because they are
yours:
auth-posture— the ten[auth.mfa]keys.supabase/config.tomlis seeded. The 0.9.9 section above has the reviewed block; append it if you have not already. Exactly ten keys across four sections, checked by value in both directions, so a typo that the CLI silently ignores reds here.version-sync— the end-of-life register.tools/eol.jsonwas planted at 0.9.9 with six rows drawn from the harness's own lockfile. Yours is a superset, so dispose of whatever else your tree resolves: each row records the package, the scope, and why it is accepted or when it goes.docs-sync— the AGENTS.md agent-surface list.AGENTS.mdis seeded. The finding prints the exact list; paste it in.boundaries— the vertical-anatomy laws.packages/**is seeded. Each finding names the package, the law and the path. The escape, where a finding is wrong for your architecture, is a reviewed entry intools/vertical-anatomy-allow.json— and a stale entry reds, so it cannot rot.
The seeded-source sweep (five files, one instruction)
Section titled “The seeded-source sweep (five files, one instruction)”Separately from the expiries, update parks one seededSourceFixes instruction
covering five files it cannot edit for you: the two axe specs, the tenant
switcher's link sizing, the rate limiter's outage rung, and its budget policy's
new fallback field. npx next-expo-supabase-agent-harness@latest doctor surfaces it,
and each probe self-clears once your tree stops matching the broken shape. Those
corrections are not on a deadline in this release — the checkers that demand
them are ramped to 0.11.0.
Then graduate
Section titled “Then graduate”Sweep the reds, then
npx next-expo-supabase-agent-harness@latest graduate, then re-run pnpm validate.
0.11.0 — THE SEVENTH ALARM, and the widest population the lineage has published
Section titled “0.11.0 — THE SEVENTH ALARM, and the widest population the lineage has published”Twelve vintages meet a hard failure (0.1.3 through 0.9.9), against eleven at 0.10.0 —
and it is the first wave ever to reach a 0.9.9 install. Five ramps fall due at once
across four gate names: docs-sync (twice), web-e2e, version-sync and rate-limits.
If your baseVersion is 0.10.0 you are UNAFFECTED. Four of the five carry
minVersion 0.10.0, and a ramp is inert when baseVersion >= minVersion — the demand was
live for you from your first validate, so nothing expires. This section is for installs
seeded before the demand existed.
THE MITIGATION IS THE ONE THAT HAS ALWAYS WORKED, and it is stated first rather than
last: UPGRADE ONE MINOR AT A TIME. Each graduate moves baseVersion forward and every
ramp at or below it goes inert. tools/validate.mjs is FAIL-FAST, so a human meets these
one per run — five round trips before you see the last. There is no flag that extends a
deadline; moving one needs a reviewed rampExtensions entry in the harness itself.
What you must sweep, in the order the chain will ask for it
Section titled “What you must sweep, in the order the chain will ask for it”tools/eol.json— the uuid acceptance (gate:version-sync). CORRECTED AT 0.11.1 — on 0.11.1 or later this is a NOTE, not a hard red; see the 0.11.1 section below. The paragraph that follows describes 0.11.0 only. The register ships aremovalTargetthe HARNESS wrote, your copy is SEEDED, andupdatemay never rewrite it. At 0.11.0 that date arrives AND the ramp that softened it into a NOTE expires, so the finding is hard. Re-affirm the acceptance — moveremovalTargetto a release you actually mean, recording what you re-checked in the diff — or remove the dependency. This is the one correction this release parks for you throughseededSourceFixes.AGENTS.md— the Stop-chain list (gate:docs-sync). The sentence "The N Stop-chain steps, in order:" must agree with your floor.AGENTS.mdis seeded, soupdatecannot correct it. v0.3.0, v0.4.0 and v0.5.0 each shipped "The 9 Stop-chain steps" against a 10-step floor. If your file carries no such sentence, you are green by the absent-is-green rule — a fork may decline to document the Stop chain.docs/adr/**— ADR content shape (gate:docs-sync). Load-bearing sections need real bodies,Statuscomes from a closed vocabulary, corpus refs must resolve, and source hosts must be allowlisted. ADRs are owned by you, which is why this had two releases of runway rather than one.- The seeded axe scans (gate:
web-e2e).apps/web/e2e/*.spec.tscallingwithTags([...])must carry the full ladder['wcag2a','wcag2aa','wcag21aa','wcag22aa']—withTagsNARROWS axe, so a short list is a decision not to run everything outside it. Stated as counts and never as a level: those four tags select 63 runnable rules over 21 success criteria. An UNTAGGED scan is exempt because it runs more. tools/rate-limit-budget.json—failOpen.fallback(gate:rate-limits). The block records WHETHER the limiter fails open; it must now also say WHAT limits traffic while it does.updatecannot write this for you because the honest answer depends on your deployment.
Items 2, 4 and 5 were parked for you by the 0.10.0 record as advisory seededSourceFixes.
At 0.11.0 they stop being advisory.
What this release OPENS, recorded beside what it closes
Section titled “What this release OPENS, recorded beside what it closes”The web account-deletion surface (gate: data-flow), advisory until 0.12.0 — which
was never cut, so it arrives at 1.0.0 (every comparison is >=). The
delete-account Edge Function and the mobile command that calls it have shipped since
0.7.0; tools/data-flow.json now carries an erase record whose clients closure names
BOTH initiators, so the rule that holds export to a delivered surface holds erase to one
too. Your web app is very likely missing its half. Two reasons it arrives as a NOTE and not
a file: tools/data-flow.json is SEEDED, so update cannot write the record for you, and
apps/web/app/(protected)/ has been seedOnInitOnly since 0.2.0, so update was never
going to plant the button either. A FRESH 0.11.0 scaffold is seeded with the whole surface
and held to it immediately. Yours is yours to add — the shape to copy is
apps/web/lib/account/delete-account.ts in a fresh scaffold: server-side deletion FIRST,
local session dropped only after it succeeds. Apple 5.1.1(v) already requires this of the
mobile half.
After this release, one ramp is left — and it is the one above
Section titled “After this release, one ramp is left — and it is the one above”Every ramp opened BEFORE this release is now inert or expired. Exactly one of the 43
shipped rampNote sites is still advisory, and 0.11.0 is the release that opened it — so
graduate's refusal-while-NOTEs-stand has exactly one thing left to hold, and adding the
erase surface clears it. For the count that applies to YOUR baseVersion, run
pnpm validate 2>&1 | grep 'NOTE — (ramp)'; never read it off this page.
0.11.1 — a correction to 0.11.0, and one less thing to sweep
Section titled “0.11.1 — a correction to 0.11.0, and one less thing to sweep”If you are on 0.11.0 already, nothing here applies to you — your baseVersion is at or above every escape this release touches, and you meet nothing. This section is for an install crossing 0.11.0 on its way to 0.11.1 or later.
One expiry is withdrawn. tools/eol.json's uuid acceptance — item 1 of the 0.11.0 list
above — is a dated NOTE on 0.11.1, not a hard red, and it now falls due at 0.12.0 (a
release that was never cut: it arrives at 1.0.0). So
you meet four expiring sites crossing this hop rather than five: docs-sync twice,
web-e2e and rate-limits. Items 2 through 5 above are unchanged and still hard.
Why it was withdrawn, because "we moved a deadline" deserves a reason. The escape was
live for exactly the population it could not help. A ramp is inert once your baseVersion
reaches its minVersion, and that one opened at 0.10.0 — so a 0.10.0-vintage install was
never covered by it, and a 0.10.0-vintage install is the only one whose seeded
tools/eol.json carries a removalTarget of "0.11.0" that the harness itself wrote. You
would have met a hard failure, on your first validate after update, for a value you did
not choose, in a file update is not allowed to fix for you. That is not an expiry — no
deadline of yours had been reached — and the upgrade lane refused the release for it.
You still owe the row. Re-affirm the uuid acceptance and move removalTarget to a
release you actually mean, recording what you re-checked in the diff. The deadline is 0.12.0
— met at 1.0.0, since 0.12.0 was never cut — and the HARNESS will not move it in your file
again (its own copy moved to 1.1.0 on fresh evidence, which reaches fresh scaffolds only): check-eol-target now reds any harness PR that would ship an
arrived target, so the harness cannot re-author this date into your seeded file a fourth
time.
1.0.0 — THE SETTLEMENT RELEASE: 0.11.1 to 1.0.0 direct, no 0.12.0 ever cut
Section titled “1.0.0 — THE SETTLEMENT RELEASE: 0.11.1 to 1.0.0 direct, no 0.12.0 ever cut”Every comparison in the fleet is >=, so the deadlines the 0.11.x releases wrote as
0.12.0 arrive at THIS hop exactly as they would have at a 0.12.0. Read the count that
applies to YOUR baseVersion off pnpm validate 2>&1 | grep 'NOTE — (ramp)' and, for the
expiries, off node scripts/ci/ramp-expectations.mjs <your base> 1.0.0 in a harness
checkout — never off this page. What the page owes you is the SHAPE, and the sweep.
One release at a time. 1.0.0 is a major bump and a large one; if you are more than
one release behind, hop to 0.11.1 first (its section is above) and let its expiries settle
before crossing this one. update will happily hop further — the ramps compose — but the
list below is written for an install arriving from 0.11.x, and an install arriving from
0.9.x meets this list PLUS everything the sections above owe it.
What ARRIVES (hard) — for installs below 0.11.0
Section titled “What ARRIVES (hard) — for installs below 0.11.0”- The web account-deletion surface (gate:
data-flow). The 0.11.0 NOTE falls due. Add the web erase half (the shape isapps/web/lib/account/delete-account.tsand the(protected)button + layout in a fresh scaffold) or the record intools/data-flow.jsonstays a promise the gate reds. AbaseVersionof 0.11.0 or later meets nothing here.
What OPENS (dated NOTEs, until 1.1.0) — for EVERY install below 1.0.0
Section titled “What OPENS (dated NOTEs, until 1.1.0) — for EVERY install below 1.0.0”Six gates, each ramped at minVersion 1.0.0, so graduate refuses until each is swept:
suppressions(NEW chain step, 35 of 36) — the inline-directive census overapps/,packages/,supabase/.updatePLANTStools/suppressions-allow.jsonwith rows describing the 1.0.0 seeded tree; a tree of yours that carries a directive the register does not name NOTEs until you add its row (with awhy— a reviewed sentence), and a planted row naming a directive your tree lacks NOTEs as a stale acceptance until you drop it. Reconcile the register to YOUR tree; the gate never invents awhyand neither should you.resilience(NEW chain step, 10 of 36) — the outbound-seam posture register.updatePLANTStools/resilience.jsondescribing the seeded seams; a seam you added NOTEs until it has a row.docs-sync's gate list — your seededAGENTS.mdsays "34 gates"; the chain now runs 36. Paste the 36 names the NOTE prints (the same edit 0.6.0 and 0.8.0 asked for).boundaries' anatomy widening — the DAL laws are behaviour-keyed now (a client value-import anywhere undersrc/**; PostgREST callers owe a resolvingport.tsimport). Findings carry avintage; the widened ones NOTE until 1.1.0.version-sync— two things: the seeded vendor-support register (tools/support-register.json, planted) with its platform-fact closure, and the uuid arrival: your seededtools/eol.jsonsaysremovalTarget: 0.12.0, that date arrives at 1.0.0, and the harness re-dated its OWN copy to 1.1.0 on fresh registry evidence (a caret-range correction;scripts/sweep-registry-deprecations.mjsis the review method). The re-date reaches you as a PARKED fix under.harness/pending/(seededSourceFixesprobe on the old literal) — apply it, or re-affirm the row under a target you mean, or remove the dependency. The 0.11.1 section said "the deadline will not move again" and meant the HARNESS would not re-author your file; it did not, and the NOTE is the runway to apply the parked fix rather than a hard red on your first validate.auth-posture's[auth.hook.*]floors — ONLY IF you adopt the auth-event trail. The trail is a migration (20260816000000_auth_event_trail.sql+ the declarative twin45_auth_trail.sql, its pgTAP suite and its live wiring test) and two config sections that bind GoTrue's password/MFA verification hooks to it.updatewrites neither: the migration is your append-only history andconfig.tomlis yours. The two halves are ONE act, and the gate holds it: with the migration in your tree the four hook floors are demanded (NOTE until 1.1.0 for a pre-1.0.0 base); with a hookenabled = trueand NO migration, the gate is a HARD red — GoTrue would call a function nothing created and every sign-in would fail; with neither, nothing is demanded and one plain line says so. Adopt both together (docs/adr/20260816-auth-event-trail.mdshows both), and restart the auth container (supabase stop && supabase start—db resetdoes not reload hook config).
What is WITHHELD, and what the sweep adopts for you
Section titled “What is WITHHELD, and what the sweep adopts for you”seedOnInitOnly in the 1.0.0 record: the MFA enrolment surface (web sign-up,
sign-in/mfa, (protected)/security; mobile sign-up, mfa-challenge, security with
their suites), the privilege-lifecycle + JIT migration, the auth-event trail, and
security.txt. The documented sweep (scripts/ci/upgrade-sweep.mjs, SWEEPS['1.0.0']) is
what upgrade-lane leg E executes and what proved this list SUFFICIENT — and it was written by
running that leg, not by reading the record:
- It ADOPTS the web MFA seams — because the 0.6.0 parked fixes copy HEAD's
sign-in/page.tsx,sign-in-form.tsxand@app/supabase'sclient.ts, and at 1.0.0 those import the MFA ceremony; the importers without the imported is adead-code"unresolved imports" red and aroute-manifestred for allowlisted pages that do not exist. If you applied those parked fixes, adopt the web MFA files with them (update --refresh-seeded apps/web/app/sign-in/ apps/web/app/sign-up/ "apps/web/app/(protected)/security/" packages/platform/supabase/src/mfa-actions.ts packages/platform/supabase/src/mfa-flow.ts). The mobile screens are yours to adopt deliberately (route registry, i18n catalog, startup budget). - It RECONCILES
tools/data-flow.json'sexport.excludedto your migrations — the 0.7.0 parked fix copies HEAD's file, whose excluded set namesadmin_elevations, a table only the (withheld) JIT migration creates;check-data-flowreds a stale exclusion and its own remedy is to remove it. Drop the entry, or adopt the JIT migration deliberately (it REPLACESprivate.member_ranksand rewritesmembershipspolicies — read it first). - It does NOT append the
[auth.hook.*]block and does NOT adopt the trail (item 6 above says why: the two halves travel together or not at all, and the trail drags three seeded wiring files behind it —tools/rls-exempt.jsonrows, the pgTAPrls_targetslist, the client suite'sISOLATION_TARGETS).
After this release
Section titled “After this release”Every ramp opened before 1.0.0 is inert or expired. The advisory column is exactly the
six-gate 1.0.0 fleet, all dated 1.1.0, and each has an obligations row that owes the 1.1.0
record its expiry. graduate refuses while any of them NOTEs; the sweep above clears the
ones a script may clear, and the rest (registers naming YOUR code, the paired trail
adoption) are yours.
1.0.1 — a patch: nothing expires, nothing opens, and the lock follows the re-pin
Section titled “1.0.1 — a patch: nothing expires, nothing opens, and the lock follows the re-pin”If you are on 1.0.0 already, no ramp here applies to you — EXPIRED and NOTING are
both empty at baseVersion 1.0.0 (node scripts/ci/ramp-expectations.mjs 1.0.0 1.0.1 in the
harness repo says so). The population 1.0.0 reds is restated in this release's record because
a direct hop from an old vintage crosses 1.0.0's record on the way and meets its expiries on
arrival; the 1.0.0 section above is the sweep, and 1.0.1 adds nothing to it.
What update plants. Three owned files, re-planted when your copy still matches the
recorded sha: tools/check-diff-coverage.mjs (the comment-stripping fix — on a 1.0.0
scaffold the gate was demanding coverage the runner never measured for everything below the
first apostrophe inside vitest.config.ts's exclude array), .claude/settings.json (the
~/.claude deny narrowed to the two settings files, so plans and memory can persist under
~/.claude), and .claude/agents/architecture-reviewer.md (model: opus → model: fable).
The agent lock follows the re-pin. update never regenerates an existing
tools/agents.lock.json, because that would launder every edit made since the last one.
It re-records only the entries of the agent-surface files it rewrote itself, hash and
model pin together. If your architecture-reviewer.md was untouched, update rewrites
it, the lock records its new model, and prompts does not red on it: nothing is owed.
If you had edited it, update keeps your copy and parks the incoming one under
.harness/pending/ (see "Forking an owned file" in the 1.0.2 section). Merging it is
your edit, so prompts reds on it until a human runs
HARNESS_ALLOW_SELF_EDIT=1 node tools/gen-agents-lock.mjs --write and commits the lock
with the merge. That is not a ramp and has no deadline.
1.0.2 — a security patch: the next floor moves, and it reds every install below it
Section titled “1.0.2 — a security patch: the next floor moves, and it reds every install below it”No ramp here applies to a 1.0.0 or 1.0.1 install, and the population 1.0.0 reds is restated in this release's record for the same reason 1.0.1 restated it. What follows is not a ramp, and no vintage is exempt from it.
Expect version-sync to red after update. tools/framework-floor.json is
harness-owned, so update refreshes it; pnpm-workspace.yaml is seeded, so update does
not touch your pins. The next floor is now 16.3.3 (or 15.5.24 on the 15 line), for
the two critical advisories in the
August 2026 security release:
- GHSA-2xp9-vwfh-vxw4: unauthenticated remote code execution when the Image Optimization API optimizes an attacker-controlled AVIF image. The patched releases disable AVIF optimization until an upstream fix propagates.
- CVE-2026-75604: unauthenticated remote code execution for apps using both the Pages Router and the App Router without Cache Components, when the server uses a Windows filesystem. Linux and macOS are not affected.
Upstream patched no 16.2 release, so a 16.2.x pin moves to 16.3.x. That is a minor bump of your web framework, which this harness otherwise pins exactly to avoid. A fresh scaffold passes the whole chain on 16.3.5, which is what new installs now pin. Your app has code the scaffold does not, so read the 16.3 release notes before you take it:
# raise the `next` pin in the pnpm-workspace.yaml catalog, thenpnpm install && git add pnpm-lock.yaml pnpm-workspace.yamlpnpm validateIf you deploy to Vercel or another Linux host, use the App Router only and configure no remote images, your exposure to both advisories is narrow. The floor does not ask: there is no flag that lowers it, for the reason the 0.5.0 section gives.
Expect version-sync to red on eslint 9 as well, and this one is not caused by the
update. The vendor ended the ESLint 9 line on 2026-08-06 and has since flagged every 9.x
release on the registry. tools/eol.json is seeded, because its rows are your decisions,
so update does not touch your copy. The next time your lockfile re-resolves, the census
reds on a deprecated package your register has no row for. Two ways to clear it:
# if you have never edited tools/eol.json: take the harness's register, which now# carries an eslint 9 row (overwrites when untouched, parks on drift)npx next-expo-supabase-agent-harness@latest update --refresh-seeded tools/eol.json
# if you have your own rows: copy the eslint row from the parked or template copy into yoursThe row accepts ESLint 9 as a development dependency because
eslint-plugin-react-native-a11y and eslint-plugin-jsx-a11y do not yet admit ESLint 10.
react-native 0.84 also left the supported set. The scaffold has never shipped below 0.86,
so that reds only a tree that was moved down by hand.
One migration you have to write yourself: revoke authenticated's default write grants
Section titled “One migration you have to write yourself: revoke authenticated's default write grants”Supabase's default privileges grant ALL on every new public table to authenticated.
The migrations that created the seven tables authenticated may only read revoked that
from anon and service_role, then granted SELECT to authenticated. A GRANT adds a
privilege and removes none, so authenticated kept INSERT, UPDATE, DELETE, TRUNCATE,
REFERENCES and TRIGGER on them.
Row security still refused every client write, because all seven are FORCE ROW LEVEL SECURITY with deny-all write policies. So this is a missing layer and not an open door.
It still needs closing: table privileges are checked before row security, and row security
does not apply to TRUNCATE at all. docs/adr/20260920-authenticated-write-revoke.md, which
this update plants, has the full account.
A fresh scaffold gets supabase/migrations/20260920000000_authenticated_write_revoke.sql.
update does not plant it in your project, because supabase/migrations/ is your
applied history and a file with the harness's timestamp could sort ahead of migrations you
have already applied. Create your own:
supabase migration new authenticated_write_revokeand put this in it, keeping only the tables your project has (admin_elevations arrived in
1.0.0; the three quota tables and the three seat tables in 0.2.0):
-- adr: docs/adr/20260920-authenticated-write-revoke.md-- SOURCE: https://www.postgresql.org/docs/17/ddl-priv.htmlREVOKE ALL ON TABLE public.orgs FROM authenticated;REVOKE ALL ON TABLE public.memberships FROM authenticated;REVOKE ALL ON TABLE public.invitations FROM authenticated;REVOKE ALL ON TABLE public.admin_elevations FROM authenticated;REVOKE ALL ON TABLE public.org_usage FROM authenticated;REVOKE ALL ON TABLE public.org_quota FROM authenticated;REVOKE ALL ON TABLE public.quota_defaults FROM authenticated;
GRANT SELECT ON TABLE public.orgs TO authenticated;GRANT SELECT ON TABLE public.memberships TO authenticated;GRANT SELECT ON TABLE public.invitations TO authenticated;GRANT SELECT ON TABLE public.admin_elevations TO authenticated;GRANT SELECT ON TABLE public.org_usage TO authenticated;GRANT SELECT ON TABLE public.org_quota TO authenticated;GRANT SELECT ON TABLE public.quota_defaults TO authenticated;The -- adr: line is required: your migrations gate treats a REVOKE ... FROM authenticated as a change to an authorization control. Do the same for any table of your
own that authenticated should only read. Then pnpm db:reset && pnpm db:test.
You may meet this as a red test before you read this page. rls_structure.test.sql
has asserted "authenticated holds NO write grant" on the seat tables since 0.2.0. It passed
against the local stack of Supabase CLI 2.115 and fails against 2.117, which applies the
default privileges the way the platform documents them. If that assertion goes red after a
CLI upgrade, the test is right and this migration is the fix. To pull the stricter version
of the test, which covers all seven tables and TRUNCATE:
npx next-expo-supabase-agent-harness@latest update --refresh-seeded supabase/tests/rls_structure.test.sql,
after the migration is applied.
The authoring guidance now teaches the revoke this release shipped
Section titled “The authoring guidance now teaches the revoke this release shipped”The migration above exists because a GRANT adds a privilege and removes none. Until this
release the places that TEACH a new table's grants still showed the old shape: revoke
anon and service_role, then grant authenticated. An agent following them wrote
exactly the table the new privilege assertion reds. They now teach three revokes (anon,
service_role, authenticated) and then a grant of exactly the operations the table's
policies admit, name TRUNCATE, REFERENCES and TRIGGER as what the default leaves behind,
and say that the authenticated revoke needs the -- adr: marker your migrations gate
asks for.
update delivers this to the harness-owned files: the migration-rls-author and
security-reviewer agents, the /new-migration, /new-feature and /rls-check commands,
both authoring skills and .claude/rules/security-invariants.md. AGENTS.md and
supabase/AGENTS.md are yours, so update does not touch them. If yours still say
"REVOKE ALL from service_role, and grants to authenticated", change that clause to:
REVOKE ALLfromanon,service_roleANDauthenticated, then the EXACT grants its policies admit (a GRANT removes nothing; the default leaves it TRUNCATE).
The shipped example migrations keep the older shape. They are applied history, and the seven read-only tables are corrected by the migration above. For a writable table of your own, the same three statements and an exact re-grant are the fix.
Forking an owned file: re-record it, and update parks instead of overwriting
Section titled “Forking an owned file: re-record it, and update parks instead of overwriting”Sometimes a harness-owned file has to change for your project: a gate script that needs a
local fix before upstream ships one, a workflow with a lane you run differently. The
supported way to keep such a fork is the one gate-integrity already pushes you towards:
edit the file, then have a human re-record its sha256 in .harness/manifest.json in a
reviewed commit. The gate goes green, and the record is visible in the diff.
Until 1.0.2 that record was also the fork's death warrant. update read "the bytes
match the manifest" as "the harness wrote them", so a re-recorded fork looked pristine and
was overwritten on the next update, silently, with exit 0. disable and a removed
migration deleted one the same way, and the installer did it to two files it had recorded
itself: .claude/settings.json after a retrofit merged your settings into it, and a root
config whose retrofit conflict you had just resolved by merging.
From 1.0.2 update asks a different question: did a release ever ship these bytes? The
installer now carries, per version, the sha of every owned file it has shipped
(template/shas/ in the harness repo; nothing is added to your tree). A recorded sha that
matches none of them is a fork, and:
- the file is kept, whatever
updatebrings; - the incoming version is parked at
.harness/pending/<path>only when upstream actually changed that file since your install's version. Merge it, re-record, delete the parked copy.updateexits 2 while anything is parked, exactly as it does for drift; - nothing is parked when upstream left the file alone. The run lists your forks in one note and the exit code stays 0. A fork does not cost you a red update forever;
update --dry-runnow names every path it would write, anddoctorlists each fork asinfowithout changing its exit code, so you can see what a run will do before it does it;update --forcestill means "discard my local version". It overwrites forks too.
A file pinned back to an older release's bytes is treated the same way, and the note says which release it matches.
Do not reach for the quieter escape of editing the record's mode to seeded. It does
stop update from touching the file, and it also takes the file off the hash surface, so
gate-integrity no longer notices anyone editing it. Re-recording keeps the evidence.
Two limits. update rebuilds a placeholder-bearing file's template source from the
answers in your manifest, so hand-editing answers afterwards makes those files read as
forks (parked, never lost). And tsconfig.json is exempt: the installer derives its
project references at install time, so no release's bytes can match it, and it is
refreshed as before.
The shipped workflows now fail a broken pipe, and every job has a ceiling
Section titled “The shipped workflows now fail a broken pipe, and every job has a ceiling”update refreshes every workflow under .github/workflows/ that you have not changed. Two
things are different in them.
Every workflow selects shell: bash at the top. GitHub runs a step that names no shell
as bash -e, without pipefail, so producer | tee file reported tee's status and the
producer's failure was lost. That is what gate-summary was: the check you were told to
mark required printed gate-summary: FAIL and exited 0. It now goes red when a lane it
covers is red. If gate-summary turns red on the first run after this update, open the
lane it names. The lane was already failing; the summary has started saying so. Two other
steps were rewritten for the same class: the pull request mutation lane no longer reads a
scoper that failed closed as "nothing to mutate", and the native job's targetSdk check
still prints why it failed.
Every job has a timeout-minutes. Without one a hung step costs GitHub's 360-minute
default before anything reports. The ceilings are several times what the lanes take on a
fresh scaffold, and a job that times out is reported as cancelled, which gate-summary
already counts as a failure. If one of your lanes legitimately runs longer than its
ceiling (mutation testing and CodeQL grow with your code), raise the number in your copy
of the workflow and re-record its sha, as this release's "Forking an owned file" section
describes: update keeps a fork and parks the incoming version for you to merge.
1.0.3 — a patch: nothing expires, nothing opens, one pin to raise by hand
Section titled “1.0.3 — a patch: nothing expires, nothing opens, one pin to raise by hand”No ramp here applies to a 1.0.0, 1.0.1 or 1.0.2 install. The population 1.0.0 reds is restated in this release's record for the same reason 1.0.1 and 1.0.2 restated it, and the 1.0.0 section above is still the sweep.
What update plants. Owned files, re-planted when your copy still matches a released
sha: the shipped gates and libraries that close the template's CodeQL findings
(tools/check-secrets.mjs, tools/lib/gate.mjs, tools/check-mutation-ratchet.mjs,
tools/perf-baseline.mjs, tools/check-rls-manifest.mjs, tools/lib/observability.mjs,
tools/lib/sbom.mjs, tools/check-backup-posture.mjs, tools/check-restore-manifest.mjs,
and tools/check-store-config.mjs where the store-metadata module is enabled), plus
.github/workflows/codeql.yml, tools/conformance-map.json, renovate.json and the hook
version stamps. What you may notice afterwards:
- New entries in the Security tab. The CodeQL lane now runs the
security-and-qualitysuite: every query it ran before, plus reliability and maintainability ones. They are informational. The lane is not a required check, and no gate reads it. - A gate stamp that fails where it used to pass.
hashInputsnow throws on an input it cannot open for any reason other than absence (a symlink loop,EACCESon a parent directory), where it used to hash that input as missing. A readable or absent input hashes exactly as before, so no stamp moves unless an input really was unreadable. - The backup-evidence lane skips on
TBD. init's default project ref now reads as "never linked", the same loud skip as the unrendered placeholder, which still fails underHARNESS_REQUIRE_BACKUP_EVIDENCE=1. A ref that is not 20 lowercase letters fails before any request, naming where it came from but never printing it. renovate.jsondrops the deprecatedbaseBrancheskey and gains two packageRules: one restores the 5-day npm cooldown thatconfig:best-practicesquietly shortens to 3, one lets the CodeQL action pin update. If you tuned your copy, it is kept and the incoming one is parked under.harness/pending/; copy the two rules across by hand.
The one thing you owe: raise vitest. The catalog now pins vitest and
@vitest/coverage-v8 at 4.1.11, for GHSA-82fw-gwwq-j7x9 (moderate: an arbitrary file
read by path traversal through the @vitest/mocker redirect mock, affecting 2.1.0 up to
4.1.11). Every release through 1.0.2 shipped 4.1.10. pnpm-workspace.yaml is seeded, so
update does not touch your pins, and no gate reds on the old one; your daily osv-scan
job is what reports it. Move both pins together, because the coverage package peer-pins
the identical vitest:
pnpm install && git add pnpm-lock.yaml pnpm-workspace.yamlpnpm validateDo not use update --refresh-seeded pnpm-workspace.yaml for this: it replaces your whole
catalog with the template's.
1.0.4 — the local loop release: a patch, nothing expires, nothing opens
Section titled “1.0.4 — the local loop release: a patch, nothing expires, nothing opens”No ramp here applies to a 1.0.0, 1.0.1, 1.0.2 or 1.0.3 install. The population 1.0.0 reds is restated in this release's record for the same reason 1.0.1 through 1.0.3 restated it, and the 1.0.0 section above is still the sweep.
What update plants. Owned files, re-planted when your copy still matches a released
sha: tools/check-types-drift.mjs, docs/harness/gates-catalog.md, this runbook, the hooks
under .claude/hooks/ (their version stamps, and the telemetry below),
.claude/hooks/lib/hookio.mjs, .claude/hooks/lib/guard-rules.mjs, docs/harness/README.md,
tools/lib/gate.mjs, tools/lib/stamp-inputs.mjs, tests/rls/run-rls.mjs,
tests/rls/auth-trail.test.ts, tools/validate.mjs, tools/check-migrations.mjs,
tools/check-version-sync.mjs, tools/check-styleguide-manifest.mjs,
tools/check-perf-budget.mjs and tools/check-expo-policy.mjs; where the eas-update
module is enabled, tools/check-eas-update.mjs; and where the store-metadata module is
enabled, docs/store/app-review-notes.md. tools/lib/supabase-cli.mjs is new, and update
plants it. The project citation corpus below adds the new tools/lib/corpus.mjs, which
update plants too, and changes tools/check-sources.mjs, tools/check-docs-sync.mjs,
tools/mcp/corpus-search-server.mjs, tools/mcp/README.md,
tools/lib/enforcement-surface.mjs, the header comments of tools/lib/provenance-rules.mjs
and tools/lib/citation-domains.mjs, .claude/rules/provenance.md,
.claude/agents/citation-verifier.md, .claude/commands/verify-citations.md,
.claude/skills/authoring-e2ee-feature/SKILL.md, docs/adr/README.md and
docs/security/approved-tools.md. The device lane below changes
.github/workflows/quality-gate.yml, tools/ci/device-lane.sh,
tools/check-e2e-device.mjs and tools/lib/maestro-flows.mjs, and, where the
device-e2e module is enabled, .github/workflows/device-e2e.yml. What you may notice
afterwards:
-
A
types-driftFAIL shows the diff. Before the unchanged FAIL sentence the gate prints each side's line count, the first line that differs, and a bounded window of each side from there (DIFF_LINESin the gate), the committed file's lines prefixed-and the generated output's+. The verdict and the exit code do not change. -
A new log,
.harness/telemetry.jsonl. The hooks append a record per Stop step (its status, duration, andSKIPPEDandSTAMPEDcounts), per gate time in a step'sVALIDATE_TIMINGSline, and per in-turn deny, provenance block, Biome warning or reviewer bounce. It holds ids and counts, never content, commands or paths. It is never trimmed and no gate reads it, and.harness/*is already ignored, so it never shows ingit status. To reset it, delete it yourself: the guards deny an agent's edits and deletions under.harness/. If you forked.claude/hooks/lib/hookio.mjsandupdateparked the new one under.harness/pending/, every hook still loads and nothing is recorded until you take the parked copy. -
A stamp hit prints
STAMPED. A gate riding its stamp used to print<gate>: OK — inputs unchanged since last green run (…); it now prints<gate>: STAMPED — inputs unchanged since last green run (…), still exits 0, and the Stop hook lists those lines beside its skipped layers. If a script of yours matched the old line, match the new one.updatedeletes every.harness/*.okas it always has, so the first run after it re-proves every gate. -
Stamps expire on more edits. Each gate's stamp now also hashes the
tools/libmodules its script imports (lib/sql-parse.mjsfortenancy,query-shapesanddb-limits, for example) andlib/fs-walk.mjs. An edit to one of them re-runs the gate instead of riding a warm stamp. Nothing expires less often than before. -
rls-isolationcan printSTAMPEDwith the stack up. When nothing the two suites read has changed, thesupabase --versionoutput is the same and the running database has the same start time and applied migrations as at the last green run, the Stop step printsrls-isolation: STAMPEDand runs neither suite.pnpm db:reset,pnpm db:downthenpnpm db:up, a migration applied from the command line, or a different CLI each run both suites again. Any non-emptyCI,CI=falseincluded, andHARNESS_REQUIRE_TOOLCHAINS=1always run them. The stamp cannot see SQL you run by hand against the running database: after that, runpnpm db:reset, or delete.harness/rls-isolation.okyourself (the guards deny an agent's deletions under.harness/). Ifupdateparked your copy oftools/lib/gate.mjsortools/lib/stamp-inputs.mjs, the runner stamps nothing and runs both suites, as it did before, until you take the parked copies. -
The Stop hook's database steps run your workspace Supabase CLI. The
rls-isolationstep andtypes-driftnow putnode_modules/.binfirst on thePATHthey spawn with (not on Windows), so they run the CLI your catalog pins, aspnpm test:rls,pnpm db:typesand CI already did. The runner prints which CLI it used. -
types-driftcan now red locally on a stale mirror. On a machine with no global CLI it used to skip; with your stack up it now runs, and a mirror that no longer matches your schema blocks the turn. CI'sruntime-rlsjob was already judging the same file with the same CLI. Clear it the way the FAIL says:pnpm db:up && pnpm db:typesgit add packages/platform/supabase/src/database.types.ts -
The two
tests/rlsfiles change together. The runner now hands vitestSUPABASE_DB_URL, andauth-trail.test.tsreads it instead of naming a port. If you forkedtests/rls/run-rls.mjs,updatekeeps your fork and parks the incoming copy under.harness/pending/, while the unmodifiedauth-trail.test.tsis re-planted. Your fork must then passSUPABASE_DB_URL: s['DB_URL'] ?? ''to vitest, or that suite throws (locally and inruntime-rls) with a message naming the runner. Merge the parked copy, or add that one key. -
doctorreports your toolchain and can clean residue. Run with this release's CLI, it printsinfolines naming thenode,pnpm, Supabase CLI andpsqlit found, their versions and their pins.doctor --cleandeletes.harness/stop-output/,apps/mobile/dist/,apps/web/.next/,apps/mobile/.expo/,coverage/,.stryker-tmp/and.eslintcachewhen git ignores them and they hold no tracked file (--clean --dry-runlists them first). Your.gitignoreis yours, so an entry it does not ignore is skipped with a note. Neither changes its exit code. The bash guard still denies a recursive force-delete, and its message now namesdoctor --clean. -
A new flag,
node tools/validate.mjs --ci-parity. It gives one local run CI's posture: a missing prerequisite fails instead of skipping, no stamp is honoured, and the run closes by naming each missing prerequisite. Runnode tools/validate.mjs --min-floor --ci-paritybefore you push to see what CI'sstaticjob will say. Without the flag nothing changes, and no chain step, floor or workflow moves. If you forkedtools/lib/gate.mjsandupdateparked the new one, the gates still run and nothing is recorded until you take the parked copy. -
You can edit a migration draft you have not committed. The write guard used to deny every Edit or Write to an existing
supabase/migrations/*.sql, so the filesupabase migration neworsupabase db diff -fhad just written could not be filled in. It now allows it when git reports the file as untracked (?? <path>) and it has one hard link,.harness/manifest.jsondoes not record it, and the session has noGIT_DIR,GIT_WORK_TREE,GIT_INDEX_FILEorGIT_COMMON_DIRset. Once yougit addit, it is history again, and the deny returns naming the proof that failed. Committed migrations stay append-only: themigrationsgate and CI'sappend-onlyjob judge them as before. A migration someone applied to a shared database by hand and never committed reads as a draft too, so commit what you apply. If your install sits in a subdirectory of its git repository, git names the file with that prefix, which is not exactly?? <path>, so the deny stays as it was through 1.0.3. If you forked.claude/hooks/pretool-write-guard.mjs,updateparks the new one under.harness/pending/and your fork keeps denying every existing migration until you take it. -
Two optional register keys; neither is needed, and neither changes a verdict unless you add it. Both registers are escape-listed and write-guarded, so each key lands as a committed human edit.
tools/perf-budget.jsonmay declare"subjects": []beside"emptySubjects": { "reason": …, "reviewedOn": "YYYY-MM-DD" }when nothing in your app is dense enough to measure. The reason needs at least 40 characters after trimming; the date is checked for format only.perf-budgetthen prints a NOTE and names the reason in its OK line. The leak scan and the dense-feature closure still run, so afeatures/*/perfSubject.tsxyou keep needs a reviewedexemptrow for its directory (declaring it as a subject ends the empty state), and the row beside a non-emptysubjects[]reds as a stale escape.tools/store-tunables.jsonaccountDeletionmay carry"registry"when your command registry is notapps/mobile/src/features/actions/registry.ts: a forward-slash.tsor.tsxpath underapps/mobile/src/with no..segment, legal only with"surface": "action". Without the keyexpo-policyreads the default path, as before. If you move the registry, move the mobile entry intools/data-flow.jsonerase.clientswith it, in the same commit: that entry names the file the mobile app starts erasure from, anddata-flowreds once the file it names is gone.
-
expo-policyre-checks more often locally. Its stamp now also hashestools/store-tunables.json,apps/mobile/src,apps/mobile/appandsupabase/functions, which it read without hashing, so an edit to any of them could pass on a warm stamp. Any mobile source or Edge Function edit now re-runsexpo-policy, as a mobile source edit already re-runsbuildande2e. CI never honoured a stamp, so no CI verdict moves. -
A new vertical's events reach the catalog only through a forked generator, until 1.1.0.
tools/gen-event-catalog.mjswalks only the catalogs it imports by name, and it is owned and hash-pinned, so adding your vertical's import line redsgate-integrityunless you keep it as a deliberate fork: re-record its sha as the 1.0.2 section's "Forking an owned file" describes. Without the line, that vertical's events are missing from the committed catalog whilecontractsstays green. Discovery is planned for 1.1.0, and that release's section will say what changes. -
A project adds a citation authority in
tools/mcp/corpus/project.json. Theprovenancegate, the ADR check indocs-syncand thecorpus_searchMCP server read it besidetools/mcp/corpus/index.json, throughtools/lib/corpus.mjs. An absent file counts as empty, so until you create one every verdict is what it was. Once it exists,provenancejudges it: it must parse as exactly{ comment, entries }, each entry passes the same lint as an index entry, and an id the index already pins reds naming both files. The gate's remedies now point atproject.json. The one change that reaches you whether or not you create the file is inwiring; see "The CODEOWNERS case" below. -
updatecan exit 2 over an owned file it holds no record for. A file at a path the harness owns, with no record in.harness/manifest.json, is now judged by its bytes. Unless a release shipped exactly those bytes for that path,updatekeeps your file, parks the incoming copy and exits 2, where it used to overwrite the file and exit 0. See "A harness-owned file with no manifest record" below. -
The 1.0.1 section above and
docs/harness/gates-catalog.mdare corrected. They saidupdateleaves an existingtools/agents.lock.jsonalone, and the 1.0.1 section said thearchitecture-reviewer.mdre-pin leftpromptsred until a human regenerated the lock. Since 0.3.0updatehas re-recorded the lock entry of each agent-surface file it rewrites, hash and model pin together, so an install that had not edited that file owed nothing then and owes nothing now. An agent file you edited still needs a human to runHARNESS_ALLOW_SELF_EDIT=1 node tools/gen-agents-lock.mjs --writeonce you merge its parked copy. Whatupdatedoes has not changed. -
Your
mobile-e2eandintegration-lanejobs reach their suites. Both booted the web app before the env its first request parses was set (SUPABASE_SERVICE_ROLE_KEY,SUPABASE_DB_URLand the threeNEXT_PUBLIC_names), so it answered 500, and waited on/api/trpc/health, which is no procedure (the routers are namespaced), so neither could get past the boot step to the device lane or the live-api proof. Each job now has a "Publish the local Supabase env" step before "Boot the web app", fed fromsupabase status -o env, and waits on/api/trpc/system.health. From this release the two jobs can go red on their suites; treat that as you would any red suite. If you forked.github/workflows/quality-gate.yml,updateparks the new copy under.harness/pending/and neither job gets past the boot until you take those two publish steps and thesystem.healthprobe from it. -
A device red says what was on screen.
tools/check-e2e-device.mjsprintse2e-device: on screen when <flow> failed — ids: …; text: …before its FAIL line, and the evidence upload now keeps Maestro's own debug output, which it writes under a hidden.maestro/directory. The perf-harness journey fails as soon as the screen shows its verdict withoutperf-pass, and the printed text names each breached cap with its measured value. The failure step prints/tmp/web.log, the web app's log. -
The device lane's Metro prewarm can fail the lane.
tools/ci/device-lane.shfetches the bundle a debug build asks Metro for, the Expo virtual entry, where it fetched/index.bundle, a 404 on this SDK, and ignored the result. A Metro that cannot serve the bundle within 600 seconds now fails the lane on that line instead of on the first journey. -
A FIX line repeats path arguments. The command after
FIX[<gate>]: reproduce withnow keeps relative paths and prints aKEY=VALUEargument asKEY=…, so a value passed with--envnever reaches a log. If a script of yours reads FIX lines, it may see more arguments than before.
What only a fresh scaffold gets. These files are seeded, so update never plants them.
Each note says what an existing install does instead.
-
packages/platform/supabase/src/database.types.ts, regenerated with Supabase CLI 2.118.0. The schema is unchanged. The generator's layout moved, and a function that takes no arguments is now typedArgs: Record<PropertyKey, never>where it wasArgs: never. Your catalog'ssupabaseentry decides which CLI your install runs, not this release. Once that CLI generates differently from your committed file,types-driftreds with your stack up, and the diff it prints shows where. Regenerate from your own schema and commit the result:pnpm db:up && pnpm db:typesgit add packages/platform/supabase/src/database.types.tspnpm validateDo not copy the template's file over yours. It describes the example's schema, not your database.
-
The template pins the Supabase CLI exactly. New scaffolds get
supabase: 2.118.0where the catalog said^2.34.3.pnpm-workspace.yamlis seeded, soupdateleaves yours alone. Your committed lockfile already fixes the CLI your CI installs. To take the pin, set it in your catalog, run the commands below, and commit the lockfile and any change topackages/platform/supabase/src/database.types.ts:2.118.0 pnpm install && pnpm db:down && pnpm db:up && pnpm db:types && pnpm validategit add pnpm-lock.yaml pnpm-workspace.yaml packages/platform/supabase/src/database.types.tsIf
rls_structure.test.sqlthen goes red on write grants, the 1.0.2 section is the fix. Do not useupdate --refresh-seeded pnpm-workspace.yaml: it replaces your whole catalog. -
tools/store-tunables.json's//comment documentsaccountDeletion.registry. The key itself works on an existing install without it; the section above says how. -
tools/mcp/corpus/project.json, the empty project corpus.updateprintsnew exemplar available (not auto-planted): tools/mcp/corpus/project.jsoninstead of planting it: every reader takes an absent file as empty, and a file whose gate reads absence as empty is not planted. Pull it when you need it, as described below. -
The seeded sentences that name it. These seeded files now name
project.json, andupdatechanges none of yours. Each sentence is prose, so copying it changes no verdict, and you may copy any of them from the template:AGENTS.md, the first bullet under## Provenance(reworded in place, so its line count and the budgetdocs-syncholds it to do not move);tools/decision-groups.json, the sentence incommentabout the covering entry a group you add needs;tools/provenance-overrides.json, the sentence incommentthat prefers a properly-grouped authority over an override;tools/approved-tools.json, thereasonof thecorpus_searchrow.
The JSON files among them are reviewed escape files: the guards deny an agent's edit, and
gate-integrityreds an uncommitted one, so a human makes the change and commits it.
The project citation corpus: pulling it, and moving a forked index into it
Section titled “The project citation corpus: pulling it, and moving a forked index into it”Through 1.0.3 the only place to add a citation authority was
tools/mcp/corpus/index.json, which the harness owns and gate-integrity hash-pins, and
the provenance gate's own remedy said to extend it. If you did, you re-recorded its sha
(the "Forking an owned file" section above), and since 1.0.2 every update that changed
the index has parked the incoming copy under .harness/pending/ for you to merge by hand.
From 1.0.4 your authorities belong in tools/mcp/corpus/project.json, and the index can
go back to being the harness's.
- Pull the skeleton.
update --refresh-seeded tools/mcp/corpus/project.jsonwrites{ "comment": "…", "entries": [] }. Itscommentgives the entry shape. The file is write-guarded, so a human edits it. - Move your additions. Cut every entry you added to
tools/mcp/corpus/index.jsonand paste it, unchanged, intoentries. Keep each id: aproject/prefix is recommended for new ids and required of none, so every[corpus: <id>]you cite keeps resolving. Do not copy an entry the harness shipped; an id both files pin reds as "already pinned intools/mcp/corpus/index.json". The same red appears if a later release pins an id you chose. Rename your entry and its citations then; the harness's entry stays. - Return the index to a released version. Only once step 2 is done, run
update --refresh-seeded tools/mcp/corpus/index.json --force. Scoped to that one path,--forcediscards your copy, writes the index this release ships and re-records itssha256in.harness/manifest.json, the reverse of the re-record you made when you forked it; no other file is touched. If an earlierupdateparked a copy at.harness/pending/tools/mcp/corpus/index.json, delete it.doctorstops listing the index as a fork, and the nextupdaterefreshes it again. - Commit
project.json,index.jsonand.harness/manifest.jsontogether and runpnpm validate.provenanceis green when every citation resolves and each moved entry still hashes; a red names the file and the entry.project.jsonis a reviewed escape file, sogate-integrityalso reds while an edit to it is uncommitted.
A decision group you add to tools/decision-groups.json still needs an entry tagged with
its key. That entry now goes in project.json, so adding a group no longer forks the index.
The CODEOWNERS case
Section titled “The CODEOWNERS case”tools/mcp/corpus/project.json joins the escape lists that wiring checks CODEOWNERS
against, and wiring asks about every path on them whether or not the file exists. The
shipped /tools/** rule and the * catch-all both give it an owner. wiring reds only
when the last CODEOWNERS rule that matches the path names no owner, for example a bare
/tools/mcp/ line added below /tools/**. GitHub reads that rule as "no review" for
everything under it. The red names the path and the rule. Give that rule an owner, or add
a /tools/mcp/corpus/project.json line with one below it.
A harness-owned file with no manifest record: update keeps it and parks the incoming copy
Section titled “A harness-owned file with no manifest record: update keeps it and parks the incoming copy”Who is affected. An install that holds a file at a path the harness owns, with no
record for that path in .harness/manifest.json. init and update record every owned
file they write, so an install nobody has touched has none. One appears when:
- a release starts shipping a path where your project already had its own file;
enablefound your file at a module path, kept it and parked the module's copy, which records nothing for the path;disablekept a module file you had modified and dropped its record, and you enabled the module again;- someone deleted a record by hand.
What you see. Through 1.0.3 update read a missing record as "unmodified": it
replaced your file with the harness's copy, recorded it and exited 0, and a removed or
renamed migration deleted it. From 1.0.4 update asks whether any release of the
harness shipped exactly the bytes on disk for that path. When one did, the file is
refreshed and recorded as before. When none did, update:
- keeps your file and records nothing for it;
- parks the incoming version at
.harness/pending/<path>and lists the path as drift; - adds one note,
<path> has no manifest record and its bytes match no release of this harness — kept; …; - exits 2, as it does for any drift.
update --dry-runreports the same park.
It parks even when upstream has not changed the file since your install's version: with
no record, nothing shows that your file started as the harness's copy. A removed or
renamed migration leaves such a file in place and says so in a note ending left in place; remove it manually. gate-integrity checks recorded files only, so the file stays
outside it until you resolve the park, as it was before the update, and doctor keeps
naming the parked copy.
Three ways to resolve it.
- Keep your file, merged. Merge what you need from
.harness/pending/<path>into your file, delete the parked copy, and have a human record the file'ssha256in.harness/manifest.json, with modeowned, in a reviewed commit. The file is then a fork, and "Forking an owned file" in the 1.0.2 section above applies on every later update. - Take the harness's copy. Delete your file and the parked copy, then run
updateagain. The path is written fresh and recorded. - Discard yours in one step.
update --refresh-seeded <path> --forceoverwrites that one file with this release's version, records itssha256and notes--force overwrote locally-modified <path>; no other file is touched. Delete the parked copy afterwards. A plainupdate --forcedoes the same, but it also discards every other drifted or forked owned file in the run, so readupdate --dry-runfirst if you use it.
1.1.0 — the sharper verdicts release: the 1.0.0 notes fall due
Section titled “1.1.0 — the sharper verdicts release: the 1.0.0 notes fall due”If your baseVersion is 1.0.0 or later, nothing expires for you. Every ramp 1.0.0
opened carries minVersion 1.0.0, so none of them has ever been live on your install. What
the version bump itself brings a 1.0.x install is the uuid arrival NOTE below, and the new
web-compile chain step brings the gate-list NOTE beside it; each ramp a later 1.1.0 change
opens has its own part of this section. Read what applies
to YOUR baseVersion off node scripts/ci/ramp-expectations.mjs <your base> 1.1.0 in a
harness checkout, and off pnpm validate 2>&1 | grep -E 'NOTE — \(ramp\)|RAMP EXPIRED' in
your own tree, never off this page.
If your baseVersion is below 1.0.0, this is where the 1.0.0 sweep stops being
optional. The 1.0.0 section above is the sweep, and nothing in it changed. If you are
more than one release behind, read the sections above in order before crossing this one.
What ARRIVES (hard) — for installs below 1.0.0
Section titled “What ARRIVES (hard) — for installs below 1.0.0”The NOTE fleet 1.0.0 opened, less its two re-opened sites (the eol arrival and
docs-sync's gate list, both below). Each finding that printed as NOTE — (ramp) with
expires in 1.1.0 now prints under a RAMP EXPIRED banner and reds its step. The numbers
are the items of the 1.0.0 section's "What OPENS" list, which says what to do for each:
suppressions(item 1). Reconciletools/suppressions-allow.jsonto your tree: a directive with no row, and a row naming a directive your tree lacks, both red.resilience(item 2). Every outbound seam your tree added needs itstools/resilience.jsonrow.boundaries, two sites. The behavior-keyed anatomy widening (item 4), and the census module-name closure: an entry intools/exports-walls.jsonwhosemoduleis not in the ownedtools/modules.jsonreds. Fix the name, or remove the entry's sanction.version-sync's vendor-support register (item 5, its first half):tools/support-register.jsonand its platform-fact closure against your Postgres and Node pins.auth-posture's[auth.hook.*]floors (item 6), and only if the auth-event trail migration is in your tree. With neither the migration nor the config sections, nothing is demanded, exactly as before.
scripts/ci/upgrade-sweep.mjs SWEEPS['1.0.0'] is what the upgrade lane's swept leg runs
before it requires graduate to succeed. For these sites SWEEPS['1.1.0'] adds no step
(it says why); what it does carry belongs to later subsections of this section: the grant
bound's grantDoctrine step, and the two seeded browser specs it adopts.
What re-OPENS (a dated NOTE, until 1.2.0) — for every install below 1.1.0
Section titled “What re-OPENS (a dated NOTE, until 1.2.0) — for every install below 1.1.0”version-sync's uuid arrival. The harness re-reviewed its own uuid 7 acceptance at
this release and moved its removalTarget from 1.1.0 to 1.2.0. xcode 3.0.1 still
declares uuid: ^7.0.3, @expo/config-plugins still depends on that xcode, and a
registry sweep of a fresh scaffold (scripts/sweep-registry-deprecations.mjs) still finds
uuid@7.0.3 as the only deprecated package in the production closure. The vendor's
message now also tells ESM codebases to update to uuid@latest and CommonJS codebases to
use uuid@11. Neither is a move this tree can make, because xcode chooses the range.
Your tools/eol.json is seeded, so it still says what it said: "removalTarget": "1.1.0"
on a 1.0.x install, and "0.12.0" or earlier on an older one that never re-dated it. That
date has arrived. update parks the re-affirmation under
.harness/pending/source-fixes.json (a SEEDED SOURCE FIX note naming version-sync),
and version-sync prints the arrival as NOTE — (ramp) with expires in 1.2.0 rather
than a hard red. Re-affirm the row under a release you mean, recording what you
re-checked, or remove the dependency. If you never edited tools/eol.json, taking the
harness's register is the same act:
update --refresh-seeded tools/eol.jsongit add tools/eol.jsonThat pull replaces the whole file, your own rows included, so read the diff before you
commit it. The parked fix clears itself once your file no longer says "1.1.0".
graduate refuses while this NOTE stands.
docs-sync's AGENTS.md gate list. 1.1.0 injects a chain step, web-compile, directly
after build (the last subsection of this section), so your chain has 37 steps while your
seeded AGENTS.md still lists 36, or fewer. The escape that covered this drift reached its
deadline at this very release, so it re-opens at minVersion 1.1.0: while every step your
AGENTS.md lists still exists in the same order, the drift prints as NOTE — (ramp) with
expires in 1.2.0, and the NOTE ends by telling you to paste the chain's names. Do exactly
that: put the 37 names it prints into the "The N gates, in order:" sentence, and change the
"N-step chain" line to 37. A listed step that no longer exists, or a reordering, is your
own drift and stays a hard red. graduate refuses while this NOTE stands.
What update plants, and what else moved
Section titled “What update plants, and what else moved”Owned files, re-planted when your copy still matches a released sha: the hooks under
.claude/hooks/ (their version stamps), this runbook, tools/check-version-sync.mjs (the
re-opened arrival ramp), tools/deferrals.json, tools/auth-posture.json,
tools/check-auth-posture.mjs and docs/harness/gates-catalog.md (the census date below),
tools/conformance-map.json and the comments of tools/check-docs-sync.mjs and
tools/check-workspace-deps.mjs (the sentences that called the 1.0.0 ramps open). The
surface deferral adds tools/ci/surface-deferral.mjs and tools/lib/surface-deferral.mjs,
and re-plants .github/workflows/quality-gate.yml, .github/workflows/osv-scan.yml,
tools/ci/summarize-gate.mjs, tools/lib/gate.mjs, tools/lib/enforcement-surface.mjs,
.claude/hooks/lib/guard-rules.mjs, docs/harness/enforcement-tiers.md and
docs/security/threat-model.md; the register itself is withheld (the subsection below).
Post-merge lane reuse adds tools/ci/lane-reuse.mjs and tools/lib/lane-reuse.mjs, and
re-plants .github/workflows/quality-gate.yml, tools/ci/summarize-gate.mjs and
docs/harness/README.md (its subsection below). The generated skill
references re-plant the vertical-slice skill's
.claude/skills/authoring-vertical-slice/references/dal-dto.md and
references/migration-rls.md, and the .claude/agents/migration-rls-author.md agent, and
update re-records their tools/agents.lock.json entries. The fixture-table pgTAP suites
re-plant tools/conformance-map.json, tools/essential-eight.json and
docs/harness/gates-catalog.md, whose sentences now say where the MFA and audit proofs
run; the suites themselves are seeded and stay as they are (their subsection below).
The reviewer ledger v2 re-plants tools/check-reviewer-verdicts.mjs,
tools/lib/git-diff.mjs, tools/lib/reviewer-verdicts.mjs,
.claude/hooks/subagent-verdict.mjs, .claude/settings.json,
docs/harness/gates-catalog.md and docs/harness/README.md; its three seeded texts are
yours to copy (its subsection below). The session-start brief adds
.claude/hooks/session-brief.mjs, tools/harness-status.mjs and
tools/lib/harness-brief.mjs, and re-plants .claude/settings.json and
docs/harness/README.md; its two seeded texts are yours to copy, and a forked settings file
has one entry to merge (its subsection below). Field notes re-plant tools/lib/gate.mjs,
.claude/hooks/lib/guard-rules.mjs, docs/harness/gates-catalog.md and
docs/security/threat-model.md, and plant the seeded tools/field-notes.json when your
install has none (its subsection below). The reviewer model record re-plants
.claude/hooks/subagent-verdict.mjs, .claude/hooks/stop-validate-gate.mjs,
tools/check-reviewer-verdicts.mjs, tools/lib/reviewer-verdicts.mjs,
tools/lib/agent-roster.mjs, tools/check-docs-sync.mjs, the eight reviewer files under
.claude/agents/ (and re-records their tools/agents.lock.json entries),
docs/harness/gates-catalog.md and docs/harness/README.md; nothing of it is seeded (its
subsection below). The severity contract re-plants the eight reviewer bodies under
.claude/agents/ (update re-records their tools/agents.lock.json entries),
.claude/hooks/subagent-verdict.mjs, tools/check-docs-sync.mjs,
tools/check-reviewer-verdicts.mjs, tools/lib/agent-roster.mjs,
tools/lib/reviewer-verdicts.mjs, docs/harness/gates-catalog.md and
docs/harness/README.md; a reviewer body you edited is kept and the new one parked (its
subsection below). The verdict-demand rule re-plants tools/check-docs-sync.mjs,
tools/lib/agent-roster.mjs, docs/harness/gates-catalog.md, and the comments of
tools/gen-agents-lock.mjs and .claude/hooks/lib/guard-rules.mjs; it changes no reviewer
body (its subsection below). The spec anchors add tools/spec-anchor.mjs and
tools/lib/spec-anchor.mjs, and re-plant specs/_template.md,
.claude/commands/new-feature.md, .claude/commands/adr.md and
.claude/agents/torvalds-reviewer.md (update re-records their tools/agents.lock.json
entries), docs/adr/0000-adr-template.md, docs/harness/README.md and
tools/conformance-map.json; nothing of it is seeded, and your own specs stay as they are.
Review records plant the seeded docs/reviews/README.md when your install has none, and
re-plant docs/adr/0000-adr-template.md, docs/adr/README.md, .claude/commands/adr.md
and .claude/commands/new-feature.md (update re-records the two commands'
tools/agents.lock.json entries); their AGENTS.md sentence is yours to copy (its
subsection below). The proposal flow re-plants .claude/hooks/lib/guard-rules.mjs (the
apply-proposal-invocation rule), .claude/hooks/pretool-write-guard.mjs (its tamper deny
names the flow), docs/harness/README.md and docs/security/threat-model.md (generated; it
lists the new rule). The verb itself is the installer's, so nothing else lands in your tree
(its subsection below). The companion tables re-plant the seven reviewer bodies under
.claude/agents/ other than citation-verifier.md (update re-records their
tools/agents.lock.json entries) and docs/harness/README.md; a reviewer body you edited
is kept and the new one parked (its subsection below). The smaller always-loaded context
re-plants .claude/rules/encryption.md (now a stub), the authoring-e2ee-feature skill
(update re-records its tools/agents.lock.json entry), docs/harness/README.md and
tools/conformance-map.json, and plants the new .claude/rules/e2ee.md; the AGENTS.md
change is yours to take or leave (its subsection below). The register stamps re-plant
tools/check-essential-eight.mjs, tools/check-conformance-map.mjs,
tools/lib/stamp-inputs.mjs, docs/harness/gates-catalog.md and docs/harness/README.md;
nothing of it is seeded. The provenance advisory split re-plants
tools/lib/provenance-rules.mjs, tools/check-sources.mjs,
.claude/hooks/posttool-source-check.mjs, .claude/hooks/lib/hookio.mjs,
.claude/rules/provenance.md, .claude/agents/citation-verifier.md (update re-records
its tools/agents.lock.json entry), docs/harness/gates-catalog.md and
docs/harness/README.md; the comments of your seeded tools/decision-groups.json and
tools/reviewer-triggers.json stay as they are (its subsection below). The self-edit
documentation re-plants docs/harness/README.md (a section, "What
HARNESS_ALLOW_SELF_EDIT=1 relaxes", under Tamper evidence),
docs/harness/gates-catalog.md and tools/check-gate-integrity.mjs (its OK line); no
verdict changes. The event-catalog discovery adds tools/lib/event-catalogs.mjs, and
re-plants tools/gen-event-catalog.mjs, tools/lib/stamp-inputs.mjs, the vertical-slice
skill's scripts/scaffold-slice.mjs and references/dal-dto.md (update re-records their
tools/agents.lock.json entries) and docs/harness/gates-catalog.md; the example's
client.ts and your root package.json are seeded and stay as they are (its subsection
below). The catalog pin floors re-plant tools/lib/harness-brief.mjs; the floors are the
installer's own, and your pnpm-workspace.yaml is never written (its subsection below).
The project workflow rules add tools/check-workflow-hardening.mjs and
tools/lib/workflow-hardening.mjs, and re-plant .github/workflows/actions-lint.yml (a
new workflow-hardening job; harden-runner-coverage changes only its comment),
.github/zizmor.yml (its comment) and docs/harness/gates-catalog.md; nothing of it is
seeded (its subsection below). The SQL history fold re-plants tools/lib/sql-parse.mjs,
tools/lib/stamp-inputs.mjs, tools/check-rls-manifest.mjs, tools/check-tenancy.mjs,
tools/check-migrations.mjs, tools/check-data-flow.mjs, tools/check-db-limits.mjs,
tools/check-query-shapes.mjs and docs/harness/gates-catalog.md, and adds
tools/lib/sql-fold-ramp.mjs; nothing of it is seeded (its subsection below). The grant
bound re-plants tools/lib/table-grants.mjs, tools/lib/sql-parse.mjs,
tools/check-rls-manifest.mjs, tools/lib/enforcement-surface.mjs,
.claude/hooks/lib/guard-rules.mjs (the grant-bound-allow rule),
docs/security/threat-model.md, the migration-rls-author and security-reviewer agents,
the /new-migration, /new-feature and /rls-check commands, both authoring skills and
the vertical-slice skill's references/migration-rls.md and references/tests.md (update
re-records their tools/agents.lock.json entries), .claude/rules/security-invariants.md
and docs/harness/gates-catalog.md, and adds tools/gen-grant-assertions.mjs and
docs/adr/20260930-three-role-revoke.md; its migration and its generated test are withheld,
and its three seeded texts are yours to copy (its subsection below). The query-shape rules
for rpc() and upsert() re-plant tools/lib/query-recorder.mjs, tools/lib/query-shapes.mjs,
tools/lib/sql-parse.mjs, tools/check-query-shapes.mjs, tools/conformance-map.json and
docs/harness/gates-catalog.md; your committed tools/generated/query-shapes.json is seeded
and stays as it is (its subsection below). The i18n syntax-tree walk adds
tools/lib/i18n-tree.mjs and re-plants tools/check-i18n.mjs and
docs/harness/gates-catalog.md; your tools/i18n-allow.json is seeded and stays as it is,
and a site entry in it has a key to take instead (its subsection below).
The web compile step adds tools/check-web-build.mjs and injects it into your
tools/harness.config.mjs as web-compile, after build, and re-plants
tools/check-web-routes.mjs, tools/check-docs-sync.mjs, tools/lib/stamp-inputs.mjs,
tools/validate.floor.json, tools/build-check.mjs (comments),
.github/workflows/quality-gate.yml, docs/harness/gates-catalog.md,
docs/harness/enforcement-tiers.md, docs/harness/README.md, tools/conformance-map.json
and docs/compliance/controls-crosswalk.md; the two new browser specs are withheld, and
your AGENTS.md is yours to update (its subsection below).
The Edge Function checks add tools/check-edge-functions.mjs, and re-plant
eslint.config.mjs, vitest.config.ts, biome.jsonc, tools/check-diff-coverage.mjs,
tools/check-mutation-ratchet.mjs, tools/mutation-scope.mjs, tools/lib/gate.mjs,
tools/lib/mutation-critical.mjs, tools/conformance-map.json,
.github/workflows/quality-gate.yml (a new edge-functions job),
docs/harness/gates-catalog.md, docs/harness/enforcement-tiers.md and
docs/adr/20260720-account-deletion.md; the split of the delete-account function they
reach is seeded, and update withholds it (its subsection below).
The planted-list provenance re-plants tools/check-gate-integrity.mjs and
docs/harness/gates-catalog.md, and adds two owned files: tools/lib/derender.mjs and the
generated tools/lib/planted-shas.json, which lists the escape-list bytes each harness
release planted. Both arrive in the same run as any escape list update plants, and nothing
of it is seeded (its subsection below).
What you may notice afterwards:
- The CLI config census now targets 1.2.0. It was due at 1.1.0 and arrived with the
upstream condition unmet: supabase/cli#5894, the side-effect-free
config validatesubcommand the census waits for, is still open, and the CLI documentsconfig pushas its onlyconfigsubcommand. The date moved in the owned ledger and its three sentences together, sodocs-syncdoes not red on it. Nothing is yours to do. - The
changesjob checks out the tree and runs one more step, the surface deferral below. With no register it printsmobile-deferred=falseand reads nothing else, so every lane runs exactly as before. - On a push to your default branch, some merge-gate lanes finish in seconds.
static,unit,mutation,runtime-rls,e2e-fastandintegration-lanereuse the pull request run that already passed on the identical tree, name it, and appear underREUSEDingate-summary. They now requestactions: readandpull-requests: read. The subsection onquality-gate.ymlbelow says when a lane reuses and when it runs. - The vertical-slice skill's code blocks name
notesand sit betweenskill-regioncomments. Each is now a verbatim copy of a marked span of the harness's own example: thecreateprocedure ofpackages/api/src/routers/notes.tsinreferences/dal-dto.md, and the four permissive policies ofsupabase/schemas/20_notes.sqlas the policy half of the RLS skeleton inreferences/migration-rls.md. That half readsnoteswhere the skeleton's hand-written half above it reads<t>; rename when you copy, as the line beside each block says. A fresh scaffold's copies of those two example files carry the matching markers as comments. Yours are seeded, soupdateleaves them as they are, and nothing asks you to add the markers: the check that reads them runs in the harness repository, not in your chain. If you edited one of the three owned files above, your copy stays, the new one is parked under.harness/pending/, andupdateexits 2 while it stays there. reviewer-verdictsprints NOTEs from the reviewer ledger v2. On an install whosebaseVersionis below 1.1.0 they readNOTE — the reviewer ledger v2 judgement … expires in 2.1.0, followed by each withheld finding; the 1.0.x judgement still decides. On a branch with no upstream the step printsNOTE — no merge baseinstead. The subsection on the reviewer ledger v2 below says what v2 judges and what to copy.- Every session starts with a short brief in its context. Four lines and up to ten
entries: the harness version, base and tier, the upgrades parked under
.harness/pending/, how the last turn in this directory ended, and the reviewers the current diff owes.node tools/harness-status.mjsprints the same thing. It changes no verdict. If your.claude/settings.jsonis a kept fork, you get no brief until you merge its entry, andupdateparks the hook instead of writing it: the subsection on the brief below says what to do. - A new
tools/field-notes.jsonappears, untracked, with an emptynotesobject. It changes nothing until you write a note in it: a failing gate then prints your note on the line after itsFIX[<gate>]:line. Commit it as it is, or with your first note. reviewer-verdictsnames a reviewer verdict that ran on a model other than its pin. Onereviewer-verdicts: FALLBACK MODEL — …line per such verdict, and on a green turn Claude Code shows them to you as a hook warning. On an install whosebaseVersionis below 1.1.0, a security reviewer's PASS on a model its agent file does not name prints asNOTE — the security-reviewer model check … expires in 2.1.0. The subsection on the model record below says what counts.- A reviewer can be sent back for a PASS that lists a blocking finding, and
docs-syncmay print a NOTE about a reviewer body you forked. Every shipped reviewer body now statesBlocking: CRITICAL, HIGH, and a PASS whose reply lists- [HIGH] …is bounced to re-state, the way a reply with no verdict line is. A body you forked has no such line until you add it, so its reviewer is not bounced on this ground, anddocs-syncnames it in a NOTE that expires in 1.2.0. On abaseVersionbelow 1.1.0,reviewer-verdictsmay also printNOTE — the per-reviewer round budget. The subsection on the severity contract below says what to do. docs-syncmay print a NOTE that a reviewer body you forked does not close on its verdict demand. Only a reviewer body whose last paragraph is not the verdict demand, or a fork oftools/lib/agent-roster.mjs, produces one; every shipped body conforms. The subsection on the verdict demand below gives the fix.- The spec template has
##headings, andnode tools/spec-anchor.mjsprints one section. The bold labels ofspecs/_template.mdare now headings, with a newDecisionssection whose entries each take a###heading, and a heading's id is its GitHub anchor.node tools/spec-anchor.mjs specs/<feature>.mdlists a spec's ids, andnode tools/spec-anchor.mjs specs/<feature>.md#<id>prints one section./new-featureputs the sections a slice implements in thetorvalds-reviewerbrief, and/adrcites them in Traceability. Your own specs are not rewritten: one with bold labels has no ids, and the reviewer reads it whole. Nothing reads the tool's output, and no gate changes. If you edited one of the owned files above, your copy stays, the new one is parked under.harness/pending/, andupdateexits 2 while it stays there. - A new
docs/reviews/README.mdappears, untracked, and/adrand/new-featurepoint at it. It says where review rounds go. No gate reads it, so it changes no verdict. If you already had a file at that path,updateleft it exactly as it was and parked nothing. - An agent that wants to change a register under
tools/stages a proposal instead of asking you forHARNESS_ALLOW_SELF_EDIT=1. The write guard's deny now tells it how, so aharness-proposals/<id>.jsoncan appear in your tree.doctorlists it asinfo, and it changes nothing until you apply it. The bash guard denies an agent theapply-proposalcommand. The subsection on proposals below says how to review and apply one. - Reviewer replies carry
<id>: present (file:line)and<id>: absentlines. Every reviewer body butcitation-verifier's now lists what a change must bring, and its reviewer accounts for each row that applies. An absence is a finding at the severity the body already gives that rule, so a reviewer can now BLOCK on a revoke, a register row or apage.meta.tsthat the diff never mentions. No gate changes. If you edited a reviewer body, your copy stays and the new one is parked under.harness/pending/; the subsection on companion tables below says how to take the table. .claude/rules/encryption.mdis shorter, and.claude/rules/e2ee.mdis new. The stub keeps what applies while thee2eemodule is off, each item with the check that holds it. The full rule loads when a file underpackages/platform/crypto/,apps/*/src/host/ordocs/modules/e2ee/is read, and theauthoring-e2ee-featureskill's Step 0 now tells the agent to read it first. No verdict changes. If you editedencryption.mdor the skill, your copy stays, the new one is parked under.harness/pending/, andupdateexits 2 while it stays there; a file of your own already at.claude/rules/e2ee.mdis kept the same way, as the 1.0.4 section describes for a harness-owned path with no manifest record.- A warm validate prints two more
STAMPEDlines:essential-eightandconformance-map. Thedocs-syncstep's two register scripts now skip when nothing their verdict reads has changed since their last green run: the register, the chain config, the workflows and, for the map, the guard rules, the module list,docs/modules, the generator and its two documents.essential-eightstill checks[storage]and the upload surfaces on every run, before it looks at its stamp. CI andvalidate --ci-parityjudge both in full, and the first run afterupdatere-proves both. Nothing is yours to do. If you forkedtools/lib/stamp-inputs.mjs, both judge in full until you merge the parked copy. provenanceprintsADVISORYlines, and the source-check hook stops blocking on some uncited sites. An uncited or wrongly grounded site invector-index,llm-samplingortuning-constantsalone now printsprovenance: ADVISORY (n) — file:line [class]and passes, and the hook hands the agent a note instead of exiting 2. Nothing that was green turns red. The subsection on advisory classes below says how to keep a class mandatory.gate-integrity's OK line can say a check did not run. WithHARNESS_ALLOW_SELF_EDIT=1set, it readsescape-list commit rule not runandthreshold-config commit rule not runwhere it printed a count of clean files, and with no git work tree it also readshistory check not run. It exits as it did before. The doctrine's new section lists everything the flag relaxes, and its Stop-hook cost section no longer suggests commentingbuildore2eout, which the floors andgate-integrityrefuse.contractsre-runs locally after an edit to your rootpackage.json, andpnpm genmay name a vertical as not catalogued. The event-catalog generator now reads the rootpackage.jsonto decide whether the example's old export still applies, so the file joins thecontractsstamp; CI never honoured a stamp.tools/generated/event-catalog.jsonregenerates unchanged. A direct run of the generator prints<package> is not cataloguedfor each other vertical whose./clientdoes not exportEVENT_CATALOG; those were not catalogued under 1.0.x either, and the gate prints nothing new. If you forked the generator, your copy stays, the new one is parked under.harness/pending/, andupdateexits 2 while it stays there: the subsection on the event catalog below says what to do.doctormay warn that a catalog pin is below a security floor, and exit 2. If yourpnpm-workspace.yamlstill pinsvitestor@vitest/coverage-v8at 4.1.10, as every release from 0.1.3 through 1.0.2 scaffolded it,doctorprints one warning per package and exits 2 where it exited 0, andupdateprints aCATALOG PIN FLOORnote per package and parks.harness/pending/pin-floors.json. No gate reds on it, andupdate's exit code does not move. The subsection on catalog pin floors below says what to do.actions-lintruns a new job,workflow-hardening, and it may print NOTEs about workflows you wrote. It checks every workflow under.github/workflows/for a workflow-level bash default, a ceiling on every job and harden-runner as each job's first step. Every workflow the harness ships passes. On an install whosebaseVersionis below 1.1.0 a finding prints asworkflow-hardening: NOTE — (ramp) …and the job stays green until 1.2.0. The job also runs when.harness/manifest.jsonchanges. The subsection on your own workflows below gives the sweep.- Six SQL gates may print a NOTE about your migration history. Only when it holds a
top-level
DROP TABLEorALTER POLICY(or, forschema-rls, aDROP POLICY), and only for a finding the gates now see because they read those statements. On an install whosebaseVersionis below 1.1.0 each such finding readsNOTE — (ramp)under a NOTE that expires in 1.2.0. A table created and later dropped no longer redsschema-rlsas undeclared. The subsection on the SQL history fold below says what to sweep. schema-rlsprints NOTEs about your grants, namingprofilesandnotesat least. It now reds a table privilegeanonorauthenticatedholds that no policy admits, a table that keeps the platform's default privileges for any ofanon,authenticatedorservice_role, and a missing or stalesupabase/tests/rls_grants.generated.test.sql. Every install below 1.1.0 meets the first two onprofilesandnotes, and an install that never applied 1.0.2's migration meets them on seven more tables. On an install whosebaseVersionis below 1.1.0 each readsNOTE — (ramp)under a NOTE that expires in 1.2.0, andgraduaterefuses while they stand. The subsection on the grant bound below gives the SQL, then the command.pnpm genrecords a DAL'srpc()andupsert()calls, andquery-shapesjudges them. If no probed DAL function of yours makes either call, the manifest regenerates unchanged and nothing moves. If one does, it could not pass under 1.0.x:pnpm genthrew on the rpc, and an upsert failedquery-shapeswith advice about OFFSET pagination. Runpnpm genagain and commit the manifest; the subsection on rpc and upsert below says what the gate now checks.- The
i18nStop step may print NOTEs that expire in 1.2.0, and every FAIL line ends with a{"key": …}entry. The step now also parses each file with yourtypescriptand finds copy its regular expressions never matched, such asaccessibilityLabel={'Close dialog'}or<h2>Plans from $5</h2>. On an install whosebaseVersionis below 1.1.0 each such string prints as a NOTE, and so does each{"site": "file:line"}entry intools/i18n-allow.json, with the key that replaces it. A finding the step already reported stays a red, and one only its regular expressions report says it retires with them in 1.2.0. Without an installedtypescriptthe step says the walk did not run, and in CI it fails. The subsection on the i18n syntax-tree walk below says what to do. - Your chain has a 37th step,
web-compile, and it builds your web app. The first validate afterupdaterunsnext buildoverapps/web, which takes tens of seconds, and printsweb-compile: STAMPEDon every later run until something underapps/web,packages, the base tsconfig, the workspace file or the lockfile changes.staticruns it on every pull request.route-manifestmay print a NOTE namingnotesandsecurity, anddocs-synca NOTE about your gate list. Your quality-gate workflow'sweb-buildandweb-e2ejobs gain one step that builds the workspace declarations first. The subsection on the web compile step below says what each NOTE asks of you. lintnow readssupabase/functions, and a newedge-functionsjob may print NOTEs. AgetSession()call or a rawcrypto.subtlein one of your own Edge Functions now redslintat once, as it would anywhere on the server graph, and so does a function over cognitive complexity 15 (the delete-accountindex.tsyour install carries is exempt from that one until 1.2.0).doctorwarns that the seeded delete-account function still has its 1.0.x shape, andupdatenotes four new files it did not plant. On an install whosebaseVersionis below 1.1.0,edge-functions,diff-coverageand the mutation lane printNOTE — (ramp)lines aboutsupabase/functionsuntil 1.2.0. The subsection on Edge Functions below gives the sweep.gate-integrity's plant NOTE reads differently, and an untracked escape list can be a finding. For an escape listupdatejust planted, the NOTE now says its bytes match the manifest record and a harness release planted exactly these bytes (tools/lib/planted-shas.json); commit it with the rest of the upgrade, as before. An untracked escape list whose sha matches its manifest record but that no release planted, such as one created by hand with its sha written into.harness/manifest.json, now readsgate-integrity: NOTE — (ramp 1.1.0) … no harness release planted these byteson an install whosebaseVersionis below 1.1.0, until 1.2.0, and fails after. The subsection on planted escape lists below gives the sweep.
A surface you have not built yet: tools/surfaces.json
Section titled “A surface you have not built yet: tools/surfaces.json”A project that builds its web surface first can now say so, instead of forking the owned
quality-gate.yml. A live row skips mobile-e2e and perf-lane on a pull request, and
nothing else: scheduled and dispatched runs keep both lanes, every other job ignores the
file, and gate-summary prints the row's reason beside each lane it skipped.
update withholds the register from an existing install. It is seedOnInitOnly in the
1.1.0 record: its reader treats an absent register as an empty one, which defers nothing,
so an install that never writes a row sees no change. Fresh scaffolds get the empty
register. Creating one is a committed, reviewed act. The file is write-guarded, so a
human writes it, and it is on the escape lists, so gate-integrity reds while an edit to
it is uncommitted and wiring asks CODEOWNERS about it:
{ "//": "Surfaces this project has not built yet.", "deferrals": [ { "surface": "mobile", "deferredUntil": "YYYY-MM-DD", "reason": "one line: why the app is not built yet" } ]}mobile is the only surface a row may name, one row per surface; deferredUntil is the
last deferred day; the reason is non-empty and on one line. Any other shape makes the
changes job red, after it reports mobile-deferred=false. Check a row before you push:
node tools/ci/surface-deferral.mjs --mode=pr prints the two output lines and, on stderr,
whether the row is live and how many files it compared.
What ends a deferral, without anyone touching the row.
- The content tripwire. The row is void, and both lanes run, as soon as any tracked
file under
apps/mobile/differs from the sha256 the installer recorded in.harness/manifest.json, a file is added there or a recorded one is gone, or the manifest is absent. Your first real screen re-arms the lanes. Changes to the shared packages do not void the row on their own; one that forces an edit underapps/mobile/voids it through that edit.update --refresh-seeded <path>rewrites a file and its record together, so it keeps the row live; hand-editing the manifest to do the same is an edit to the write-guarded, CODEOWNERS-covered.harness/, and the date still ends it. - The date. After
deferredUntila pull request runs the lanes again, and the scheduledfloor-reviewjob (osv-scan.yml) reds on the lapsed row, naming it. It also reds on a void or malformed row. It never reds a pull request.
A retrofit install planted no apps/mobile/ files. If your project has its own mobile
app there, its files have no records, so a mobile row is void from the start. If nothing
is tracked or recorded under apps/mobile/, the row is live and the CLI says that zero
files were compared.
quality-gate.yml: a push reuses its pull request's green lanes on an identical tree
Section titled “quality-gate.yml: a push reuses its pull request's green lanes on an identical tree”update re-plants .github/workflows/quality-gate.yml, tools/ci/summarize-gate.mjs and
docs/harness/README.md when your copies still match a released sha, and plants two new
owned files, tools/ci/lane-reuse.mjs and tools/lib/lane-reuse.mjs. What changes for
you:
- On a push to your default branch,
static,unit,mutation,runtime-rls,e2e-fastandintegration-lanemay finish in seconds. Each first asks whether the pull request you just merged passed that same job on the identical tree, at its final head. On a hit its steps are skipped, a notice names the pull request run it relied on, andgate-summarylists the lane asREUSEDwith that run. The job still reports success, because it cites one. A merge of a branch that was behind its base, a conflict resolution, a direct push, a red or unfinished pull request run, or a GitHub API error runs everything, as before. Nightly and manually dispatched runs never reuse. - Those jobs now request
actions: readandpull-requests: readbesidecontents: read, to list the pull request's runs, their jobs and job logs, and the pull request a push merged. Nothing is written. If the token cannot read them, the lookup misses and the lane runs in full. - A pull request from a fork never reuses. Its run executed workflow text from a repository you do not control, so its merge runs every lane, which is what it did before.
- If you forked
quality-gate.yml,updatekeeps your fork and parks the incoming copy at.harness/pending/.github/workflows/quality-gate.yml, and it exits 2 while that copy is there ("Forking an owned file" in the 1.0.2 section). Your fork keeps running every lane on every push until you merge the new steps in. When you merge them, copy each lane's lookup, hit-report and record steps, itspermissions:andoutputs:blocks and thesteps.reuse.outputs.hit != 'true'condition on every step in between, then re-record the sha. A step you add to one of those lanes later needs the same condition.
The pgTAP suites prove the rails on a fixture table: fresh scaffolds only
Section titled “The pgTAP suites prove the rails on a fixture table: fresh scaffolds only”A fresh 1.1.0 scaffold's supabase/tests/rls_isolation.test.sql, mfa_aal2.test.sql and
audit_immutability.test.sql no longer prove isolation, the aal2 rail and audit capture by
writing to the example's table. Each builds a table of its own, public.pgtap_fixture,
inside its test transaction, between -- fixture:begin and -- fixture:end, and the
suite's ROLLBACK removes it. Its DDL is the RLS skeleton of
.claude/skills/authoring-vertical-slice/references/migration-rls.md with the table
renamed, plus the example's title and body columns; mfa_aal2 adds the example's MFA
rail and audit_immutability its audit trigger, both renamed. The fixture runs on your real
private.member_org_ids(), private.member_ranks(), private.mfa_satisfied() and
audit.write_row(). A project that deletes the example keeps these proofs.
Your install keeps its suites. They are seeded: update does not rewrite them, and
nothing in your chain asks for the new ones. If you never edited a suite, pull the new one
with npx next-expo-supabase-agent-harness@latest update --refresh-seeded supabase/tests/<file>,
the channel the 1.0.2 section uses for rls_structure.test.sql, then run
pnpm db:reset && pnpm test:rls. If you did edit it, --refresh-seeded keeps your copy,
parks the new one under .harness/pending/supabase/tests/, and exits 2: merge by hand, by
adding the fixture region before the suite's first role switch and pointing your
behavioural assertions at public.pgtap_fixture.
The new suites still name the example. rls_isolation's recursion probe still reads
public.notes, with every other RLS target, and the structural checks still name it:
mfa_aal2's shape assertions read the notes_mfa_aal2 policy, and audit_immutability's
coverage read lists notes among the audited tables, as rls_structure.test.sql does. A
project that removes the example still edits those lines, as it did before. What no pgTAP
suite does any more is write to public.notes: the example's own rank floors, MFA policy
and audit trigger are judged statically, by schema-rls and tenancy, and the supabase-js
suite under tests/rls/ still reads it across tenants.
reviewer-verdicts judges the branch: the reviewer ledger v2 (a NOTE until 2.1.0)
Section titled “reviewer-verdicts judges the branch: the reviewer ledger v2 (a NOTE until 2.1.0)”Through 1.0.4 the Stop step owed reviewers on the diff against HEAD and judged one
prompt. A migration committed before the turn ended owed nobody, a deleted policy owed
nobody, a BLOCK was forgotten when you next spoke, and a reviewer that blocked, read the fix
and passed could not clear its own BLOCK. From 1.1.0 the step also runs the reviewer ledger
v2, which judges the branch:
- The owed set is the diff from the merge base with the branch's upstream (the PR base
in CI) to the working tree, plus untracked files, with deletions and both sides of a
rename included and
.harness/left out. Committing does not clear it. Pushing clears whatever the upstream then holds: on a branch whose upstream is its own remote branch, which is whatgit push -usets, the owed set after a push is only what is not pushed yet. An upstream set to the branch you will merge into (git branch --set-upstream-to=origin/main) keeps the whole branch owed. - The ledger is read for the whole session. A BLOCK stands until the SAME reviewer run
(its
agent_id) returns PASS at the current tree. To clear one, fix what it named and resume that reviewer withSendMessageto itsagent_id. A fresh run of the same reviewer is a second opinion and retracts nothing. A PASS whose tree has not moved stands for later prompts of the same session; a PASS from another session counts for nothing. - A PASS counts only for the tree it was dispatched on. The hook now also runs on
SubagentStartand records the reviewer's digest in.harness/reviewer-dispatch.jsonl. At the verdict it records the start and stop digests besidepath_state, and the step counts the PASS only when both equal the tree at Stop. Let a reviewer finish before you edit the paths it is reading. torvalds-reviewerandcitation-verifierare owed on every non-empty diff, through the newwholeTurnclass oftools/reviewer-triggers.json.- With no upstream, v2 does not judge. A fresh
git init, or a branch with no upstream configured, printsNOTE — no merge baseand keeps the 1.0.x judgement. Set one withgit branch --set-upstream-to=<remote>/<branch you will merge into>.
The ramp. If your baseVersion is below 1.1.0, the 1.0.x judgement still decides and
v2's findings print as NOTEs that expire in 2.1.0. Neither relaxation applies to you yet:
a BLOCK still stands for the rest of its prompt, and a PASS from an earlier prompt still
does not count. They arrive with the tightening, when you graduate or when the ramp
expires. A fresh 1.1.0 scaffold is judged by v2 from the start.
What to do, in this order.
-
If
updateparked.claude/hooks/subagent-verdict.mjs, merge it first. A 1.0.x copy of the hook reads aSubagentStartpayload as a reviewer that ended without a verdict: every reviewer dispatch then exits 2 (whichSubagentStartdoes not block), appends a bounce to.harness/verdict-bounces.jsonland records a blocked turn outcome. Ifupdatere-planted.claude/settings.jsonwhile it parked your hook, take the hook'sSubagentStartbranch now, or remove theSubagentStartblock until you do. -
If
updateparked.claude/settings.json, add theSubagentStartblock besideSubagentStop, after step 1. Without it every PASS reds, once v2 is live, with a finding that names this block:wiringcannot see it, because the hook is already wired underSubagentStop."SubagentStart": [{"hooks": [{"command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/launch.mjs\" subagent-verdict.mjs","timeout": 10,"type": "command"}],"matcher": "*"}], -
Add the
wholeTurnclass totools/reviewer-triggers.json. The file is seeded, soupdatenever rewrites it. Put these two keys beforenotTriggered, and delete thetorvalds-reviewerandcitation-verifierrows fromnotTriggered. Until you do, v2 owes no whole-turn reviewer on your install."wholeTurnMatching": "A reviewer in `wholeTurn` is OWED whenever the owed diff is non-empty, whatever its paths, and its verdict binds to a digest over the WHOLE diff: any later change anywhere in it sends the PASS stale. The owed diff is the one tools/lib/git-diff.mjs reviewChanges() returns (the merge-base diff with deletions, 1.1.0), and the class is judged by the reviewer ledger v2 in tools/check-reviewer-verdicts.mjs.","wholeTurn": [{"agent": "torvalds-reviewer","why": "AGENTS.md says it runs 'before finishing', which is EVERY turn. No path pattern expresses that: a path trigger would fire it on everything (noise) or on an arbitrary subset (a rule that reads as coverage and is not). Through 1.0.4 it sat in notTriggered for that reason, and its verdicts were recorded and never judged. The whole-turn class is the different mechanism a whole-turn obligation needed."},{"agent": "citation-verifier","why": "Its definition says it MUST BE USED before finishing a feature, so it is summoned before a turn ends, like torvalds-reviewer, and until 1.1.0 its verdict was recorded and never judged. The `provenance` chain gate still reds tree-wide on an uncited decision site; this class judges the other half, that the reviewer which checks each citation resolves actually ran on this diff."}], -
Update the
reviewer-verdictssentence inAGENTS.md. Also seeded. In the Stop-chain bullet, replace the text from "The last one is the only check" to "Triggers are reviewed data in" with this, and keep a period at the end of the line that ends the bullet: a backticked lowercase name before the first(would read todocs-syncas a Stop step.The last one is the only check in theharness whose subject is the TURN rather than the tree: every reviewer whose`MUST BE USED` paths this branch's diff touched must have returned`VERDICT: PASS` on the tree it was dispatched on, recorded by theSubagentStart and SubagentStop hooks (the diff runs from the merge base withthe branch's upstream and keeps deletions; `torvalds-reviewer` and`citation-verifier` are owed on every non-empty diff; a BLOCK stands until thesame reviewer passes). Triggers are reviewed data in -
Ignore the local stack's branch marker.
.gitignoreis seeded too.supabase startwritessupabase/.branches/_current_branchbesidesupabase/.temp/, and an untracked file is part of the owed set, so without this line a clean tree with the stack up owes both whole-turn reviewers. Add it under yoursupabase/.temp/line:supabase/.branches/ -
Before you graduate, run the owed reviewers once more. Entries a 1.0.x hook wrote carry no v2 digests, so v2 counts none of them. Read what v2 would say off the NOTEs, with an upstream set, and graduate when they are gone.
The session-start brief: node tools/harness-status.mjs and a SessionStart hook
Section titled “The session-start brief: node tools/harness-status.mjs and a SessionStart hook”A SessionStart hook, .claude/hooks/session-brief.mjs, prints the harness brief into every
session's context when it starts, resumes, clears, compacts or forks, and
node tools/harness-status.mjs prints the same bytes in a terminal:
harness 1.1.0 (base 1.0.4) · tier standard · mode bootstrapparked: 1 - `.claude/settings.json`last turn in this directory: greenreviewers owed by the current diff: 1 - security-reviewer (`supabase/migrations/20260930000000_x.sql`)The hook blocks nothing, reads no stdin, writes nothing and exits 0 on every path. A value
that fails its validator prints as (unprintable), and a source that cannot be read prints
<field>: unavailable. The owed reviewers are the set Stop step reviewer-verdicts decides
on, so on a baseVersion below 1.1.0 they are the 1.0.x set until you graduate, and with no
upstream they are the uncommitted changes only. .claude/settings.json,
tools/harness-status.mjs and tools/lib/harness-brief.mjs are owned and reach you with
this update. Three things do not:
-
The
harness:statusscript.package.jsonis seeded, soupdateonly printsnew template script not installed: "harness:status". Add it by hand if you want it; nothing in the harness's own text depends on it, because every owned file citesnode tools/harness-status.mjs:"harness:status": "node tools/harness-status.mjs", -
The
AGENTS.mdline. Also seeded. Copy it into the Commands list if you want agents told about the command; it spends one line of the file's~350budget:- `node tools/harness-status.mjs` — install state, parked files, last turn, reviewers owed.Do not write
pnpm harness:statusthere unless you added the script:docs-syncreds anAGENTS.mdthat advertises a scriptpackage.jsonlacks. -
With a kept-fork
.claude/settings.json, the hook itself.wiringreds a hook file in.claude/hooks/that nothing wires, so whenupdatekeeps your settings fork it parks the new hook at.harness/pending/.claude/hooks/session-brief.mjs, beside the parked.harness/pending/.claude/settings.json, and says so in a note;wiringstays green. To adopt it, merge theSessionStartblock from the parked settings into yours, besidePreToolUse(the settings keep their events in alphabetical order), re-record your fork (the 1.0.2 section, "Forking an owned file"), and runupdateagain: with the entry in your settings,updatewrites the hook and records it. Then delete both parked copies. Do not move the parked hook into.claude/hooks/by hand: a hook placed there by hand has no manifest record, sogate-integritycannot hash it."SessionStart": [{"hooks": [{"command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/session-brief.mjs\"","timeout": 10,"type": "command"}],"matcher": ""}],The command runs the hook directly, not through
launch.mjs: SessionStart cannot block, so the launcher's "failing closed, action blocked" would be false there.gate-integrityaccepts this form.
Field notes: tools/field-notes.json
Section titled “Field notes: tools/field-notes.json”A place for what your project has learned about a gate in its own tree: the fixture it
trips on, the fix that is usually right. update plants the empty skeleton when your
install has none, and never touches one you already have. It stays untracked until you
commit it. No gate judges its contents, so the planted skeleton cannot turn a validate red;
the format step checks it like any JSON under tools/.
{ "comment": "…", "notes": { "tenancy": "one line: what usually causes this red here, and the fix that is usually right" }}When that gate fails, its output ends with the usual FIX[tenancy]: line and then
FIELD-NOTE[tenancy]: <your text>. Nothing else changes: the exit code, the finding and
the FIX line are the same with or without a note, and a note never prints on a pass, a
stamped run or a local skip.
- Key on the token the FAIL line prints (
<gate>: FAIL), which is not always the chain step's name: thedocs-syncstep's scripts report asdocs-sync,essential-eightandconformance-map, and the scheduled floor review reports asfloor-review. A key outside[a-z0-9-], and a value that is not a string, are ignored. - One line of text. Whitespace collapses to single spaces, control and format
characters are removed, and the text is cut at
FIELD_NOTE_MAX_CHARScode points (tools/lib/gate.mjs). A file that is not valid JSON prints one line saying so under every failing gate, and no note. - A human writes it. The file is write-guarded (
field-notes): its text reaches an agent at the moment it decides how to make a red go away, so an agent cannot edit it. - Where no note can print.
format,types,lint,dead-code,architecture,unit,mobile-unitandrls-isolationprint no<gate>: FAILline, and a long failed Stop step may keep a note only in.harness/stop-output/<step>.log. The catalog's "Shared behavior" paragraph has the details.
A reviewer verdict records the model it ran on (security reviewers: a NOTE until 2.1.0)
Section titled “A reviewer verdict records the model it ran on (security reviewers: a NOTE until 2.1.0)”A reviewer's agent file pins one model, and Claude Code can still run it on another: a
per-invocation model parameter, CLAUDE_CODE_SUBAGENT_MODEL (with
CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1), an availableModels allowlist that substitutes for a
blocked model, or a fallbackModel chain that fails over. From 1.1.0 the SubagentStop hook
records in each ledger entry the model that wrote the verdict, read from the subagent's
own transcript, and pinned, whether it is the pin. reviewer-verdicts judges it:
- The pin counts, and an alias pin counts for its whole family.
model: opuscounts for any Opus model ID, whichever version the alias resolved to. A full model ID counts only as itself. - A listed model counts, and is named. Each shipped reviewer file now carries a
harnessFallbackModelsline, next tomodel. Claude Code ignores the key and picks no model from it;reviewer-verdictsreads it. To run a reviewer on a listed model, pass that model as the per-invocationmodel. - Any other model is named, never silently counted. One
FALLBACK MODELline per verdict, shown to you as a warning when the turn ends green. - For
security-reviewer,web-security-reviewerandmobile-security-reviewerit does not count. A PASS on a model that is neither the pin nor listed, or one whose model the hook could not read (model: null), reds.
Entries written before the update carry no model field and are judged exactly as before.
The ramp. If your baseVersion is below 1.1.0, the security finding prints as a NOTE
that expires in 2.1.0. A fresh 1.1.0 scaffold is judged from the start.
What to do.
- If your configuration forces a model on subagents, check it against the security
reviewers' lists. The settings that do are
CLAUDE_CODE_SUBAGENT_MODEL_FORCE, anavailableModelsallowlist that blocks a reviewer's pin, and afallbackModelchain. A re-run lands on the same model, so re-running does not clear the finding. Either lift the setting for your security reviews, or, if you accept that model for security review, add it to that reviewer'sharnessFallbackModelsin a reviewed diff. The agent files are write-guarded and hashed, so that is a human act: edit underHARNESS_ALLOW_SELF_EDIT=1, re-record the lock withHARNESS_ALLOW_SELF_EDIT=1 node tools/gen-agents-lock.mjs --write, and re-record the file's sha in.harness/manifest.json(the 1.0.2 section, "Forking an owned file"). - If a reviewer cannot run on its pin (the Agent tool reports a model error, and the
Stop red says the reviewer did not run), dispatch it with the Agent tool's
modelset to a model itsharnessFallbackModelsline names. The red says so too. That verdict counts, and is named. - If
updateparked one of your reviewer files, your copy has no list, so only its pin counts. Merge the parked copy to take the list. - If every PASS records
model: null, the hook cannot find the model in your Claude Code's transcript. That is a harness defect, not yours: report it with your Claude Code version. - Before you graduate, read the NOTEs, fix what they name, and graduate when they are gone.
Reviewer bodies state which severities block, and review rounds get a budget (NOTEs until 1.2.0)
Section titled “Reviewer bodies state which severities block, and review rounds get a budget (NOTEs until 1.2.0)”Until 1.1.0 no reviewer body said which severity justifies VERDICT: BLOCK, so a nit and a
vulnerability both could, and a reviewer could PASS over a HIGH finding it listed itself.
Nothing bounded a fix-and-re-review loop either, except the turn-wide block cap that every
kind of block spends. From 1.1.0:
-
Every reviewer body states its severity contract, on two lines of their own before its closing verdict paragraph, with the finding format after them:
Severities: CRITICAL, HIGH, MEDIUM, LOWBlocking: CRITICAL, HIGHWrite each finding on a line of its own as `- [SEVERITY] file:line — …`, with a severityfrom the `Severities:` line. Return `VERDICT: BLOCK` when a finding at a `Blocking:`severity stands, and `VERDICT: PASS` otherwise: a PASS that lists a blocking finding issent back to you to re-state.docs-syncholds the two lines: each present once,Blocking:a subset ofSeverities:, andBlocking:holding at leastCRITICALandHIGH. -
The SubagentStop hook bounces a PASS that lists a blocking finding. It reads the
Blocking:line from the body of the reviewer that stopped. A reply that endsVERDICT: PASSand has a line starting- [HIGH] …exits 2, so the reviewer re-states, and.harness/verdict-bounces.jsonlrecords the shapepass-with-blocking-findingwith the finding lines. A BLOCK is never bounced on this ground, and neither is a body with noBlocking:line, which keeps the 1.0.x behaviour. -
Each verdict records its round, and the Stop step holds each reviewer to a budget of 3 rounds per review loop. A BLOCK opens a loop, every later verdict of that reviewer in the session is its next round, and the loop closes when the same run (its
agent_id) passes over a tree that did not move under it. A loop still open after its third round is spent:reviewer-verdictsreds with every blocking finding the reviewer recorded and tells the agent to stop and hand them to you, and a PASS recorded after that never clears it. The budget is judged over the change set the reviewer ledger v2 keys, so on a branch with no upstream it does not judge, andNOTE — no merge basesays so. It is a constant of the ownedtools/lib/reviewer-verdicts.mjs, not a field of the seededtools/reviewer-triggers.json, so there is nothing to add to your trigger table.
When a budget is spent. The step keeps redding for as long as that reviewer is owed in this session, and the agent is told not to run it again. The findings are yours to decide: fix them yourself, or with the agent in a NEW session, where every budget starts afresh; or change the diff so it no longer owes that reviewer. Starting a new session is the reset, and it is a human's act, not the agent's.
The ramps. If your baseVersion is below 1.1.0, both checks print as NOTEs that expire
in 1.2.0:
docs-sync: NOTE — the reviewer severity contract (ramp: live from baseVersion 1.1.0; this install's baseVersion is <yours>; expires in 1.2.0). …docs-sync: NOTE — (ramp) .claude/agents/<name>.md: no `Blocking:` line — …reviewer-verdicts: NOTE — the per-reviewer round budget (ramp: live from baseVersion 1.1.0; …; expires in 1.2.0). …From harness 1.2.0 the same findings print under RAMP EXPIRED and red their step, and on an
install whose baseVersion is 1.1.0 or later they red from the start. The hook's bounce is
not ramped: it fires only for a body that states Blocking:, and an existing install gets
one only when update re-plants an unmodified body or you add the lines yourself.
Adding the contract to a forked reviewer body. .claude/agents/ is write-guarded and the
lock is a human act, so each step is yours, not an agent's.
-
Add the lines. Merge the parked copy from
.harness/pending/.claude/agents/<name>.mdifupdateleft one, or paste the block above into your body, just before its closingEnd with exactly one final line: …paragraph, which must stay last. KeepCRITICALandHIGHon theBlocking:line; you may addMEDIUMto block on more. -
Re-lock the agent surface.
promptsreds the edited body until the lock moves, and the bash guard refuses the writer from an agent's shell, so a human runs:HARNESS_ALLOW_SELF_EDIT=1 node tools/gen-agents-lock.mjs --write -
Re-record the body's sha in
.harness/manifest.json, in a reviewed commit, as "Forking an owned file" in the 1.0.2 section describes, and delete the parked copy.
A reviewer of your own, outside the eight the harness ships, is not judged by docs-sync. Add
the two lines to it if you want the hook to hold its PASSes to its findings. If the docs-sync
finding names tools/lib/agent-roster.mjs, or the reviewer-verdicts one names
tools/lib/reviewer-verdicts.mjs, your fork of that lib predates the contract: merge the
parked copy under .harness/pending/tools/lib/ into it and re-record its sha.
A reviewer body must close on its verdict demand (docs-sync, a NOTE until 1.2.0)
Section titled “A reviewer body must close on its verdict demand (docs-sync, a NOTE until 1.2.0)”The SubagentStop hook records a reviewer's PASS only when VERDICT: PASS is the last line
of its reply. A reviewer body that asks for anything after that line, such as "Follow it
with the top 3 fixes", which two shipped bodies said at v1.0.1, gets every obedient PASS
bounced. docs-sync used to check only that a body asked for the verdict somewhere. From
1.1.0 it also checks where: the last paragraph of each reviewer body in .claude/agents/
must be exactly this, with the second and third sentences optional:
End with exactly one final line: `VERDICT: PASS` or `VERDICT: BLOCK`. The prefix iswhat makes the outcome machine-readable — a bare `PASS` can occur anywhere in prose,so a caller (or a future receipt gate) cannot tell a verdict from a sentence.Line breaks and CRLF endings do not matter; words do. The severity contract's lines and the finding format (the subsection above) come before this paragraph, never after it. A body with no verdict demand at all still reds on every vintage, as it always has.
Who sees it. Only an install that forked a reviewer body. update re-plants an
unmodified body, and every shipped body conforms. A fork you re-recorded is kept; when the
shipped body changed since your version, the incoming copy is parked at
.harness/pending/.claude/agents/<name>.md. If your baseVersion is below 1.1.0 the
finding is a NOTE:
docs-sync: NOTE — reviewer bodies closing on the verdict demand (ramp: live from baseVersion 1.1.0; this install's baseVersion is <yours>; expires in 1.2.0). …docs-sync: NOTE — (ramp) .claude/agents/<name>.md: reviewer body does not close on the verdict demand — …From harness 1.2.0 the same finding prints under RAMP EXPIRED and reds the step, and on
an install whose baseVersion is 1.1.0 or later it reds from the start.
The fix, in this order. .claude/agents/ is write-guarded and the lock is a human act,
so each step is yours, not an agent's.
-
Restore the closing paragraph. Merge the parked copy from
.harness/pending/if there is one, or copy the paragraph above from the template. Move whatever your fork asked for after the verdict to an earlier paragraph, and say it comes BEFORE the verdict, as the shippedtorvalds-reviewer.mddoes: "Give the top 3 fixes, most important first, BEFORE the verdict". -
Re-lock the agent surface.
promptsreds the edited body until the lock moves, and the bash guard refuses the writer from an agent's shell, so a human runs:HARNESS_ALLOW_SELF_EDIT=1 node tools/gen-agents-lock.mjs --write -
Re-record the body's sha in
.harness/manifest.json, in a reviewed commit, as "Forking an owned file" in the 1.0.2 section describes: the record keepsupdatetreating the file as yours. Delete the parked copy once merged.
If the finding names tools/lib/agent-roster.mjs instead of a body, your fork of that lib
predates the rule: the gate still checks that each body asks for the verdict, but cannot
check where. Merge .harness/pending/tools/lib/agent-roster.mjs into your fork, so that it
exports verdictDemandProblem, and re-record its sha. A fork older than 1.1.0 also lacks
severityContractProblems, so the severity contract's NOTE names the same lib, and the
same merge clears both.
One limit. Only the last paragraph is checked. An earlier paragraph that asks for text
after the verdict line still passes docs-sync, and the hook still bounces every PASS that
obeys it, so read your fork for that too.
Review records: docs/reviews/
Section titled “Review records: docs/reviews/”Reviewer findings had no place in history: the ledger under .harness/ is ignored by git,
and the ADR template has no section for them. From 1.1.0 a change keeps its review at
docs/reviews/<YYYYMMDD>-<slice>.md, named like its ADR, with a ## Round <n> — YYYY-MM-DD
section per round holding a | Reviewer | Verdict | Findings | Resolution | table.
docs/reviews/README.md defines the shape, the ADR template's Traceability section links
the record, and /adr and /new-feature tell the agent to write it. No gate reads the
directory, so a missing or malformed record turns nothing red.
update plants docs/reviews/README.md where your install has none, and leaves one you
already have exactly as it is, with nothing parked beside it. The owned files above reach
you as usual. One text does not:
-
The
AGENTS.mdsentence.AGENTS.mdis yours, soupdatedoes not touch it. In its Provenance list, extend the ADR bullet so it reads:- Emit one ADR per slice via `/adr <slice>` (records in `docs/adr/`); then run`/verify-citations` until it returns `CITATIONS: CLEAN`. Review rounds go in`docs/reviews/<YYYYMMDD>-<slice>.md`, never in the ADR.It spends one line of the file's
~350budget.
Two things to know once you keep records:
- The whole-turn reviewers see the record. No path in the
reviewerslist oftools/reviewer-triggers.jsonmatchesdocs/**, so writing a record leaves those reviewers' verdicts standing; keep it that way if you add trigger paths. ThewholeTurnclass, where your table has one, binds each PASS to the whole diff, the record included, so a round recorded after atorvalds-reviewerorcitation-verifierPASS sends it stale. The README's "The record is part of the diff" section gives the order that ends there: record every round, then run the whole-turn reviewers once more over the diff that holds the record, and leave that confirming run out of it. - An
-- adr:marker names the ADR, never the record. Themigrationsgate checks only that the named file exists, so it would accept a record; the rule is written down, not enforced.
Proposing a register edit: harness-proposals/ and apply-proposal
Section titled “Proposing a register edit: harness-proposals/ and apply-proposal”The write guard denies an agent every reviewed register under tools/: the allowlists, the
budgets and registers such as tools/i18n-allow.json, tools/approved-tools.json or
tools/mcp/corpus/project.json. Until 1.1.0 an agent with a reason to change one could only
describe the edit, and you either typed it or relaunched the session with
HARNESS_ALLOW_SELF_EDIT=1, which lifts the guard for every protected path at once. From
1.1.0 the agent writes the whole proposed file as one JSON document,
harness-proposals/<id>.json:
{ "version": 1, "target": "tools/i18n-allow.json", "reason": "Why the register should change.", "base": "<output of git rev-parse HEAD:tools/i18n-allow.json, or null if the file is not in HEAD>", "content": "<the whole proposed file>"}harness-proposals/ is a committed directory outside every path the deny list and the two
guards name, so staging a proposal narrows none of them, and a proposal is inert: no gate
reads it. format checks it like any other file, so the agent writes it the way
JSON.stringify(proposal, null, 2) prints it, with one trailing newline.
What to do.
- List what is pending.
doctorlists each proposal asinfo, and so doesnpx next-expo-supabase-agent-harness@latest apply-proposalwith no id. Run the installer of the harness version you installed or a later one. - Review one. Add the id and
--dry-run: the command prints the reason and agit diff --no-indexof the current file against the proposed one, and writes nothing. It needs a terminal on stdin and stdout. - Apply it, or delete it. Run the same command without
--dry-run. After the diff it asks you to type the target path, and only that answer writes the file. It then deletes the proposal and printscommit <target>. To reject a proposal, delete the file. - Commit the register. It is left uncommitted on purpose:
gate-integrityfails on an escape list left uncommitted (a shell withHARNESS_ALLOW_SELF_EDIT=1set skips that check), and the commit carries the change into your pull request, where CODEOWNERS applies. If the proposal was committed, commit its removal with it.
When it refuses. Each refusal exits 1, names its reason and writes nothing:
- The target is not proposable. A proposal may target the escape lists in
tools/lib/enforcement-surface.mjsandtools/field-notes.json. It may not target an owned file, the agent surface, settings, a pinned, hashed or generated file, ortools/perf-baseline.jsonandtools/mutation-baseline.json, which only their generators write: re-run the generator yourself. basedoes not matchgit rev-parse HEAD:<target>, or it is null for a file that is inHEAD, or set for one that is not. The register changed after the proposal was staged, and replacing the whole file would revert that change. Ask for the proposal again.- The target has uncommitted changes. Commit or discard them first, so the file the diff shows is the file that is replaced. The check runs again after you answer.
- The id or the target resolves outside
--dir, the content is not JSON, the proposal has a field the format does not have, or its text carries a control or bidirectional-format character that could make the terminal show something other than the bytes written.
Reviewer bodies list what a change must bring: ## WHAT MUST ACCOMPANY IT
Section titled “Reviewer bodies list what a change must bring: ## WHAT MUST ACCOMPANY IT”A reviewer body's rubric asks about the lines a diff contains. A companion the diff should
have brought and did not, such as the REVOKE ALL … FROM authenticated that 1.0.2 found
missing on seven tables, is on no line of it. From 1.1.0 every reviewer body except
citation-verifier's carries a table of those companions, placed before its Flag ONLY
paragraph where it has one, before its Severities: line, and always before its closing
verdict demand:
| id | The diff introduces | It must also bring | Stated in | Enforced by |Each row restates a rule the harness already states in the file its Stated in cell names,
so the table adds questions, not rules. The reviewer reports each row that applies as
<id>: present (file:line) or <id>: absent, and an absence is a finding at the severity
the body already gives that rule, so it BLOCKs when that severity is on the body's
Blocking: line. Enforced by names the chain step that reds the absence, or says
review only. A row that a step enforces stays in the table, because the database-backed
proofs skip when no local stack is running. The web-page rows of accessibility-reviewer
and design-reviewer widen those two bodies past the mobile UI: tools/reviewer-triggers.json
already summons both on apps/web/app/**/page.tsx.
Who has to act. Only an install that forked a reviewer body. No gate reads a table, so a fork without one reds nothing and simply gets no companion questions. To take the table:
- Copy the section. Take
## WHAT MUST ACCOMPANY IT, its paragraph and its table from the parked copy at.harness/pending/.claude/agents/<name>.mdinto your body where the parked copy has it: after the rubric, before theFlag ONLYparagraph where your body has one, before theSeverities:line where it has one, and always before the closing verdict demand, which stays the body's last paragraph. - Re-lock the agent surface and re-record the body's sha, as steps 2 and 3 of "Adding the contract to a forked reviewer body" describe, in the severity-contract subsection above, and delete the parked copy.
A row of your own is welcome in a fork: keep the five columns, a backticked kebab-case id,
and a Stated in path that exists in your tree.
A smaller always-loaded context: encryption.md is a stub, and trimming AGENTS.md is optional
Section titled “A smaller always-loaded context: encryption.md is a stub, and trimming AGENTS.md is optional”Every session loads AGENTS.md and each rule file without paths:. Until 1.1.0
encryption.md was one of them in full, while most of it governs the opt-in e2ee module.
It now keeps what applies with the module off, each item with the check that holds it in a
base install, and its seven bullets moved word for word to the path-scoped
.claude/rules/e2ee.md. The lint and write-guard messages that cite
.claude/rules/encryption.md still point at text that states their rule.
update delivers this to the harness-owned files: both rule files, the
authoring-e2ee-feature skill, docs/harness/README.md and tools/conformance-map.json.
AGENTS.md is yours, so update does not touch it, and nothing turns red whether you
trim yours or not. The shipped copy no longer carries the rules it repeated from
.claude/rules/security-invariants.md that a hook denies with a message naming the fix:
WITH RECURSIVE without a CYCLE clause or visited guard, secret-shaped EXPO_PUBLIC_ and
NEXT_PUBLIC_ names, and the shell commands the bash guard refuses outright (rm -rf,
force-push, git reset --hard, git commit --no-verify, reading .env* or .dev-auth/,
pnpm update, knip --fix). No gate reads those sentences, and the always-loaded
security-invariants.md still states each rule in every session. To take the smaller
file, delete the WITH RECURSIVE bullet under ## Security invariants, and change the
secret-name bullet and the shell-hygiene bullet to:
The public config is
NEXT_PUBLIC_SUPABASE_URL/NEXT_PUBLIC_SUPABASE_PUBLISHABLE/EXPO_PUBLIC_*transport only; the service-role key and any provider secret stay server-env.Shell hygiene (bash-guard enforced): no destructive raw SQL outside migrations, and store/signing credentials (
EXPO_TOKEN, Android keystores, Apple API keys) never touch shell or repo.
Leave the gate-list and Stop-chain sentences, the Keep under ~N lines sentence and every
pnpm command it advertises as they are: docs-sync reads them.
Advisory decision classes: provenance reds only the mandatory ones
Section titled “Advisory decision classes: provenance reds only the mandatory ones”Until 1.1.0 every decision class blocked alike: an uncited timeoutMs failed provenance
and made the source-check hook exit 2, exactly as an uncited CREATE POLICY did. From
1.1.0 three classes are advisory: vector-index, llm-sampling and
tuning-constants, listed in the owned tools/lib/provenance-rules.mjs. An uncited site,
or a citation whose corpus entry does not cover the class, whose classes are all advisory
prints on every run and fails nothing:
provenance: ADVISORY (1) — apps/web/lib/limits.ts:1 [tuning-constants] no SOURCE citationThe hook answers the same site with a note in the agent's context and exits 0. Every other
class stays mandatory: rls-policy, guc-identity, token-verification,
cryptography, the seeded mobile-security and every group your
tools/decision-groups.json adds. A line that matches a mandatory class and an advisory
one, such as jwtVerify(t, k, { timeoutMs: 5 }), is mandatory. A citation you do write
must still ground, whatever the class: a pinned corpus id, an existing repository path or
a URL on an allowlisted host.
Keeping a class mandatory. Add a top-level mandatory list to
tools/decision-groups.json, beside comment and groups:
"mandatory": ["tuning-constants"]The file is write-guarded, so a human makes the edit and commits it, or reviews and applies
an agent's harness-proposals/<id>.json for it (the subsection above). A value that is not
an array, or a key that is not a decision group, fails provenance and blocks every edit
until it is fixed. Nothing in the file can make a mandatory class advisory.
What update leaves alone. tools/decision-groups.json and
tools/reviewer-triggers.json are seeded, so an existing install keeps its copies. The
mandatory key works without the new comment, which only documents it, and the
citation-verifier sentence in tools/reviewer-triggers.json is prose no check reads.
Copy either from the template if you want the text.
If you edited tools/lib/provenance-rules.mjs. update keeps your copy, parks the new
one under .harness/pending/ and exits 2 while it stays there. The re-planted gate and
hook find the split only in the new copy, so until you merge it every class stays
mandatory, exactly as before 1.1.0, and nothing turns red that was green.
The event catalog: a vertical opts in from its ./client
Section titled “The event catalog: a vertical opts in from its ./client”tools/gen-event-catalog.mjs no longer imports the example by name. It walks the platform
catalog plus each packages/verticals/* whose ./client entry exports its catalog as
EVENT_CATALOG, and it still reads the example's noteEvents export, as 1.0.x did, for as
long as your root package.json lists @app/notes and that vertical has not opted in.
Your catalog regenerates unchanged, and nothing is yours to do. A fresh 1.1.0 scaffold's
packages/verticals/notes/src/client.ts carries the line below where yours has
export { noteEvents } from './events.js', and its root package.json no longer lists
@app/notes. Adopting the line is optional:
export { noteEvents as EVENT_CATALOG } from './events.js'To catalogue one of your own verticals, add the same line to its src/client.ts, naming its
own catalog, then run pnpm gen:contracts, the part of pnpm gen that needs no database,
and commit the regenerated tools/generated/event-catalog.json. The generator looks for the
name in the file's code, not in its comments, and a vertical that names it without exporting
a catalog fails the generator with an error naming the file. If you later remove the
example, drop @app/notes from the root package.json in the same change: while the root
lists it and the vertical has not opted in, the generator still takes the old import, which
fails once the vertical is gone, as it did under 1.0.x. The reverse holds too: pulling the
new root package.json with update --refresh-seeded package.json without the client.ts
line drops the example's three rows at the next regeneration, so take both together.
If you forked tools/gen-event-catalog.mjs to add your own verticals' imports, your copy is
kept and the new one is parked at .harness/pending/tools/gen-event-catalog.mjs. Add the
export to each vertical your fork imported, take the parked file, re-record it as "Forking
an owned file" in the 1.0.2 section describes, and delete the parked copy. Then
pnpm gen:contracts leaves tools/generated/event-catalog.json unchanged.
A catalog pin below a security floor: .harness/pending/pin-floors.json
Section titled “A catalog pin below a security floor: .harness/pending/pin-floors.json”A release can now record a security floor for a pin in your seeded catalog. 1.1.0 records
two: vitest and @vitest/coverage-v8 at 4.1.11, for GHSA-82fw-gwwq-j7x9, the raise the
1.0.3 section above asks you to make by hand. update still never edits
pnpm-workspace.yaml, so it tells you instead, once per package:
CATALOG PIN FLOOR (1.1.0): raise `vitest` from 4.1.10 to at least 4.1.11 in the pnpm-workspace.yaml catalog, then `pnpm install` and commit pnpm-lock.yaml. WHY: … (parked at .harness/pending/pin-floors.json)and doctor warns with the same finding (catalog pin below a security floor (since 1.1.0)) and exits 2 until the pin meets the floor. It never exits 1 for a floor: an old
pin stops no gate from running, and your daily osv-scan job is still what judges the
version your lockfile resolved. To clear it, raise both pins together, exactly as the 1.0.3
section shows, then run doctor again:
pnpm install && git add pnpm-lock.yaml pnpm-workspace.yamlnpx next-expo-supabase-agent-harness@latest doctordoctor deletes .harness/pending/pin-floors.json once every floor is met and says so in
an info line; it is an instruction, not a parked upgrade, so there is nothing to merge
from it. A pin is judged by the lower bound of its catalog value: ^4.1.11 and >=4.1.11
meet the floor, ^4.1.10 does not, even where your lockfile resolved something newer, and a
value that is not a version (a dist-tag, an npm: alias, a URL) cannot be proven to meet
it, so it warns too. The key is found whether it is bare, single-quoted or double-quoted,
and a package you removed from the catalog is not judged.
Your own workflows: workflow-hardening (a NOTE until 1.2.0)
Section titled “Your own workflows: workflow-hardening (a NOTE until 1.2.0)”Since 1.0.2 every workflow the harness ships selects bash at workflow level and gives every
job a ceiling, and every shipped job starts with harden-runner. Nothing checked a workflow
you wrote, and harden-runner-coverage only counts harden-runner lines per .yml
file: two in one job cover a neighbour with none, one placed after checkout passes, a
comment counts, and a .yaml file or a workflow indented by four spaces is never read.
From 1.1.0 the workflow-hardening job in actions-lint.yml runs
node tools/check-workflow-hardening.mjs, which holds every workflow in
.github/workflows/ to three rules and names each finding <file> or <file>#<job>:
-
A workflow-level bash default, above
jobs:. GitHub runs a step that names no shell asbash -e, withoutpipefail, soproducer | tee filereports tee's status. Add this at the top level, beforejobs::defaults:run:shell: bashWrite it as plain
bash: GitHub adds-eo pipefailonly to that spelling, so a custom command such asbash -el {0}is reported even where it setspipefailitself. A workflow with norun:step needs none, and the one workflow that publishes OpenSSF Scorecard results must carry no top-leveldefaultsorenvinstead. -
A ceiling on every job. A whole number of
timeout-minutesat job level, from 1 to 360 on a GitHub-hosted runner and to 7200 on a self-hosted one: those are the platform's limits, and a job with no ceiling inherits 360. An expression is not read, so write the number. A job that calls a reusable workflow (uses:at job level) takes none. -
harden-runner first. The first step of every job that is neither a reusable-workflow call nor on a
self-hostedrunner isstep-security/harden-runner, pinned by SHA, as in every shipped workflow. When the job'sruns-on, or a matrix value it reads, names windows, that step setsegress-policy: audit.
Who sees it, and when. If your baseVersion is below 1.1.0, each finding is a NOTE and
the job stays green:
workflow-hardening: NOTE — the workflow hardening rules over the project workflows (…) (ramp: live from baseVersion 1.1.0; this install's baseVersion is <yours>; expires in 1.2.0). …workflow-hardening: NOTE — (ramp) .github/workflows/<file>#<job>: the first step uses actions/checkout@…, not step-security/harden-runner — …From harness 1.2.0 the same findings print under RAMP EXPIRED and red the job, and on an
install whose baseVersion is 1.1.0 or later they red it from the start.
The sweep. Run node tools/check-workflow-hardening.mjs in your tree; it needs Node
only. Fix each finding it names as the rules above say, and run it again until it prints
workflow-hardening: OK. The check is not a chain step, so graduate does not run it:
run it by hand before you graduate. Because the job also runs when .harness/manifest.json
changes, a graduation that left a finding shows it on that pull request. The job is new, so
no branch-protection rule requires it until you add workflow hardening (bash default, job ceilings, harden-runner first) to your required checks.
If you forked actions-lint.yml. update keeps your copy, parks the new one under
.harness/pending/.github/workflows/actions-lint.yml and exits 2 while it stays there.
Your fork has no workflow-hardening job until you merge it, so nothing runs the check in
CI; node tools/check-workflow-hardening.mjs still works locally.
What OPENS: the SQL gates fold DROP TABLE and ALTER POLICY (NOTEs until 1.2.0)
Section titled “What OPENS: the SQL gates fold DROP TABLE and ALTER POLICY (NOTEs until 1.2.0)”Through 1.0.x the parser the SQL gates share read neither statement. A table your history
dropped kept its columns, indexes, triggers, RLS toggles, policies and grants in every view,
and lent them to a later table of the same name. A policy you rewrote with ALTER POLICY
was judged on its CREATE text, which the database no longer runs. schema-rls also read
DROP POLICY and ignored it, so a dropped policy still covered its operation. From 1.1.0
each gate reads the history as the database applies it:
DROP TABLE [IF EXISTS] a, b [CASCADE | RESTRICT]removes each table, its partitions and everything on them, and clears the foreign keys that pointed at them. A laterCREATE TABLEof the same name starts with nothing.ALTER POLICYreplaces theTO,USINGandWITH CHECKclauses it names and keeps the rest;ALTER POLICY … RENAME TOrenames.schema-rlsreports aDROP TABLEwithoutIF EXISTS, or anALTER POLICY, whose target no earlier migration left in place.DROP TABLE IF EXISTSon an unknown table is a no-op, as it is in the database.migrationstreatsALTER POLICYas an authorization change: it needs an-- adr: docs/adr/<file>line naming an existing ADR, asDROP POLICYalready did. A migration whose onlyALTER POLICYstatements areRENAME TOneeds none.
A DROP TABLE inside a function body (EXECUTE format('DROP TABLE …')) is not a statement
of the history, so it folds nothing. The harness's own migrations hold neither statement at
the top level, so a scaffold that never wrote one sees no change.
Who sees a NOTE. An install whose baseVersion is below 1.1.0 and whose history holds
one of those statements, for each finding that only the new reading produces. The gates
are schema-rls, tenancy, data-flow, db-limits and query-shapes, each with one
ramp, and migrations for its ALTER POLICY rule:
schema-rls: NOTE — the SQL history fold (DROP TABLE, ALTER POLICY and DROP POLICY) (ramp: live from baseVersion 1.1.0; this install's baseVersion is <yours>; expires in 1.2.0). …schema-rls: NOTE — (ramp) notes: policy notes_select_own has a vacuous USING (true) — it permits every rowmigrations: NOTE — ALTER POLICY as an authorization change (ramp: live from baseVersion 1.1.0; …; expires in 1.2.0). …migrations: NOTE — (ramp) supabase/migrations/<file>.sql: ALTER POLICY removes an authorization control — …A finding the old reading also produced stays a hard failure, whatever your
baseVersion: the ramp covers only what the gates could not see before. A finding only
the old reading produced is gone, because it described a table or policy your history
dropped or rewrote. From harness 1.2.0 every NOTE above prints under RAMP EXPIRED and
reds its step, and on an install whose baseVersion is 1.1.0 or later they red from the
start. To tell the two kinds apart the gate replays itself over your history as 1.0.x read
it; that replay runs only when the history holds one of the statements and the gate found
something.
The sweep, before 1.2.0. Your migrations are append-only, so every fix goes in a NEW migration:
-
Fix what the fold exposes. A re-created table needs its own
ENABLEandFORCE ROW LEVEL SECURITY, its per-operation policies, itsGRANTs and its owner-column index; a predicate anALTER POLICYrewrote must match the reviewed formstenancynames; a reviewed entry that named a dropped table (untenantedTablesintools/tenancy.json, atools/data-flow.jsonrow) is stale, so remove it. A new migration that rewrites a policy carries its-- adr:line. -
Acknowledge an
ALTER POLICYthat is already applied. It cannot take an-- adr:line without editing a committed migration, whichmigrationsrefuses. Add the existing escape for it totools/migrations-allow.json, one entry per file:{ "file": "<migration basename>", "rule": "authz-adr", "reason": "<why applied history cannot be swept>" }The migration must exist at your diff base, and the entry reds once the finding is gone.
-
A drop of a table made outside the migrations. If an applied migration drops a table the dashboard or an extension created,
schema-rlscannot place the drop. Record the table with a reason intools/rls-exempt.json; nothing of a dropped table is left for the exemption to hide. The same holds for a drop of a table anALTER TABLE … RENAME TOrenamed: the gates do not follow a table rename, so the new name reads as never created. The entry exempts the NAME, so a table a later migration creates under it is exempt too: give a new table a new name.
The harness's upgrade lane has nothing to sweep here: scripts/ci/upgrade-sweep.mjs
SWEEPS['1.1.0'] adds no step, because its scaffolds hold neither statement. Then
graduate as the section on graduating says.
One case has no escape yet. An applied ALTER POLICY of a policy the migrations never
created (one made in the dashboard) stays unresolved for schema-rls, and nothing
acknowledges it before the ramp expires. Report it; the release that owes this ramp's
expiry has to answer it.
What OPENS: schema-rls bounds grants by policies and holds every table to the three-role revoke (a NOTE until 1.2.0)
Section titled “What OPENS: schema-rls bounds grants by policies and holds every table to the three-role revoke (a NOTE until 1.2.0)”Supabase's default privileges grant ALL on every new public table to anon,
authenticated and service_role, and a GRANT removes nothing. Until 1.0.2 the harness's
own migrations revoked the default from anon and service_role only, then granted four
verbs to authenticated, which kept TRUNCATE, REFERENCES, TRIGGER and (on PostgreSQL 17)
MAINTAIN. Row security does not apply to those four, so no policy narrows them. 1.0.2
closed that on the seven tables authenticated only reads; profiles and notes, the two
it writes, kept it. And schema-rls only ever checked that a policy had a grant behind it,
never that a grant had a policy behind it, so nothing found any of it. From 1.1.0
(docs/adr/20260930-three-role-revoke.md) schema-rls reds three things:
- A grant wider than the table's policies. Every privilege
anonorauthenticatedholds, counting the platform default, a grant toPUBLICand column grants, needs a PERMISSIVE policy for that operation (orALL) naming the role,publicor no role, whose predicate is not literallyfalse. TRUNCATE, REFERENCES, TRIGGER and MAINTAIN are never admitted: revoke them. - A table that keeps the platform default for any of the three roles: the doctrine is
REVOKE ALLfrom all three, then the exact grants. This is what makes the privileges the same on every database, including a project created on or after 2026-10-30, which gets no default at all. - A missing or stale
supabase/tests/rls_grants.generated.test.sql, the pgTAP assertion of the exact privileges every table's three roles hold. It is generated from your migrations bynode tools/gen-grant-assertions.mjsand never edited by hand.
Each finding prints the REVOKE and GRANT statements that clear it.
Who sees a NOTE. Every install whose baseVersion is below 1.1.0, because its
profiles and notes predate the doctrine:
schema-rls: NOTE — the grant bound, the three-role revoke doctrine and the generated grant assertions (ramp: live from baseVersion 1.1.0; this install's baseVersion is <yours>; expires in 1.2.0). …schema-rls: NOTE — (ramp) notes: `authenticated` holds TRUNCATE, REFERENCES, TRIGGER, MAINTAIN on public.notes, which no policy admits — … Clear it in a NEW migration: REVOKE ALL ON TABLE public.notes FROM authenticated; GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.notes TO authenticated; …schema-rls: NOTE — (ramp) notes: the platform default still reaches `authenticated` — … Clear it in a NEW migration: REVOKE ALL ON TABLE public.notes FROM authenticated; GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.notes TO authenticated; …From harness 1.2.0 they print under RAMP EXPIRED and red the step, and on an install
whose baseVersion is 1.1.0 or later they red from the start. graduate refuses while one
stands.
The sweep: the SQL first, then the generator. A fresh scaffold gets
supabase/migrations/20260930000000_three_role_revoke.sql and the generated test.
update plants neither: supabase/migrations/ is your applied history, and a file
with the harness's timestamp could sort ahead of migrations you have already applied; the
harness's generated test describes the harness's tables, not yours.
-
Create a migration of your own:
supabase migration new three_role_revokeand put in it the statements your findings print. For the two tables the harness shipped, keeping the four verbs their policies admit, that is:
-- adr: docs/adr/20260930-three-role-revoke.md-- SOURCE: https://www.postgresql.org/docs/17/ddl-priv.htmlREVOKE ALL ON TABLE public.profiles FROM authenticated;REVOKE ALL ON TABLE public.notes FROM authenticated;GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.profiles TO authenticated;GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.notes TO authenticated;If you never applied 1.0.2's migration, add its SQL for the seven read-only tables (the 1.0.2 section above). For a table of your own, add the lines its findings print, or run
node tools/gen-grant-assertions.mjs: while any table fails the doctrine it refuses, names the tables and prints every statement your tree needs at once. The-- adr:line is required, because yourmigrationsgate treats aREVOKE … FROM authenticatedas a change to an authorization control; the ADR is the one this update plants. Thenpnpm db:reset && pnpm db:test. -
Generate the assertion and commit it:
node tools/gen-grant-assertions.mjsgit add supabase/tests/rls_grants.generated.test.sqlIt reads
[db].major_versionfromsupabase/config.toml: eight table privileges on PostgreSQL 17, seven on 15 and 16. Run it again after every grant change;schema-rlsreds a stale copy.pnpm db:testthen runs it against your local stack. -
A privilege you mean to keep that no policy admits (a TRIGGER an owner-side process needs, say) goes in
tools/grant-bound-allow.json, one row per table, role and privilege, with a reason. The file is write-guarded like the other allow lists, and a row naming a privilege nobody holds reds:{ "allow": [{ "table": "<table>", "role": "authenticated", "privilege": "TRIGGER", "reason": "<why>" }] }
Three seeded texts are yours to copy. package.json gains the generator in pnpm gen:
"gen": "… && pnpm gen:routes && pnpm gen:grants","gen:grants": "node tools/gen-grant-assertions.mjs",AGENTS.md's RLS bullet gains, after "(a GRANT removes nothing; the default leaves it
TRUNCATE).":
pnpm genregenerates the exact-privilege pgTAP file, never a hand edit.
And supabase/AGENTS.md's grant bullet ends:
That revoke needs an
-- adr:marker (themigrationsgate). Thenpnpm gen(ornode tools/gen-grant-assertions.mjs) regeneratestests/rls_grants.generated.test.sql, the exact privileges of every table; never edit its rows or itsplan()by hand.
The schema files supabase/schemas/10_account.sql and 20_notes.sql gained the same
REVOKE ALL … FROM authenticated line in a fresh scaffold; add it to yours so the
declarative schema matches the history. The hand-written assertions in
rls_structure.test.sql stay: they state intent (the service_role allowlist, the seat and
quota shapes) that a file generated from the migrations cannot.
The harness's upgrade lane runs exactly these two steps on its swept leg
(scripts/ci/upgrade-sweep.mjs SWEEPS['1.1.0']): the statements the gate prints, in a
migration of the leg's own, then the generator. It copies neither withheld file.
A DAL that calls rpc() or upsert(): regenerate the query-shape manifest
Section titled “A DAL that calls rpc() or upsert(): regenerate the query-shape manifest”Through 1.0.4 neither call could pass query-shapes. The recording port that pnpm gen
drives each probed DAL function with had no rpc(), so generation threw on a function that
called it, and an upsert recorded as a read whose extra named .upsert(), which the gate
failed with advice about OFFSET pagination. Both are now recorded as their own kind of row.
tools/generated/query-shapes.json is seeded, so update does not rewrite it. If a probed
DAL function of yours makes either call:
- Run
pnpm gen, orpnpm gen:contracts, the part of it that needs no database. - Run
node tools/check-query-shapes.mjs, and fix what it names (below). - Commit
tools/generated/query-shapes.json. Only the rows of rpc and upsert calls change; every other row keeps its bytes.
What the gate now checks, and the fix for each red:
calls rpc public.<name>, which no migration creates. No file insupabase/migrations/creates that function inpublic, the one schema PostgREST exposes, so the call would get PGRST202. Create it there, or call a name a migration creates.without its required parameter(s) …ornames …, which public.<name> has no input parameter called. Pass every input parameter that has no DEFAULT, under its declared name, and no other. An OUT parameter is a result, not an argument.upsert into public.<table> ON CONFLICT (…) — no UNIQUE index or primary key …. Put the exact columns of a UNIQUE index or of the primary key inonConflict, in any order, or add the index in a new migration; the finding lists the ones the table holds. On a tenant table the index carries the tenant column, becausetenancyreds a UNIQUE there that omits it, so the conflict target names it too. With noonConflictthe target is the primary key, so the table needs one.upsert into tenant table "<table>" writes no <tenant column>. Write the tenant column in the payload, as an insert does.
The gate reads migrations, not a database, and it does not model DROP FUNCTION or
overloads (the last definition of a name wins), reads a partial UNIQUE index as an arbiter,
and does not judge the arguments of a function with an unnamed input parameter. If you
edited one of the owned files this item re-plants, your copy stays, the new one is parked
under .harness/pending/, and update exits 2 while it stays there.
The i18n syntax-tree walk: tools/i18n-allow.json keys, not lines
Section titled “The i18n syntax-tree walk: tools/i18n-allow.json keys, not lines”tools/check-i18n.mjs now walks the TypeScript syntax tree of every file it scans, with
the typescript your root package.json has listed since the first release, beside the
regular expressions it has always run, and it reports both. Two ramps open at 1.1.0 and
expire in 1.2.0; on a baseVersion of 1.1.0 or later, and on a fresh scaffold, both are
already hard.
Strings only the walk finds. A string inside {…} (title={'Settings'}), a template
literal with no ${…} (label: `Home`), a double-quoted object value in a .ts
module, and JSX text holding =, ;, a backtick or $ were never matched before. Below
1.1.0 each prints as i18n: NOTE — <file>:<line>: hardcoded user-facing string …, under
one ramp NOTE naming copy only the syntax-tree walk finds. Move each into your catalog
and render it through t('<key>'), as the line says, or, for a string no human reads, add
the {"key": …, "reason": …} entry the line prints to tools/i18n-allow.json.
site entries become keys. A 1.0.x entry {"site": "file:line", "reason": …} mutes
whatever sits on that line, and a line inserted above the string moves it onto something
else. A key is 12 hex characters hashed from the file's path, the finding's kind, its
attribute or property name and its text, so it stays on its string. Below 1.1.0 the step
still honours a site entry and prints its replacement:
i18n: NOTE — tools/i18n-allow.json: {"site": "apps/mobile/src/Brand.tsx:12"} mutes this line until 1.2.0; replace it with {"key": "6d2720cab99b", "reason": "a brand name"}, which stays on the string when the line movesReplace each site entry with the entry its NOTE prints, keeping your reason. A site
entry whose NOTE says it matches no finding is stale: delete it. The file is
write-guarded, so a human makes the edit and commits it, or reviews and applies an agent's
harness-proposals/<id>.json for it (the subsection on proposals above). Run
node tools/check-i18n.mjs again: once no site entry is left and no NOTE names the walk,
graduating turns nothing of either ramp red. graduate runs validate, and this is a Stop
step, so run it yourself before you graduate. A key that matches no finding reds, as a
stale site entry never did.
What update leaves alone. tools/i18n-allow.json is seeded, so your copy and its
comment stay as they are; only a fresh scaffold gets the comment that describes keys. The
shipped allowlist is empty, and no release's own scaffold holds a string only the walk
finds, so an install that never edited its copy or its screens' copy sees nothing new.
If typescript is not installed. The step prints
i18n: NOTE — the syntax-tree walk did not run: …, judges with the regular expressions
alone, and cannot tell whether a key that matches none of their findings is stale. Run
pnpm install. In CI, where the quality gate installs before it runs the step, a walk that
did not run is a failure.
The web app compiles in the chain, and each web route needs a browser spec (NOTEs until 1.2.0)
Section titled “The web app compiles in the chain, and each web route needs a browser spec (NOTEs until 1.2.0)”Through 1.0.x nothing in your chain compiled the web app: types typechecks and does not
bundle, and build is the mobile export. A Client Component that imports a server-only
module, or an import only the bundler cannot resolve, passed the whole chain and static,
and only the path-filtered web-build job ran next build. The new step, web-compile
(node tools/check-web-build.mjs), runs pnpm --filter web exec next build --webpack when
its inputs changed and prints STAMPED when they did not; CI never uses the stamp.
What it needs. node_modules and apps/web: without either it skips loudly on your
machine and fails in CI, like every toolchain step. It runs after types, whose tsc -b
writes the declarations Next's type check reads; run on its own on a fresh clone it fails
with TS6305 and prints pnpm exec tsc -b . apps/web apps/mobile, which is the fix. Your
environment is used as it is. For each Supabase key the build needs that you have not set
and no apps/web/.env* file defines, it uses the placeholder your web-build job builds
with and prints which keys it filled, so a scaffold with no local stack compiles. It
restores the committed apps/web/next-env.d.ts that next build rewrites, so the build
leaves your tree clean.
If your web app does not compile. On an install whose baseVersion is below 1.1.0 the
step prints web-compile: NOTE — the web compile step (next build over apps/web) with
expires in 1.2.0, followed by the build's output, and passes. It records no stamp, so the
NOTE comes back on every run, and graduate refuses, until the build is green. Reproduce it
with node tools/check-web-build.mjs and fix what Next reports.
Each registered web route needs a spec that renders it. route-manifest now also asks,
for every route in apps/web/lib/routes.generated.ts, that some *.spec.ts under
apps/web/e2e names one of the route's declared state test ids as a quoted string, outside
a comment. A fresh scaffold ships apps/web/e2e/notes.spec.ts and
apps/web/e2e/security.spec.ts for the two seeded routes that had none; update does not
plant them, so your install has a spec for orgs only. Below baseVersion 1.1.0 the
finding is a NOTE, route-manifest: NOTE — the per-route browser closure (…), naming each
route, its path and its state test ids. Copy the two specs from the harness's
template/stack/apps/web/e2e/ if your notes and security routes are the seeded ones,
or write a spec per route that signs in, visits it and asserts one of its ids with
page.getByTestId('<id>'). Each seeded spec mints its own user through
SUPABASE_SERVICE_ROLE_KEY, as authenticated.spec.ts does, so it runs in the web-e2e
job with no new setup. A route that is chrome rather than content belongs in
tools/web-route-allowlist.json instead.
Your AGENTS.md gate list. Add web-compile after build and change 36 to 37 in both
places (the "What re-OPENS" part above).
If you edited an owned file. Your copy of tools/check-web-routes.mjs,
tools/check-docs-sync.mjs, tools/lib/stamp-inputs.mjs or quality-gate.yml is kept,
the new one is parked under .harness/pending/, and update exits 2 while it stays there.
Until you merge a parked tools/lib/stamp-inputs.mjs, your copy has no list for
web-compile, so the step builds on every run and records no stamp.
Edge Functions: pull the handler split, deno.json and deno.lock (NOTEs until 1.2.0)
Section titled “Edge Functions: pull the handler split, deno.json and deno.lock (NOTEs until 1.2.0)”Through 1.0.x no check compiled, linted, ran or mutated supabase/functions: a global lint
ignore covered it, tsc -b never reached it, and vitest, coverage and Stryker cannot import
a file that imports a jsr: specifier and starts a server as it loads. 1.1.0 reaches it four
ways. lint gives it the TypeScript parser, so no-unverified-session,
crypto-primitives-one-door, cognitive complexity 15 and no-suppressed-complexity apply.
unit runs every vitest suite under it (a *.test.ts importing from 'vitest'; a
deno test file is left alone) and measures each function directory that holds one, and
diff-coverage holds a changed file there to the per-file floors. The mutation floor gains
supabase/functions/*/, each index.ts excepted. The new edge-functions job in
quality-gate.yml installs deno and runs node tools/check-edge-functions.mjs, which runs
deno check --frozen on each supabase/functions/<fn>/index.ts against that function's
deno.json (exact jsr:/npm: versions) and deno.lock.
The seeded delete-account function is split so those checks reach what it decides:
handler.ts holds readKey and the four deletion steps and takes its clients and
environment as parameters, handler.test.ts proves the personal-org sweep is verified before
deleteUser, index.ts becomes a one-call Deno.serve shell, and deno.json and
deno.lock pin supabase-js to one release. index.ts is seeded, so update never rewrites
yours, and the four new files are withheld.
Who sees what, and when. On an install whose baseVersion is below 1.1.0:
lintstays green on the 1.0.xindex.ts: itsreadKeymeasures 16, and that one path is exempt from the complexity rules until 1.2.0. The security rules are not exempt; the seeded file passes both. A function of YOUR OWN that callsgetSession(), reachescrypto.subtle, or measures over 15 redslintnow.edge-functionsprintsedge-functions: NOTE — (ramp) supabase/functions/<fn>: no deno.json(andno deno.lock) for each function, and stays green.diff-coverageprintsdiff-coverage: NOTE — (ramp) supabase/functions/…: absent from every coverage mapfor a changed file in a function directory that holds no vitest suite.- The mutation lane's scoper withholds such a file from Stryker with a
mutation-scope: NOTE — (ramp) …line, and the ratchet NOTEs a new survivor undersupabase/functions/in a directory that has a suite.
From harness 1.2.0 all of it prints under RAMP EXPIRED and reds, and the lint exemption is
gone. On an install whose baseVersion is 1.1.0 or later it reds from the start.
The sweep.
- Pull the split:
npx next-expo-supabase-agent-harness@latest update --refresh-seeded supabase/functions/delete-account/. Anindex.tsyou never changed is replaced; one you changed stays, the new one is parked under.harness/pending/, and you carry your change intohandler.tsby hand. Thenpnpm exec vitest run supabase/functionsruns the suite, anddoctorstops warning. - For each function of your own, move what it decides into a file that names no
Denoglobal and nojsr:/npm:specifier (a type-only import is fine) and give that directory a vitest suite; keepindex.tsa shell. Code in_shared/needs a suite in_shared/. - Give each function a
deno.jsonwhose imports name exact releases, and write its lock:deno check --frozen=false --config supabase/functions/<fn>/deno.json supabase/functions/<fn>/index.ts. Commit both. Supabase deploys each function with its owndeno.json. - With deno installed (the version
quality-gate.yml'sedge-functionsjob pins), runnode tools/check-edge-functions.mjsuntil it printsedge-functions: OK. It is not a chain step, sograduatedoes not run it: run it by hand before you graduate. The job is new, so no branch-protection rule requires it until you addedge functions (deno check against deno.json + frozen deno.lock)to your required checks;gate-summaryalready waits for it.
The harness's upgrade lane adopts the whole split on every swept leg (the fix's paths ride
the derived pass), and SWEEPS['1.1.0'] adds no step.
If you forked eslint.config.mjs, vitest.config.ts or quality-gate.yml. update
keeps your copy, parks the new one under .harness/pending/ and exits 2 while it stays
there, and your fork does not reach supabase/functions until you merge it.
An uncommitted escape list must be one a release planted (gate-integrity, a NOTE until 1.2.0)
Section titled “An uncommitted escape list must be one a release planted (gate-integrity, a NOTE until 1.2.0)”What changed. gate-integrity reds an escape list (the files in
tools/lib/enforcement-surface.mjs ESCAPE_LISTS) that is modified but not committed. Its
one exemption is a list the harness itself just planted, which init and update leave
untracked. Until 1.1.0 that exemption asked two questions: is the file untracked, and does
its sha256 match its .harness/manifest.json record? Since 1.0.2 you may re-record a sha
yourself to keep a fork (the 1.0.2 section, "Forking an owned file"), and the manifest does
not have to be committed, so a record alone no longer says who wrote the bytes. The gate
now asks a third question: did a harness release plant exactly these bytes? The answer is
in tools/lib/planted-shas.json, a new owned file that update plants and gate-integrity
hash-pins like every owned tools/ file. It is generated from the harness's released-sha
tables, which never reach an install; never edit it. A list that carries a placeholder,
such as tools/rls-exempt.json with your SECURITY_OWNERS, is compared after the tokens
are put back from your manifest's answers (tools/lib/derender.mjs, also new and owned).
What you see. The escape lists update plants on this hop are explained by the file
the same update delivers, so the hop adds no finding: each prints the plant NOTE until
you commit it. An untracked escape list that matches its record but no release variant
prints, on an install whose baseVersion is below 1.1.0:
gate-integrity: NOTE — (ramp 1.1.0) <path>: escape hatch present but not committed, and no harness release planted these bytes. …From harness 1.2.0, or once you graduate to 1.1.0, it is a failure. Everything that failed before still fails with the same text: a tracked list that is modified, an untracked one with no record, and an untracked one whose sha differs from its record.
What to do. Review each list the NOTE names. If you meant the entries, commit the file,
so the change is in a pull request diff under CODEOWNERS; if you did not, delete the file
(or restore it from git) and remove the record you wrote for it. graduate refuses while a
NOTE stands. With HARNESS_ALLOW_SELF_EDIT=1 set, the commit rule does not run at all, as
before.
What is unchanged. The threshold-config rule beside it (vitest.config.ts,
eslint.config.mjs and the other configs that carry numbers) still treats a dirty config
whose sha matches its record as a harness refresh. If you forked
tools/check-gate-integrity.mjs, your copy is kept, the new one is parked under
.harness/pending/, and update exits 2 while it stays there; your copy never reads
tools/lib/planted-shas.json.
2.0.0 — the opt-in release: the 1.1.0 notes fall due
Section titled “2.0.0 — the opt-in release: the 1.1.0 notes fall due”If your baseVersion is 1.1.0 or later, nothing expires for you. Every ramp 1.1.0 opened
carries minVersion 1.1.0, so none of them has ever been live on your install. What the
version bump itself brings a 1.1.0 install is the uuid arrival NOTE below; each change a later
2.0.0 item makes has its own part of this section. Read what applies to YOUR baseVersion
off node scripts/ci/ramp-expectations.mjs <your base> 2.0.0 in a harness checkout, and off
pnpm validate 2>&1 | grep -E 'NOTE — \(ramp\)|RAMP EXPIRED' in your own tree, never off
this page.
If your baseVersion is below 1.1.0, this is where the 1.1.0 sweep stops being
optional. 1.2.0 is never cut: this lineage goes from 1.1.0 to 2.0.0 directly, and every
comparison is >=, so each deadline dated 1.2.0 arrives here. The 1.1.0 section above is the
sweep, and nothing in it changed. If you are more than one release behind, read the sections
above in order before crossing this one.
Expect version-sync to red on the next floor, whatever your baseVersion
Section titled “Expect version-sync to red on the next floor, whatever your baseVersion”This one is not a ramp, and no baseVersion is exempt from it. The next floor on the 16
line moves from 16.3.3 to 16.3.6, for
GHSA-vcvr-r3jv-pc5j
(CVE-2026-94545, Critical): remote code execution in the Node.js ImageResponse of
next/og when an app passes attacker-controlled values into the SVG content, attributes or
styles it renders. It affects 16.2.0 up to but not including 16.3.6, so it covers 16.3.5, the
pin every release from 1.0.2 to 1.1.0 shipped. The 15 line is outside its range, and its
floor stays 15.5.24.
tools/framework-floor.json is harness-owned, so update refreshes it. pnpm-workspace.yaml
is seeded, so update does not touch your pins, and the first pnpm validate after the
upgrade reds version-sync on a next pin below 16.3.6, naming the pin, the floor and the
advisory. Raise the pin yourself:
pnpm install && git add pnpm-lock.yaml pnpm-workspace.yamlpnpm validateFrom 16.3.5 this is a patch on the same minor. The scaffold imports nothing from next/og, so
a tree that never added an ImageResponse route was not exposed through one, and the floor
moves anyway, because an Open Graph image route is one import away. Until you can raise the
pin, the advisory's workaround is to keep attacker-controlled values out of everything the
Node.js ImageResponse renders; the Edge ImageResponse is not affected. No flag lowers the
floor. Do not use update --refresh-seeded pnpm-workspace.yaml to take the pin: it replaces
your whole catalog with the template's.
What ARRIVES (hard) — for installs below 1.1.0
Section titled “What ARRIVES (hard) — for installs below 1.1.0”Eighteen of the ramp sites 1.1.0 opened, across fifteen gates. Each finding that printed as
NOTE — (ramp) with expires in 1.2.0 now prints under a RAMP EXPIRED banner and reds its
step. Each 1.1.0 subsection named here says what to do:
- The SQL history fold, in
data-flow,db-limits,query-shapes,tenancyandschema-rls, andmigrations' rule that anALTER POLICYis an authorization change ("What OPENS: the SQL gates foldDROP TABLEandALTER POLICY"). Fix what the fold exposes in a NEW migration, or add atools/migrations-allow.jsonauthz-adrentry for anALTER POLICYyou already applied. schema-rls' grant bound, its three-role revoke doctrine and the generated grant assertions ("What OPENS:schema-rlsbounds grants by policies…"). The subsection's SQL goes in a NEW migration, thennode tools/gen-grant-assertions.mjs.docs-sync, three sites: the reviewer severity contract and the verdict-demand position of a reviewer body you forked ("Reviewer bodies state which severities block…" and "A reviewer body must close on its verdict demand"), and theAGENTS.mdgate list ("What re-OPENS" in the 1.1.0 section: paste the 37 names the finding prints).- The Edge Function surface, in
diff-coverage,edge-functions,mutation-ratchetandmutation-scope("Edge Functions: pull the handler split…"). gate-integrity's planted escape list ("An uncommitted escape list must be one a release planted"): commit each escape list the finding names after reviewing it, or delete one nobody meant to create.web-compileandroute-manifest's per-route browser closure ("The web app compiles in the chain, and each web route needs a browser spec").workflow-hardeningover your own workflows ("Your own workflows:workflow-hardening").
workflow-hardening, edge-functions, mutation-ratchet and mutation-scope run only in
CI and diff-coverage is a Stop step, so pnpm validate does not show them: run each by hand
before you graduate, as the 1.1.0 subsections say. scripts/ci/upgrade-sweep.mjs
SWEEPS['1.1.0'] is what the upgrade lane's swept leg runs before it requires graduate to
succeed; SWEEPS['2.0.0'] adds no step for these sites (it says why).
What RETIRES with them — on every install
Section titled “What RETIRES with them — on every install”Two of the 1.1.0 ramps existed only to carry something to this release, and both things go
now, whatever your baseVersion.
The i18n gate's regular expressions and the site entry. Through 1.1.x the gate ran
its 1.0.x regular expressions beside the syntax-tree walk and reported both. From 2.0.0 the
walk is the only scan:
- A
{"site": "file:line", …}entry intools/i18n-allow.jsonis malformed on every install and fails the step closed. Delete it; the FAIL line of the string it muted prints the{"key": …}entry that replaces it, ready to paste. - A string only the regular expressions reported (for example
title = "…"as a plain assignment in a.tsmodule) no longer reds. Akeyentry you added for one now matches no finding and reds as stale: delete it. - Without
typescriptinstalled, the copy andIntl-boundary checks cannot run. Locally the step says so in a NOTE; in CI it fails. Runpnpm install.
The lint exemption for supabase/functions/delete-account/index.ts. 1.1.0 kept that
seeded file out of the Edge Function complexity block for one release, because the copy every
1.0.x install carries measures 16. If update printed a SEEDED SOURCE FIX naming lint and
you have not acted on it, lint now reds on that file. Pull the split:
update --refresh-seeded supabase/functions/delete-account/git add supabase/functions/delete-account/That replaces the whole directory, so read the diff first if you edited it. A fresh 1.1.0 or later scaffold already has the split.
What moves to 2.1.0 (a dated NOTE)
Section titled “What moves to 2.1.0 (a dated NOTE)”version-sync's uuid arrival, a fifth time. The harness re-reviewed its own uuid 7
acceptance at this release and moved its removalTarget from 1.2.0 to 2.1.0: xcode 3.0.1
still declares uuid: ^7.0.3, @expo/config-plugins still depends on that xcode, and a
registry sweep of a fresh scaffold still finds uuid@7.0.3 in the production closure. Your
seeded tools/eol.json still says "removalTarget": "1.2.0" on a 1.1.0 install, or an older
date on an older one, and that date has arrived. update parks the re-affirmation under
.harness/pending/source-fixes.json, and version-sync prints the arrival as
NOTE — (ramp) with expires in 2.1.0. The remedy is the 1.1.0 section's, one date on:
re-affirm the row under a release you mean, or, if you never edited the file,
update --refresh-seeded tools/eol.json and read the diff before you commit it.
graduate refuses while this NOTE stands.
reviewer-verdicts' round budget. It stays a NOTE until 2.1.0 on an install below 1.1.0,
where it was dated 1.2.0. It counts rounds over the reviewer ledger v2's change set and closes
a loop by v2's rule, and v2 itself stays a NOTE there until 2.1.0, so the budget waits for it.
Nothing to do; the NOTE now names 2.1.0.
The Supabase CLI census. auth-posture's deferred ask-the-CLI check moved from 1.2.0 to
2.1.0: supabase/cli#5894 is still open, and the CLI still documents config push as its only
config subcommand. Nothing to do.
What update plants, and what else moved
Section titled “What update plants, and what else moved”Owned files, re-planted when your copy still matches a released sha: the hooks under
.claude/hooks/ (their version stamps), this runbook, tools/check-version-sync.mjs (the
re-opened arrival ramp), tools/check-reviewer-verdicts.mjs (the round budget's date),
tools/check-i18n.mjs and tools/lib/i18n-tree.mjs (the retired regular expressions),
eslint.config.mjs (the retired exemption), tools/deferrals.json, tools/auth-posture.json,
tools/check-auth-posture.mjs and docs/harness/gates-catalog.md (the census date and the
sentences that called the 1.1.0 ramps open), and tools/framework-floor.json (the next
floor above). The seeded tools/eol.json, tools/i18n-allow.json and pnpm-workspace.yaml
change for fresh scaffolds only: the first reaches you through the parked fix above, the
second changes only its comment, and the third carries the next pin you raise yourself.
The full encryption rule ships with the e2ee module
Section titled “The full encryption rule ships with the e2ee module”.claude/rules/e2ee.md, the full encryption rule, moved from the base template into the
e2ee module: the same text, the same install path and the same paths: scoping, so it loads
exactly as before wherever it is installed. What changes is which installs carry it. The
always-loaded .claude/rules/encryption.md stays in every install, and it now says that
enable e2ee installs the full rule.
- An install without
e2ee:updatedeletes.claude/rules/e2ee.mdwhen it holds the bytes a release shipped, and prunes its record.enable e2eebrings it back. - An install with
e2ee:updatedeletes the old copy and plants the module's straight back, recorded as the module's (module: "e2ee"), sodisable e2eeremoves it from now on. - A kept fork. If you edited the file, or re-recorded its sha to keep an edit,
updateleaves it in place with a note ending "remove it manually".- Without
e2ee: delete it, or keep it as your own rule. Nothing re-plants or removes it. - With
e2ee: ignore the note's "remove it manually", because the module ships this rule. Ifupdateparked a copy under.harness/pending/.claude/rules/e2ee.md(it does when the rule changed after your version), merge it into your file. Your record keeps no module attribution, sodisable e2eeleaves the file in place: delete it yourself if you disable the module.
- Without
Owned files re-planted when your copy still matches a released sha: the stub,
docs/harness/README.md and tools/conformance-map.json (five notes that named the full rule
as if every install had it; its two generated documents do not quote notes and are
unchanged), and, on an e2ee install, docs/modules/e2ee/README.md, which lists the rule
among the files the module adds.
The worked example leaves the default scaffold: init --with-demo and eject
Section titled “The worked example leaves the default scaffold: init --with-demo and eject”From 2.0.0 a fresh init writes no worked example: no @app/notes vertical, no notes or
matrix screens, no notes route or Server Action, and none of the example's migrations. The
shared files the example used to change (the API router, the home tab, the command palette,
the root package.json) and the seeded registers carry only the platform's rows, and the
gates that judged the example's rows accept a tree without them. init --with-demo writes the
example as before and records "demo": true in .harness/manifest.json; a default init
records "demo": false.
Your install keeps its example, and nothing is yours to do. Its files are seeded, so
update neither rewrites nor deletes them. Your manifest records no demo choice, so update
plans the default template for you and never hands you a file of the example's: the owned
files it re-plants are the default ones, and they are green on an install that still has the
example (the upgrade lane proves it on a 1.1.0 install). Two owned files moved into the
example, maestro/flows/matrix.yaml and maestro/journeys/mutation.yaml: yours stay where
they are, at the bytes you have, and update no longer touches them. The new
maestro/journeys/session.yaml is planted beside them, and the device lane runs every
journey in that directory.
One owned change needs a line from you if your install was created before 1.1.0:
tools/gen-event-catalog.mjs no longer reads the example's events by name ("The event
catalog: a vertical opts in from its ./client" in the 1.1.0 section). If your
packages/verticals/notes/src/client.ts does not export EVENT_CATALOG, add the line that
section shows and run pnpm gen:contracts; until you do, contracts reds on the example's
three rows. tools/generated/event-catalog.json is seeded from 2.0.0 (and write-guarded), so
update no longer re-plants it: your catalog is yours to regenerate.
eject removes the example from an install made with init --with-demo:
node <harness checkout>/installer/cli.mjs eject --dir . --dry-run # what it would removenode <harness checkout>/installer/cli.mjs eject --dir .pnpm installgit add -A && git commit -m "chore: eject the worked example"pnpm validateCommit before you validate: eject rewrites registers that gate-integrity holds to a
commit, so a pnpm validate before the commit fails on each of them. In the pull request,
migrations judges the deletions against its base. It deletes each of the example's files that still holds the bytes it was
installed with, gives each shared file it replaced the default install's bytes back, and
deletes the register rows template/demo-index.json lists where they still equal the rows
the example shipped. A file you changed is kept and reported; a shared file you changed keeps
your bytes, and the default copy parks under .harness/pending/ for you to merge; a register
row you changed is kept. Each migration it deletes is recorded with its sha256 under
ejectedMigrations in the manifest, and migrations accepts the deletion of exactly those
bytes with a NOTE per file. It still reds any other deleted or edited migration.
A database that applied the example's migrations keeps its tables. eject deletes the
files, not the schema, so a fresh supabase db reset no longer creates public.notes, but a
database you keep (staging, production) still has it, with its triggers and policies. If you
want it gone there, write a NEW migration, with an -- adr: comment as migrations requires
for a DROP TABLE. First re-create public.reconcile_org_usage() with the body
supabase/migrations/20260203000000_quota.sql gives it, because the example's rails
migration replaced that function with one that counts notes. Then
DROP TABLE IF EXISTS public.notes CASCADE. IF EXISTS keeps the migration valid on a fresh
database, where the table never existed.
An install created before 2.0.0 cannot eject. It records no demo choice, and eject
refuses it and exits non-zero. Its spine migrations (…_audit.sql, …_quota.sql,
…_mfa_aal2.sql) attach the rails to public.notes themselves, so deleting the example's
migrations would break a fresh database, and editing the spine would break append-only
history. To remove the example by hand:
- Write the NEW migration described above, so the table goes in history rather than by deleting the migrations that made it.
- Delete the example's code:
packages/verticals/notes/,packages/api/src/routers/notes.ts,apps/web/app/(protected)/o/[orgSlug]/notes/,apps/web/app/actions/notes.ts,apps/web/lib/app-data/notes.tsandnotes-model.ts,apps/mobile/src/features/notes/andmatrix/,apps/mobile/app/(tabs)/matrix.tsx, their tests and specs,supabase/schemas/20_notes.sql, and the two Maestro files above. Remove@app/notesfrom everypackage.jsonthat lists it, and the notes router frompackages/api/src/index.ts. - Run
pnpm install,pnpm gen, thenpnpm validate, and remove each row the chain names. In a harness checkout of 2.0.0,template/demo-index.jsonlists every register row the example adds, and the default tree's copy of each register is what it should come back to. - Record
"demo": falsein.harness/manifest.json, as a reviewed human commit. Until you do,boundariesreads the@app/notesrow oftools/exports-walls.jsonas live and reds it as stale once the package is gone.
Owned files re-planted when your copy still matches a released sha: eslint.config.mjs,
knip.json, vitest.config.ts and tsconfig.json (no path of the example's), the gates
that now accept a tree without it (tools/check-rls-manifest.mjs, check-query-shapes.mjs,
check-db-perf.mjs, check-rate-limits.mjs, check-perf-budget.mjs,
check-exports-walls.mjs, check-diff-coverage.mjs, check-migrations.mjs,
gen-event-catalog.mjs and tools/lib/event-catalogs.mjs), tools/conformance-map.json
and docs/security/threat-model.md, the guard rules, .github/workflows/quality-gate.yml
(its db-scale adoption step stands down when the query-shape manifest holds no shape),
tools/ci/device-lane.sh, maestro/journeys/i18n-rtl.yaml,
tests/rls/cross-tenant-isolation.test.ts (its isolation proof reads public.orgs), and the
authoring skill's scaffold-slice.mjs.
The registers that gained an empty state (tools/rls-exempt.json's mfaRailUnused row,
tools/rate-limit-budget.json's unmapped bucket) are seeded and change for fresh
scaffolds only.
Two more seeded files changed at the 2.0.0 cut, for fresh scaffolds only.
tools/duplication-allow.json gains 4f2c41321264, the mobile and web i18n catalogs'
match on a tree without the example (the same kind of match as e83e21400fb2; the span
moved with the example's keys). apps/mobile/__tests__/live-api-proof.test.ts writes the
caller's own profiles row before it reads it back, because nothing creates one at signup.
If you remove the example by hand, take both: add the entry when duplication (a Stop
step) names that fingerprint, and run
update --refresh-seeded apps/mobile/__tests__/live-api-proof.test.ts to replace the
example's live proof, which writes to public.notes.
Two registers reach a very old install as 2.0.0's default copies, because update plants a
seeded register only where it is absent. An install made before 1.0.0 that keeps the example
never had tools/suppressions-allow.json, and the default copy has no rows for the example's
files: when suppressions names them, take their rows from
template/demo/tools/suppressions-allow.json in a harness checkout of 2.0.0. An install made
before 0.6.0 receives tools/web-route-allowlist.json the same way, and it allowlists the org
landing page, which update withholds as a new exemplar: run
update --refresh-seeded 'apps/web/app/(protected)/o/[orgSlug]/page.tsx', or delete that row.
reviewer-verdicts: the ledger key is the session and a format stamp, not the prompt
Section titled “reviewer-verdicts: the ledger key is the session and a format stamp, not the prompt”Through 1.1.x every entry in .harness/reviewer-ledger.jsonl was keyed by its session and
its prompt. The reviewer ledger v2 already counts a PASS by the tree it reviewed (the
digests at its dispatch and at its verdict must equal the tree now), so from 2.0.0 the
prompt leaves v2's key:
- Every entry carries a format stamp,
"v": "2.0.0", written by.claude/hooks/subagent-verdict.mjsfromtools/lib/reviewer-verdicts.mjs. The step reads this session's entries by session and format. An entry in another format, which includes every entry a 1.1.x hook wrote, never counts as a PASS and never clears a BLOCK. A BLOCK in any format still stands until the same reviewer run passes. - A reviewer whose entries are all in another format reds with a finding that starts
<reviewer> has verdicts in this session only in another ledger formatand names the format. It is not "did not run": the reviewer ran, on the other side of the update. - The step needs only
HARNESS_SESSION_ID. The Stop hook still passesHARNESS_PROMPT_IDand the hook still recordsprompt_id, because the 1.0.x judgement keeps the prompt in its key. That judgement decides on a branch with no upstream, and, if yourbaseVersionis below 1.1.0, until 2.1.0. Run by hand with no prompt id, it now reds where it used to skip. - A ledger line that lacks
agent_typeorverdictfails closed until the prompt it was written in ends, as before. The finding now says so: re-running the reviewer does not clear it, and the next prompt does. End the turn and tell the user what it says.
What to do after the update, in this order.
- If
updateparked.claude/hooks/subagent-verdict.mjsortools/lib/reviewer-verdicts.mjs, merge the parked copy first (the 1.0.2 section, "Forking an owned file"). A kept fork of either writes entries without the stamp on every run, so the format finding comes back after each re-run. A kept 1.1.xtools/lib/reviewer-verdicts.mjsalso lacksreadSessionEntries, and the step says so in one finding. If you kept a fork oftools/check-reviewer-verdicts.mjsinstead, it goes on judging by the 1.1.x rule through the new lib, which keeps the functions it calls. - Run the owed reviewers once. In a session that spans the update, every verdict
recorded before it is in the old format. One run of each reviewer the next Stop names
records an entry in the new format. If a reviewer's BLOCK from before the update still
stands, resume that run (
SendMessageto itsagent_id): a fresh run is a second opinion and does not clear it. A session that starts after the update has nothing to re-run.
Owned files re-planted when your copy still matches a released sha:
tools/lib/reviewer-verdicts.mjs, tools/check-reviewer-verdicts.mjs,
.claude/hooks/subagent-verdict.mjs, .claude/hooks/stop-validate-gate.mjs (a comment),
docs/harness/gates-catalog.md and this runbook. Nothing here is ramped, withheld or
seeded.
2.0.1 — a security patch: the next floor moves to 16.3.8
Section titled “2.0.1 — a security patch: the next floor moves to 16.3.8”No ramp here applies to any install. 2.0.1 opens no ramp and moves no deadline. The population 2.0.0 reds is restated in this release's record, and the 1.1.0 and 2.0.0 sections above are still the sweep for an install below 1.1.0.
What update plants. Owned files, re-planted when your copy still matches a released
sha: tools/framework-floor.json, this runbook, and the hooks under .claude/hooks/ (their
version stamps). Nothing is withheld, and no seeded file changes on an existing install.
Expect version-sync to red on the next floor, whatever your baseVersion
Section titled “Expect version-sync to red on the next floor, whatever your baseVersion”This one is not a ramp, and no baseVersion is exempt from it. The next floor moves from
16.3.6 to 16.3.8 on the 16 line, and from 15.5.24 to 15.5.27 on the 15 line, for five
advisories upstream published on 2026-09-30:
- GHSA-cjq9-62q9-8jv4
(High): server-side request forgery in Image Optimization through an allow-listed remote
URL. Only an app that configures
images.remotePatternsis affected. - GHSA-4jqv-mc3x-m676 (Moderate, both lines): cache poisoning of statically generated and ISR pages on the Pages Router, when self-hosted.
- GHSA-mcj8-r9mp-w47p (Moderate, both lines): cache poisoning through a root-level catch-all page combined with static generation or ISR.
- GHSA-f87g-xv8r-7p7x
(Moderate): App Router metadata image routes built with webpack ignore
dynamicParams. - GHSA-39w2-rjm5-chcv
(Low): the
next devserver's Model Context Protocol endpoint checks no origin. Production builds are not affected.
None of them names the release that fixes it yet: each lists its patched version as 16.3.?
(and 15.5.? for the two cache poisonings). 16.3.8 and 15.5.27, the newest release on each
line, came out the same day as the advisories, and the floor moves to them rather than wait.
As shipped, the scaffold configures no images.remotePatterns, is App Router only with no
static generation or ISR, and has no metadata image route, so of the five only the next dev
disclosure reached a tree that added none of those. If yours added any of them, that
advisory applies to it.
tools/framework-floor.json is harness-owned, so update refreshes it. pnpm-workspace.yaml
is seeded, so update does not touch your pins, and the first pnpm validate after the
upgrade reds version-sync on a next pin below 16.3.8 (or 15.5.27 on the 15 line), naming
the pin, the floor and the advisories. Raise the pin yourself:
pnpm install && git add pnpm-lock.yaml pnpm-workspace.yamlpnpm validateFrom 16.3.6 this is a patch on the same minor. No flag lowers the floor. Do not use
update --refresh-seeded pnpm-workspace.yaml to take the pin: it replaces your whole catalog
with the template's.
2.0.2 — run the CLI at @latest; the installer refuses an older one
Section titled “2.0.2 — run the CLI at @latest; the installer refuses an older one”No ramp here applies to any install. 2.0.2 opens no ramp and moves no deadline. The population 2.0.0 reds is restated in this release's record, and the 1.1.0 and 2.0.0 sections above are still the sweep for an install below 1.1.0.
What update plants. Owned files, re-planted when your copy still matches a released
sha: this runbook, the hooks under .claude/hooks/ (their version stamps, and the write
guard's apply-proposal rule), the gate scripts under tools/ and the harness docs whose
messages print a CLI command. Nothing is withheld, and no seeded file changes on an existing
install.
Every printed command names @latest
Section titled “Every printed command names @latest”The gate messages, hooks and docs used to print npx next-expo-supabase-agent-harness <command>. When a global install is on your PATH, that bare form runs the global copy, at
whatever release it was installed at. They now print npx next-expo-supabase-agent-harness@latest <command>, which always fetches the newest release.
A fork you kept prints the old spelling, and it still runs.
If you installed the CLI globally (npm i -g next-expo-supabase-agent-harness), run
npm i -g next-expo-supabase-agent-harness@latest before update to move it to the newest
release.
update, enable, disable and eject refuse a CLI older than the install
Section titled “update, enable, disable and eject refuse a CLI older than the install”Each writes the running CLI's copy of the template, so an older CLI used to move an install
backwards without a word: a 2.0.0 CLI over a 2.0.1 install printed harness update 2.0.1 → 2.0.0 and rewrote 11 owned files. From the 2.0.2 CLI on, the four stop before their first
write and print both versions and the command to run instead:
error: this install is v2.0.2 and this CLI is v2.0.1, an older release, so `update` would write v2.0.1 files over it. Run the current release instead: `npx next-expo-supabase-agent-harness@latest update` (with a global install, run `npm i -g next-expo-supabase-agent-harness@latest` first).Running the same version as the install is still allowed. The check is in the installer, so only a 2.0.2 or later CLI makes it; an older CLI cannot be made to refuse.
graduate advances baseVersion to the install's version, not the CLI's
Section titled “graduate advances baseVersion to the install's version, not the CLI's”graduate used to set baseVersion to the version of the CLI that ran it. Run at @latest
on an install that had not been updated, it marked ramps the install does not carry as
swept. It now advances baseVersion to the install's own harnessVersion, whichever CLI runs
it. An install that is behind runs update first.
If your install graduated with a newer CLI before 2.0.2, its baseVersion in
.harness/manifest.json is above its harnessVersion. The checks ramped between the two
then arrive as hard reds rather than NOTEs when you update. Nothing in 2.0.2 rewrites a
recorded baseVersion.
2.0.3 — a patch: defect fixes
Section titled “2.0.3 — a patch: defect fixes”No ramp here applies to any install. 2.0.3 opens no ramp and moves no deadline. The population 2.0.0 reds is restated in this release's record, and the 1.1.0 and 2.0.0 sections above are still the sweep for an install below 1.1.0.
What update plants. Owned files, re-planted when your copy still matches a released
sha: this runbook, the hooks under .claude/hooks/ (their version stamps) and
tools/framework-floor.json. Each fix below says what else it delivers and what it leaves to
you.
Expect version-sync to red on the next floor, whatever your baseVersion
Section titled “Expect version-sync to red on the next floor, whatever your baseVersion”This one is not a ramp, and no baseVersion is exempt from it. The next floor moves from
16.3.8 to 16.4.1 on the 16 line, and from 15.5.27 to 15.5.28 on the 15 line, for three
advisories upstream published on 2026-10-14:
- GHSA-2464-fhr4-w9xw
(Critical, CVE-2026-96889, both lines): remote code execution in Image Optimization when
SVG files are used, on glibc-based Linux. The
/_next/imageendpoint exists in every install. - GHSA-673q-mjm6-r5xr
(Critical, 16 line): remote code execution in a
use cachefunction that returns typed arrays. The scaffold has none; upstream says to upgrade regardless. - GHSA-p3m5-59mp-jv5r (High, both lines): crafted requests to an App Router app with a Server Action use enough CPU to block the process. The scaffold's web half is built on Server Actions, so this one reaches every install.
tools/framework-floor.json is harness-owned, so update refreshes it. pnpm-workspace.yaml
is seeded, so update does not touch your pins, and the first pnpm validate after the
upgrade reds version-sync on a next pin below 16.4.1 (or 15.5.28 on the 15 line), naming
the pin, the floor and the advisories. Raise the pin yourself:
pnpm install && git add pnpm-lock.yaml pnpm-workspace.yamlpnpm validateFrom 16.3.8 this crosses a minor, because upstream patched no 16.3 release; read the 16.4
release notes for anything your app relies on. The fixed releases require sharp 0.35.5 or
later, which pnpm install resolves. No flag lowers the floor. Do not use
update --refresh-seeded pnpm-workspace.yaml to take the pin: it replaces your whole catalog
with the template's.
The session brief prints App Router paths (#153)
Section titled “The session brief prints App Router paths (#153)”Through 2.0.2 the brief (the SessionStart hook and node tools/harness-status.mjs) printed
every App Router route group and dynamic segment, such as
apps/web/app/(protected)/o/[orgSlug]/page.tsx, as (unprintable), the way it prints a
refused value. It now prints every path in a code span, ( ) [ ] included, and refuses an
absolute path, an empty or . segment and a segment that starts with -. update plants
tools/lib/harness-brief.mjs and docs/harness/README.md, both owned. Nothing is left to
you: the brief judges nothing, so no verdict moves. A fork of the lib you kept keeps the old
printer.
A green turn shows the gates' NOTE lines (#152)
Section titled “A green turn shows the gates' NOTE lines (#152)”Through 2.0.2 the Stop hook dropped every <gate>: NOTE — … line from a green step, and its
own notes (a floor it could not read, a previous turn that ended red) went to stderr, which a
hook that exits 0 sends only to the debug log. On a green turn you now see them in the same
message that shows FALLBACK MODEL lines: the first ten NOTE lines, each cut at 300
characters, then the hook's own notes. When that is not every line whole, all of them are in
.harness/stop-output/_notes.log. A red turn's block lists a green step's NOTE lines too.
update plants .claude/hooks/stop-validate-gate.mjs and docs/harness/README.md, both
owned. Nothing is left to you: no exit code changes and no verdict moves. A NOTE your gates
print on every run, such as the reviewer ledger's no-merge-base line, now shows on every green
turn; that is the line doing its job. A fork of the hook you kept keeps dropping the lines.
One migration you have to write yourself: schedule the auth-event trail's partitions (#146)
Section titled “One migration you have to write yourself: schedule the auth-event trail's partitions (#146)”Only if your project adopted the auth-event trail (20260816000000_auth_event_trail.sql,
from 1.0.0). If it never did, there is nothing to do here.
The trail's migration creates a partition for the month it ran in and the three after it, and
nothing ever created another: the guarded pg_cron block in the audit migration schedules only
audit's two jobs. From the fifth month after your database applied the trail, every sign-in
row lands in auth_trail.events_default, which retention never drops, and PostgreSQL refuses to
create a month's partition once the default partition holds rows in that month's range. The
trail first shipped on 2026-08-16, so no database reaches that point before 2026-12-01.
A fresh scaffold gets supabase/migrations/20261006000000_auth_trail_partition_schedule.sql,
which schedules both maintenance functions under pg_cron running as postgres, the way the
audit trail's are scheduled. update does not plant it in your project, for the 1.0.2
reason: supabase/migrations/ is your applied history.
First, see how long you have. In the SQL editor, or psql as postgres, on each database
that applied the trail:
SELECT max(c.relname) AS last_month_partition, (SELECT count(*) FROM auth_trail.events_default) AS rows_in_default FROM pg_catalog.pg_inherits i JOIN pg_catalog.pg_class c ON c.oid = i.inhrelid WHERE i.inhparent = 'auth_trail.events'::regclass AND c.relname ~ '^events_[0-9]{4}_[0-9]{2}$';last_month_partition is the last month that has a partition. Apply the migration below before
that month ends and no sign-in ever lands in the default partition. While rows_in_default is 0,
none has.
Then create your own migration:
supabase migration new auth_trail_partition_scheduleand put this in it. It is the shipped file without its header comment, and the same file is right for every database you push it to, whatever the query returned on each:
-- adr: docs/adr/20260816-auth-event-trail.md
-- On an existing install the catch-up below creates partitions of a table that takes a row-- on every sign-in, under an ACCESS EXCLUSIVE lock on the parent: fail fast rather than-- queue every sign-in behind a long-running reader.SET lock_timeout = '3s';
DO $cron$BEGIN IF EXISTS (SELECT 1 FROM pg_catalog.pg_available_extensions WHERE name = 'pg_cron') THEN CREATE EXTENSION IF NOT EXISTS pg_cron; PERFORM cron.schedule( 'auth-trail-ensure-partitions', '15 3 1 * *', $job$SELECT auth_trail.ensure_partitions(3)$job$ ); PERFORM cron.schedule( 'auth-trail-drop-old-partitions', '45 3 1 * *', $job$SELECT auth_trail.drop_partitions_older_than(interval '24 months')$job$ ); ELSE RAISE NOTICE 'pg_cron unavailable: schedule auth_trail.ensure_partitions(3) and auth_trail.drop_partitions_older_than() manually (see docs/adr/20260816-auth-event-trail.md)'; END IF;EXCEPTION WHEN insufficient_privilege OR feature_not_supported THEN RAISE NOTICE 'pg_cron present but not schedulable here (%); schedule the auth_trail partition jobs manually', SQLERRM;END$cron$;
-- Catch up now rather than at the first scheduled run. On a fresh database this creates-- nothing (the trail migration just made the same four months). On a database that applied-- the trail months ago it creates the months the schedule would otherwise reach only on the-- next first of the month. On a database already past its last month, the current month's-- rows sit in auth_trail.events_default, so that month can no longer be created and-- ensure_partitions() raises check_violation: create the three months after it instead,-- each exactly as ensure_partitions() would, so the schedule carries on from next month.-- The rows already in the default partition stay there.DO $catchup$DECLARE _start date; _name text;BEGIN PERFORM auth_trail.ensure_partitions(3);EXCEPTION WHEN check_violation THEN RAISE NOTICE 'auth_trail.events_default already holds rows for this month (%); creating the next three months only', SQLERRM; FOR _i IN 1..3 LOOP _start := (date_trunc('month', now()) + make_interval(months => _i))::date; _name := 'events_' || to_char(_start, 'YYYY_MM'); IF to_regclass('auth_trail.' || quote_ident(_name)) IS NULL THEN EXECUTE format( 'CREATE TABLE auth_trail.%I PARTITION OF auth_trail.events FOR VALUES FROM (%L) TO (%L)', _name, _start, (_start + interval '1 month')::date ); EXECUTE format('ALTER TABLE auth_trail.%I ENABLE ROW LEVEL SECURITY', _name); -- SOURCE: FORCE ROW LEVEL SECURITY subjects the table owner to policies; a partition carrying RLS with no policy of its own is deny-all for direct access [corpus: postgres/rls-force] EXECUTE format('ALTER TABLE auth_trail.%I FORCE ROW LEVEL SECURITY', _name); EXECUTE format( 'CREATE TRIGGER %I BEFORE TRUNCATE ON auth_trail.%I FOR EACH STATEMENT EXECUTE FUNCTION auth_trail.deny_mutation()', _name || '_no_truncate', _name ); END IF; END LOOP;END$catchup$;Then pnpm db:reset && pnpm db:test locally, and supabase db push for each hosted project.
Where the migration prints the pg_cron unavailable notice, nothing was scheduled: run the two
SELECTs in the cron.schedule calls yourself on the first of each month, before
last_month_partition runs out. This update re-plants two owned files that describe the
schedule: docs/adr/20260816-auth-event-trail.md, which records it, and
tools/conformance-map.json, whose notes on scheduled deletion and on trail retention now name
it. To pull the two pgTAP assertions that prove the jobs exist, after the migration is applied:
npx next-expo-supabase-agent-harness@latest update --refresh-seeded supabase/tests/auth_trail.test.sql.
That suite is seeded, so update keeps a copy you edited and parks the new one under
.harness/pending/.
If rows_in_default was not 0, sign-ins are already in the default partition. Once they
include the current month's, PostgreSQL no longer creates that month's partition:
ensure_partitions raises updated partition constraint for default partition ... would be violated and creates nothing. The migration's last block handles that database. It prints a
notice saying so and creates the three months after the current one, exactly as
ensure_partitions would, so from the next first of the month the scheduled job finds that month
present and carries on. The rows already in the default partition stay there, and retention never
drops them.
The vertical-slice scaffold writes the worked example's shape (#155)
Section titled “The vertical-slice scaffold writes the worked example's shape (#155)”Through 2.0.2 scaffold-slice.mjs wrote a slice's web page at apps/web/app/<slice>/,
outside the org scope and with no page.meta.ts, so a run with no edits turned
route-manifest red. It wrote none of the example's data seams, and its Server Action stub
taught a write with no org and a cast at each call site. It now writes the segment at
apps/web/app/(protected)/o/[orgSlug]/<slice>/ with its meta and loading state, a stub for
every seam, and apps/web/lib/app-data/<slice>-port.ts as the one narrowing function, and it
prints a next: line for each step it leaves to you. update plants the script, the skill's
SKILL.md and references/dal-dto.md, all owned. The script runs only when you run it, so no
slice you already have changes.
If your install has the worked example (--with-demo, or any install made before 2.0.0),
update withholds the example's new apps/web/lib/app-data/notes-port.ts: its three callers
are seeded and keep their casts, so nothing would import it. Nothing is left to you. To adopt
the one-cast shape, pull the file with
update --refresh-seeded apps/web/lib/app-data/notes-port.ts and make
app/actions/notes.ts, lib/app-data/notes.ts and app/api/trpc/[trpc]/route.ts call
toNotesPort. From 2.0.3 the example's own copy of app/api/trpc/[trpc]/route.ts calls
toNotesPort, so if you take that copy whole (on a demo install, or to apply 0.9.5's source
fix to an install made before 0.9.5), pull notes-port.ts with it.
The ./client census is a human's edit, and the messages say so (#154)
Section titled “The ./client census is a human's edit, and the messages say so (#154)”Through 2.0.2 a new vertical led an agent into an edit of tools/exports-walls.json. The
anatomy law requires every vertical's ./client key, boundaries reds a key the census does
not sanction, and its message said to edit the census. Nothing stopped that edit until
gate-integrity called it tampering at the next validate, because the census is
harness-owned and hash-pinned, and an owned file cannot be proposed. From 2.0.3 the write
guard denies an agent's Edit or Write of the census (rule exports-walls-census) with a
message naming the human path, both boundaries reds name it instead of "edit the census",
and scaffold-slice prints it as a next: line. update plants the write guard and its rule
table, both gate scripts, the scaffold script, the skill's SKILL.md,
.claude/rules/boundaries.md, .claude/agents/dal-author.md, docs/harness/README.md,
docs/harness/gates-catalog.md and docs/security/threat-model.md, all owned. No verdict
moves: gate-integrity already redded the edit the new rule denies.
The human path, for each new vertical. Add the {package, reason} entry to
tools/exports-walls.json under your security owners' review, and in the same reviewed
commit re-record the file's sha256 in .harness/manifest.json ("Forking an owned file" in
the 1.0.2 section). Edit the census outside an agent session or in one started with
HARNESS_ALLOW_SELF_EDIT=1, and the manifest outside one: the settings deny list keeps
.harness/** closed to the agent either way. From then on your census is a fork: update keeps it and, whenever a release
changes the census, parks the incoming copy under .harness/pending/ and exits 2 until you
merge it and re-record.
The bash guard denies every spelling of the skip-hooks commit flag (#222)
Section titled “The bash guard denies every spelling of the skip-hooks commit flag (#222)”Through 2.0.2 the bash guard's git-commit-no-verify rule denied git commit --no-verify and
a later, separate -n, and nothing else, so git commit -nm x, -n as the first argument,
-an anywhere, an abbreviated --no-verif and git -c k=v commit --no-verify committed past
lefthook. It now denies -n in any short-flag cluster, every prefix of --no-verify down to
--no-v, and those spellings after global options such as -c, -C and --git-dir=.
update plants .claude/hooks/lib/guard-rules.mjs, owned. Nothing is left to you. A commit
message that only mentions -n is now denied as well; put such a message in a file and
commit with git commit -F <file>.
The worked example builds its notes write context in one place (#144)
Section titled “The worked example builds its notes write context in one place (#144)”Through 2.0.2 the example built a note write's context twice. The tRPC router used a
writeContext function, and the Server Action in app/actions/notes.ts wrote an object
literal with a no-op event sink and a clock of its own. Both dropped every event, but a sink
wired at createContext's emit would have heard every tRPC write and no web one. From 2.0.3
the vertical exports noteWriteContext (src/data/write-context.ts), which the router and the
action both call, and apps/web/lib/request-ports.ts holds the web host's one sink (it still
drops events) and clock, which the action and app/api/trpc/[trpc]/route.ts both use.
update plants the skill's references/dal-dto.md and scripts/scaffold-slice.mjs, both
owned, which now teach the builder and the ports.
Nothing is left to you. No row, event or generated file changes, and no verdict moves.
update withholds the four new files: apps/web/lib/request-ports.ts and
apps/web/__tests__/request-ports.test.ts on every install, and the example's
packages/verticals/notes/src/data/write-context.ts and
apps/web/__tests__/notes-write-context.test.ts. Your route, action and vertical are seeded,
so they keep their own copies and nothing would import the new modules.
To adopt the new shape, pull what applies with update --refresh-seeded <path>. Any install
can take request-ports.ts and pass requestPorts()'s emit, and its now as () => now,
to createContext in app/api/trpc/[trpc]/route.ts. An install with the example also takes
write-context.ts, exports noteWriteContext from the vertical's src/index.ts, makes the
router's writeContext return noteWriteContext(ctx.actor, { id: orgId }, ctx), and makes the
Server Action call noteWriteContext({ userId: gate.data.userId }, gate.data.org, requestPorts()).
If you take one of the example's files whole instead, pull what it imports with it: from 2.0.3
its app/api/trpc/[trpc]/route.ts imports request-ports.ts, and its src/index.ts and
src/data/notes.test.ts import write-context.ts.
A note create past its quota reports quotaExceeded (#147)
Section titled “A note create past its quota reports quotaExceeded (#147)”Only if your project carries the worked example (init --with-demo). If it does not,
there is nothing to do here.
Through 2.0.2 the example's notes mapper had no case for SQLSTATE 53400, which the notes
table's per-org quota trigger raises, so the code fell into class 53's retryable fallback. A
create past the quota came back as unavailable, and the web told the user to try again
shortly, though only deleting notes or raising the ceiling frees a quota. From 2.0.3 the
mapper returns quotaExceeded, and the web shows the quota copy. Every other class-53 code is
still unavailable.
update does not change your mapper. packages/verticals/notes/src/data/errors.ts is
seeded, so add the case by hand. Declare the code beside the others:
const QUOTA_EXCEEDED = '53400'and give it its own case in mapPostgrestFailure's switch, before default:
case QUOTA_EXCEEDED: return appError.quotaExceeded({ message: 'a per-org quota refused the write' })src/data/notes.test.ts pins it in the template with a createNote case that fails with
53400 and expects that error. A vertical of your own that copied this mapper for a table
with a quota trigger has the same gap and takes the same case. No verdict moves, and nothing
is withheld.
Three copied bodies are one (#149)
Section titled “Three copied bodies are one (#149)”Through 2.0.2 three bodies were copied, each under the duplication gate's 70-token floor, so
none was reported. serverPublicCredentials in packages/platform/supabase/src/server-env.ts
repeated publicCredentials from public-env.ts; the mobile theme and locale stores each
kept a listener set with its own emit and subscribe; and the example's noteCreated and
noteDeleted differed only in the event name. From 2.0.3 the server function returns
publicCredentials(), both stores call createChangeSignal() from the new
apps/mobile/src/lib/change-signal.ts, and one private lifecycleEvent(name) builds the two
note events. No export, emitted event or gate changes.
Nothing is left to you. Every source file this touches is seeded, so your copies stay as
they are, and update withholds change-signal.ts and change-signal.test.ts: your stores
keep their own listener sets and would not import the module. To adopt it, pull both with
update --refresh-seeded <path>, replace each store's listener set, emit and subscribe with
const { emit, subscribe } = createChangeSignal(), and add
'<rootDir>/src/lib/change-signal\\.test\\.ts$' to testPathIgnorePatterns in
apps/mobile/jest.config.js, because the suite runs under vitest.
The push-notifications slice passes the anatomy laws once applied (#156)
Section titled “The push-notifications slice passes the anatomy laws once applied (#156)”Through 2.0.2 the push-notifications module's slice, applied as its APPLY.md said, redded
boundaries with five vertical-anatomy findings on @app/push: port-presence on
src/data/push-tokens.ts (its port was declared inline), port-presence and domain-purity
on src/domain/push-token-id.ts (Buffer.from( reads as a PostgREST call, and a domain file
may not import node:crypto), and two dual-barrel findings (the vertical has no ./client
barrel, on purpose). With the module enabled, update plants the corrected slice, APPLY.md
and the module README, all owned, and removes the two old
slice/packages/verticals/push/src/domain/push-token-id*.ts.txt files if you have not
modified them. If you have not applied the slice, nothing is left to you.
If you already applied it, packages/verticals/push/ is seeded, so update never touches
it. Make the same changes by hand, in one diff:
- Move the five port interfaces (
PushTokensFailure,PushTokensOutcome,PushTokensQuery,PushTokensTable,PushTokensDatabase) fromsrc/data/push-tokens.tsinto a newsrc/data/port.ts(the module'sslice/…/src/data/port.ts.txtis that file), import the two the DAL names withimport type { PushTokensDatabase, PushTokensFailure } from './port.js', and exportPushTokensDatabasefromsrc/index.tsthrough./data/port.js. - Move
src/domain/push-token-id.tsand its test tosrc/server/, point the DAL's import at../server/push-token-id.js, and replaceBuffer.from(hex, 'hex')withBuffer.alloc(16, hex, 'hex')andBuffer.from(digest.subarray(0, 16))withdigest.subarray(0, 16). The pinned id test passes unchanged, so no stored row re-keys. - Have a human add
APPLY.mdstep 8'sdual-barrelrow totools/vertical-anatomy-allow.json(write-guarded). If you had added rows of your own for the other three findings, delete them in the same diff: the file reds a row that matches no finding.
The two trails share one set of functions: one more migration of your own (#146)
Section titled “The two trails share one set of functions: one more migration of your own (#146)”Only if your project has the audit trail (20260202000000_audit.sql, every install
since 0.2.0). Through 2.0.2 the auth-event trail carried its own copies of the audit trail's
deny_mutation(), ensure_partitions(int) and drop_partitions_older_than(interval), one
schema over, and the copies drifted: the trail's maintenance was never scheduled (the
subsection above), its partition functions were revoked from PUBLIC only, and nothing
proved that a new month got its TRUNCATE guard. From 2.0.3 one audit.deny_mutation()
guards both trails and names the schema it fired in, and one pair,
audit.ensure_partitions(regclass, int) and audit.drop_partitions_older_than(regclass, interval), maintains both, refusing any other parent. The old signatures stay as one-line
wrappers, so the pg_cron jobs that call them by name keep working and none is re-pointed.
tools/tenancy.json does not change.
A fresh scaffold gets supabase/migrations/20261007000000_trail_shared_functions.sql.
update does not plant it in your project, for the 1.0.2 reason: supabase/migrations/
is your applied history. Nothing breaks if you leave it out, because the copies keep working;
what you keep is two sets of functions that can drift apart again.
To adopt it, create your own migration, after the partition-schedule migration in the
subsection above if your project has the auth-event trail (that migration's last block
creates triggers on auth_trail.deny_mutation(), which this one drops):
supabase migration new trail_shared_functionsand put this in it. It is the shipped file without its header comment. If your project
never adopted the auth-event trail (20260816000000_auth_event_trail.sql, withheld by
1.0.0), leave out everything from the The auth-event trail joins it banner to the end:
the first half changes only the audit trail.
-- adr: docs/adr/20260202-audit-trail.md-- adr: docs/adr/20260816-auth-event-trail.md
-- CREATE OR REPLACE TRIGGER takes SHARE ROW EXCLUSIVE on a trail that takes a row on every-- sign-in, and on every partition under it: fail fast rather than queue every writer behind a-- long-running reader.SET lock_timeout = '3s';
-- ─────────────────────────────────────────────────────────────────────────────-- The shared machinery, in `audit`-- ─────────────────────────────────────────────────────────────────────────────-- Replaced in place, so its OID, its owner, its revoked EXECUTE and every audit trigger that-- calls it stay as they are. The message for audit.events is byte-identical to before; the-- schema now comes from TG_TABLE_SCHEMA rather than the function's text, so one body names-- whichever trail refused. Every trail is called `events` (the parents below are a closed-- set), and TG_TABLE_NAME names the partition a row trigger fired on, as it always did.CREATE OR REPLACE FUNCTION audit.deny_mutation()RETURNS triggerLANGUAGE plpgsqlSET search_path = ''AS $deny$BEGIN RAISE EXCEPTION '%.events is append-only (% on % refused)', TG_TABLE_SCHEMA, TG_OP, TG_TABLE_NAME USING ERRCODE = '42501', HINT = 'Rows are never updated or deleted. Remove history by dropping a partition: ' || TG_TABLE_SCHEMA || '.drop_partitions_older_than(interval).';END$deny$;
-- The two-argument forms carry no DEFAULT on purpose. With one, a one-argument call such as-- audit.ensure_partitions('auth_trail.events') matches both signatures and PostgreSQL refuses-- it as "not unique" (verified against 17); without one, every two-argument call resolves.CREATE FUNCTION audit.ensure_partitions(_parent regclass, _months_ahead int)RETURNS intLANGUAGE plpgsqlSET search_path = ''AS $ensure$DECLARE _schema name; _table name; _i int := 0; _created int := 0; _start date; _stop date; _name text;BEGIN -- D3, the closed set. The parent is looked up by OID, never cast from a name, so a database -- that never adopted the auth-event trail still runs this for audit.events. SELECT n.nspname, c.relname INTO _schema, _table FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = _parent; IF NOT FOUND OR _table <> 'events' OR _schema NOT IN ('audit', 'auth_trail') THEN RAISE EXCEPTION 'audit.ensure_partitions: % is not an append-only trail', _parent USING ERRCODE = '22023', HINT = 'Only audit.events and auth_trail.events are partitioned here.'; END IF;
WHILE _i <= _months_ahead LOOP _start := (date_trunc('month', now()) + (_i || ' months')::interval)::date; _stop := (_start + interval '1 month')::date; _name := _table || '_' || to_char(_start, 'YYYY_MM');
IF to_regclass(format('%I.%I', _schema, _name)) IS NULL THEN EXECUTE format( 'CREATE TABLE %I.%I PARTITION OF %I.%I FOR VALUES FROM (%L) TO (%L)', _schema, _name, _schema, _table, _start, _stop ); -- Direct access to a partition is judged by the PARTITION's own RLS, not the parent's, -- so each month carries ENABLE + FORCE with zero policies: deny-all for direct access, -- while writes routed through the parent are judged by the parent. EXECUTE format('ALTER TABLE %I.%I ENABLE ROW LEVEL SECURITY', _schema, _name); -- SOURCE: FORCE ROW LEVEL SECURITY subjects the table owner to policies; a partition carrying RLS with no policy of its own is deny-all for direct access [corpus: postgres/rls-force] EXECUTE format('ALTER TABLE %I.%I FORCE ROW LEVEL SECURITY', _schema, _name); -- Layer 4's per-partition twin, in the branch that creates the partition. PostgreSQL -- does not clone TRUNCATE triggers to partitions, so without this a trail is emptiable -- one month at a time. EXECUTE format( 'CREATE TRIGGER %I BEFORE TRUNCATE ON %I.%I FOR EACH STATEMENT EXECUTE FUNCTION audit.deny_mutation()', _name || '_no_truncate', _schema, _name ); _created := _created + 1; END IF; _i := _i + 1; END LOOP; RETURN _created;END$ensure$;
-- Retention: the only sanctioned way rows ever leave a trail, and it is DDL that needs-- ownership of the parent. The cutoff comes from the partition NAME, which ensure_partitions()-- owns; the default partition does not match the pattern, so it is never a candidate.CREATE FUNCTION audit.drop_partitions_older_than(_parent regclass, _keep interval)RETURNS intLANGUAGE plpgsqlSET search_path = ''AS $retain$DECLARE _schema name; _table name; _dropped int := 0; _rec record; _month date;BEGIN SELECT n.nspname, c.relname INTO _schema, _table FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = _parent; IF NOT FOUND OR _table <> 'events' OR _schema NOT IN ('audit', 'auth_trail') THEN RAISE EXCEPTION 'audit.drop_partitions_older_than: % is not an append-only trail', _parent USING ERRCODE = '22023', HINT = 'Only audit.events and auth_trail.events are partitioned here.'; END IF;
FOR _rec IN SELECT c.relname FROM pg_catalog.pg_class c JOIN pg_catalog.pg_inherits i ON i.inhrelid = c.oid WHERE i.inhparent = _parent ORDER BY c.relname LOOP _month := to_date(substring(_rec.relname from _table || '_([0-9]{4}_[0-9]{2})$'), 'YYYY_MM'); IF _month IS NOT NULL AND _month < date_trunc('month', now() - _keep) THEN EXECUTE format( 'ALTER TABLE %I.%I DETACH PARTITION %I.%I', _schema, _table, _schema, _rec.relname ); EXECUTE format('DROP TABLE %I.%I', _schema, _rec.relname); _dropped := _dropped + 1; END IF; END LOOP; RETURN _dropped;END$retain$;
REVOKE ALL ON FUNCTION audit.ensure_partitions(regclass, int) FROM PUBLIC;REVOKE ALL ON FUNCTION audit.ensure_partitions(regclass, int) FROM anon, authenticated;REVOKE ALL ON FUNCTION audit.drop_partitions_older_than(regclass, interval) FROM PUBLIC;REVOKE ALL ON FUNCTION audit.drop_partitions_older_than(regclass, interval) FROM anon, authenticated;
-- D4: audit's old signatures, the ones its pg_cron jobs name, as one-line wrappers. Replaced-- in place, so they keep their revoked EXECUTE; a CREATE OR REPLACE may change the language-- and body but keeps the parameter names and defaults callers rely on.CREATE OR REPLACE FUNCTION audit.ensure_partitions(_months_ahead int DEFAULT 3)RETURNS intLANGUAGE sqlSET search_path = ''AS $wrap$ SELECT audit.ensure_partitions('audit.events'::pg_catalog.regclass, _months_ahead);$wrap$;
CREATE OR REPLACE FUNCTION audit.drop_partitions_older_than(_keep interval DEFAULT interval '24 months')RETURNS intLANGUAGE sqlSET search_path = ''AS $wrap$ SELECT audit.drop_partitions_older_than('audit.events'::pg_catalog.regclass, _keep);$wrap$;
-- ─────────────────────────────────────────────────────────────────────────────-- The auth-event trail joins it-- ─────────────────────────────────────────────────────────────────────────────-- Everything below names auth_trail, which 20260816000000_auth_event_trail.sql created. A-- fresh scaffold always has it; the runbook tells an install that never adopted the trail to-- leave this half out.---- Layers 3 and 4 on the parent, re-pointed in place. CREATE OR REPLACE TRIGGER swaps the-- function inside one statement, so the table is never without its trigger, and on the-- partitioned parent it re-points the row trigger's clone on every partition too (verified-- against 17).CREATE OR REPLACE TRIGGER events_immutable BEFORE UPDATE OR DELETE ON auth_trail.events FOR EACH ROW EXECUTE FUNCTION audit.deny_mutation();CREATE OR REPLACE TRIGGER events_no_truncate BEFORE TRUNCATE ON auth_trail.events FOR EACH STATEMENT EXECUTE FUNCTION audit.deny_mutation();
-- Layer 4's twin on every partition: the default one, and each month however many the-- database holds (the trail migration made four, the schedule adds one a month). Read from the-- catalog because the months exist only at runtime. tgparentid = 0 skips the row trigger's-- clones, which the statement above already re-pointed, and tgtype 34 is BEFORE (2) plus-- TRUNCATE (32), statement-level. A DO block is one statement, so it re-points all of them-- or none.DO $repoint$DECLARE _t record;BEGIN FOR _t IN SELECT t.tgname, c.relname FROM pg_catalog.pg_trigger t JOIN pg_catalog.pg_inherits i ON i.inhrelid = t.tgrelid JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid WHERE i.inhparent = 'auth_trail.events'::pg_catalog.regclass AND t.tgfoid = 'auth_trail.deny_mutation()'::pg_catalog.regprocedure AND t.tgparentid = 0 AND t.tgtype = 34 ORDER BY c.relname LOOP EXECUTE format( 'CREATE OR REPLACE TRIGGER %I BEFORE TRUNCATE ON auth_trail.%I FOR EACH STATEMENT EXECUTE FUNCTION audit.deny_mutation()', _t.tgname, _t.relname ); END LOOP;END$repoint$;
-- D4 again: the trail's old signatures, which its pg_cron jobs name, as one-line wrappers.CREATE OR REPLACE FUNCTION auth_trail.ensure_partitions(_months_ahead int DEFAULT 3)RETURNS intLANGUAGE sqlSET search_path = ''AS $wrap$ SELECT audit.ensure_partitions('auth_trail.events'::pg_catalog.regclass, _months_ahead);$wrap$;
CREATE OR REPLACE FUNCTION auth_trail.drop_partitions_older_than(_keep interval DEFAULT interval '24 months')RETURNS intLANGUAGE sqlSET search_path = ''AS $wrap$ SELECT audit.drop_partitions_older_than('auth_trail.events'::pg_catalog.regclass, _keep);$wrap$;
-- The REVOKE drift, closed: audit revoked its pair from anon and authenticated as well as-- PUBLIC, and the trail's copies only from PUBLIC. Unreachable either way (no client role holds-- USAGE on auth_trail), and now the same statement on both.REVOKE ALL ON FUNCTION auth_trail.ensure_partitions(int) FROM anon, authenticated;REVOKE ALL ON FUNCTION auth_trail.drop_partitions_older_than(interval) FROM anon, authenticated;
-- Last, and without CASCADE. Every trigger above now calls audit.deny_mutation(), so nothing-- depends on the copy; if anything still did (a trigger this file did not know about), the-- default RESTRICT refuses the drop and the whole migration fails rather than leaving a table-- whose guard was removed with it.DROP FUNCTION auth_trail.deny_mutation();Then pnpm db:reset && pnpm db:test locally, and supabase db push for each hosted project.
The migration re-points each trigger in place under lock_timeout = '3s', so a long-running
reader makes it fail fast rather than queue sign-ins; run it again when the reader is gone.
Its last statement drops auth_trail.deny_mutation() without CASCADE, so if anything of
your own still calls the old function, the drop and the whole migration fail and nothing
changes. This update re-plants both trails' ADRs (docs/adr/20260202-audit-trail.md and
docs/adr/20260816-auth-event-trail.md), which record the merge and what it costs: one edit
to audit.deny_mutation() now reaches both trails.
Two seeded files follow the migration, so update keeps your copies. To pull the pgTAP
assertions that prove the shared functions, after the migration is applied:
npx next-expo-supabase-agent-harness@latest update --refresh-seeded supabase/tests/auth_trail.test.sql.
supabase/schemas/40_audit.sql declares audit.deny_mutation(); replace it there with the
body above, or pull the file the same way.
2.0.4 — a patch: defect fixes
Section titled “2.0.4 — a patch: defect fixes”No ramp here applies to any install. 2.0.4 opens no ramp and moves no deadline. The population 2.0.0 reds is restated in this release's record, and the 1.1.0 and 2.0.0 sections above are still the sweep for an install below 1.1.0.
What update plants. Owned files, re-planted when your copy still matches a released
sha: this runbook and the hooks under .claude/hooks/ (their version stamps). Each fix below
says what else it delivers and what it leaves to you.
The guards deny NotebookEdit, lefthook's skip variables and git push --no-verify (#249)
Section titled “The guards deny NotebookEdit, lefthook's skip variables and git push --no-verify (#249)”Through 2.0.3 the write guard read only file_path and path, so a NotebookEdit, which
names its subject notebook_path, was judged on an empty path and passed, on a protected
path too. The bash guard let git push --no-verify skip the pre-push jobs, and let
LEFTHOOK=0, LEFTHOOK_EXCLUDE, LEFTHOOK_BIN and LEFTHOOK_CONFIG skip or replace the
commit-time layer. The write guard now reads notebook_path, and two new bash rules,
git-push-no-verify and lefthook-skip-env, deny the rest. update plants
.claude/hooks/pretool-write-guard.mjs, .claude/hooks/lib/guard-rules.mjs and
docs/security/threat-model.md, all owned. Nothing is left to you. A command that only
quotes LEFTHOOK=0, such as a grep for it, is denied as well; put the text in a file.
tools/eol.json re-reviewed
Section titled “tools/eol.json re-reviewed”The template's end-of-life register lapsed on 2026-10-20 and is re-reviewed to 2026-11-20.
The registry sweep found no new deprecation. Expo's supported set is now SDK 58, 57 and 56,
and react-native's is 0.88 down to 0.85. The register is seeded, so update leaves yours
alone, and its window lapses in the scheduled floor-review job on your own dates. To take
the review, run update --refresh-seeded tools/eol.json on an untouched register, or copy
the products sets and the two dates by hand.
The same day, @expo/config-plugins 57.0.11 moved to xcode 4, which drops uuid, so the
register's uuid row (the one production-scope acceptance, with removalTarget 2.1.0) is
deleted. Your own register keeps it until you act. After your next pnpm install resolves
the new config-plugins, version-sync reds the row as matching no deprecated resolution.
Delete the row, or take the whole register with update --refresh-seeded tools/eol.json.
The guards deny a write of lefthook-local.yml (#260)
Section titled “The guards deny a write of lefthook-local.yml (#260)”lefthook merges a local config over the committed lefthook.yml, so through 2.0.3 an agent
could Write a lefthook-local.yml that skips any commit-time job, and neither guard knew the
file. A new write-guard row, lefthook-local, denies every spelling lefthook reads
(lefthook-local, .lefthook-local and .config/lefthook-local, with .yml, .yaml,
.json, .jsonc or .toml), and the bash guard denies shell writes to them. update
plants .claude/hooks/lib/guard-rules.mjs and docs/security/threat-model.md, both owned.
A local config you already keep is left alone and lefthook still reads it. To change it
later, edit it yourself, or set HARNESS_ALLOW_SELF_EDIT=1 for the agent's session.
The template catalog moves Biome to 2.6.2 and vitest to 4.1.12 (#269)
Section titled “The template catalog moves Biome to 2.6.2 and vitest to 4.1.12 (#269)”Biome 2.6.0 added a Markdown and a YAML formatter, both on by default. update plants
biome.jsonc, which is owned: it names the 2.6.2 schema and excludes *.md, *.markdown,
*.yml, *.yaml and *.cff, so Biome 2.5 and 2.6 check the same files and neither rewrites
your docs, your workflows or pnpm-lock.yaml. Your catalog is seeded, so your Biome pin
stays where it is, and Biome 2.5 prints one information line saying that the configuration
schema version does not match the CLI version. It does not fail format. Raising
@biomejs/biome to 2.6.2 clears it; 2.6.0 and 2.6.1 were published with no platform binary
and do not run. If you keep a fork of biome.jsonc, copy the Markdown and YAML exclusions
into it before you raise the pin.
Raise vitest by hand. The catalog pins vitest and @vitest/coverage-v8 at
4.1.12, for GHSA-qxg6-89mg-rqv8 and GHSA-xp26-3454-mjgp (both moderate). Both are in
@vitest/mocker's mock registration over a dev server's WebSocket, and the second bypasses
the 4.1.11 fix on Windows. update does not touch your pins, and doctor does not ask for
this one: the floor it checks stays at 4.1.11. Move both pins together, because the coverage
package peer-pins the identical vitest:
pnpm install && git add pnpm-lock.yaml pnpm-workspace.yamlpnpm validateThe catalog also moves next to 16.4.2 and pnpm to 11.28.6, and neither is needed for this
release. If you raise pnpm, raise build.base.pnpm in apps/mobile/eas.json with
packageManager, or version-sync reds.
The agent-facing docs teach the org-scoped RLS shape (#255)
Section titled “The agent-facing docs teach the org-scoped RLS shape (#255)”Through 2.0.3 the vertical-slice skill's first step, /new-feature, /verify-invariants,
.claude/rules/security-invariants.md and the migration-rls-author and security-reviewer
agents taught policies keyed on owner_id = (SELECT auth.uid()) with a leading owner index,
the per-user shape the tenancy gate reds. They now teach the org-scoped shape: a NOT NULL
org_id frozen by a trigger, the member_org_ids() scope to read and the member_ranks()
rank floor to write, a leading org_id index, and owner_id as nullable attribution. No
gate changes. update plants the skill, the rule, the two commands and the two agents, all
owned. AGENTS.md and supabase/AGENTS.md are seeded, so update leaves your copies: if
their RLS paragraphs still teach owner_id = (SELECT auth.uid()), rewrite them to the shape
above by hand.
eject removes the demo files an earlier release wrote (#256)
Section titled “eject removes the demo files an earlier release wrote (#256)”Most of the worked example is seeded, so update leaves an untouched demo file at the bytes
your release wrote. Through 2.0.3, eject compared a seeded demo file only with its own
release's demo, so on an install made with --with-demo by an earlier release it kept every
demo file a later release changed as a "re-recorded fork" and parked the default copy of each
such shared file. From a v2.0.2 install that was eight files, five parked defaults and two
PARITY.md rows. It now also accepts the bytes any earlier release shipped as the demo. Nothing
is planted and nothing is left to you; if an earlier eject already kept such files, delete
them by hand, or take each parked default from .harness/pending/.
update knows what untagged main commits shipped (#259)
Section titled “update knows what untagged main commits shipped (#259)”The install command has no tag, so an install can come from any commit main pointed at.
update overwrites an owned file only when a release shipped the bytes it finds, and the
harness's lists of shipped bytes missed some untagged 1.0.4 and 1.1.0 commits. An untouched
install made from one of them had owned files such as tools/lib/gate.mjs kept as local
forks, with the new version parked under .harness/pending/, and gate-integrity called
some of its planted escape lists widenings. The lists are complete now, and the harness's own
CI checks every main commit against them. update plants tools/lib/planted-shas.json
(owned). If an earlier update parked a file you never edited, take the parked copy from
.harness/pending/, or run update again: it now overwrites the untouched file.
The slice scaffolder creates each stub exclusively (#163)
Section titled “The slice scaffolder creates each stub exclusively (#163)”.claude/skills/authoring-vertical-slice/scripts/scaffold-slice.mjs checked whether a
stub's path existed and then wrote it by name, so a file created between the two calls was
overwritten, and a dangling symbolic link at the path was written through to its target. It
now creates each stub with an exclusive write and prints exists, skipped: for any path that
already exists, a link included. update plants the script (owned). It runs only when
someone runs it, so no slice you already have changes, and nothing is left to you.
The device lane prints the perf-harness values after a pass
Section titled “The device lane prints the perf-harness values after a pass”Through 2.0.3, tools/check-e2e-device.mjs --phase perf-harness printed the screen's
measured values only when a budget failed, so the lane's log never held the medians that
tools/interaction-budget.json says a re-baseline starts from. After a passing flow it now
reads the screen and prints them in one line that starts e2e-device: on screen after perf-harness.yaml passed. No cap, verdict or exit code changes. update plants the runner
(owned).
The values come from apps/mobile/app/perf-harness.tsx, which from this release shows them
on a pass through the catalog key perf.measured. Both files are seeded, so update leaves
yours, and until your screen renders the values the line says no measured values. To take
them, run update --refresh-seeded on apps/mobile/app/perf-harness.tsx and
apps/mobile/src/i18n/catalog.ts, which parks the new copy under .harness/pending/ where you
changed yours, and bring in the pass branch and the key. Take both or neither: the i18n step
reds a catalog key that nothing renders.
2.0.5 — a patch: defect fixes
Section titled “2.0.5 — a patch: defect fixes”No ramp here applies to any install. 2.0.5 opens no ramp and moves no deadline. The population 2.0.0 reds is restated in this release's record, and the 1.1.0 and 2.0.0 sections above are still the sweep for an install below 1.1.0.
What update plants. Owned files, re-planted when your copy still matches a released
sha: this runbook and the hooks under .claude/hooks/ (their version stamps). Each fix below
says what else it delivers and what it leaves to you.
The doctrine says the Stop hook sends the agent back to work
Section titled “The doctrine says the Stop hook sends the agent back to work”Through 2.0.4, docs/harness/README.md said a turn cannot end while the gate is red and
called the block cap the one documented exception. It now says the Stop hook sends the agent
back to work while the gate is red, and names the two other cases in which Claude Code ends
the turn anyway: the hook reaching its timeout, and an exit 2 with nothing on stdout and a
stderr that says a file is missing. No hook, gate or setting changes. update plants the
doctrine (owned), and nothing is left to you.
Four gates run their tools without a shell
Section titled “Four gates run their tools without a shell”Through 2.0.4 edge-functions, gate-integrity, rate-limits and security-headers handed
what they read to a shell: function directory names, tsconfig paths, procedure and Server
Action names, and your project's path. $(…) or backticks in one of them could run a
command. A $NAME in your project's path made rate-limits and security-headers skip
locally and fail in CI. A tsconfig under a directory whose name has a space was looked up as
two paths that match nothing, so gate-integrity passed an uncommitted edit to it. The four
gates now pass each of these as one argument. update plants tools/check-edge-functions.mjs,
tools/check-gate-integrity.mjs, tools/check-rate-limits.mjs and
tools/check-security-headers.mjs, all owned. If such a tsconfig holds an uncommitted edit,
gate-integrity now names it; commit the edit, or undo it.
Raise zod to ^4.6.6 (GHSA-mrhx-c7xc-m3w5)
Section titled “Raise zod to ^4.6.6 (GHSA-mrhx-c7xc-m3w5)”zod through 4.6.5 can exhaust a process's memory while it validates a large array against an
array schema with no length limit (GHSA-mrhx-c7xc-m3w5, high). The zod: ^4.4.3 that every
release through 2.0.4 seeded in your catalog admits those versions. update does not touch
your pins: it names the floor and parks .harness/pending/pin-floors.json, and doctor
warns until the pin's lower bound is 4.6.6 or higher. To meet it:
# in the pnpm-workspace.yaml catalog: zod: ^4.6.6pnpm install && git add pnpm-lock.yaml pnpm-workspace.yamlpnpm validatenpx next-expo-supabase-agent-harness@latest doctordoctor then removes the parked file. @expo/cli 57, which Expo SDK 57 installs, still
depends on zod 3, which the advisory also covers. No catalog pin reaches it, so a scan of
your lockfile keeps reporting it; @expo/cli 58.1.18, on the SDK 58 line, no longer depends
on zod.
2.0.6 — a patch: defect fixes
Section titled “2.0.6 — a patch: defect fixes”No ramp here applies to any install. 2.0.6 opens no ramp and moves no deadline. The population 2.0.0 reds is restated in this release's record, and the 1.1.0 and 2.0.0 sections above are still the sweep for an install below 1.1.0.
What update plants. Owned files, re-planted when your copy still matches a released
sha: this runbook and the hooks under .claude/hooks/ (their version stamps). Each fix below
says what else it delivers and what it leaves to you.
A runbook for the first run
Section titled “A runbook for the first run”docs/runbooks/first-run.md is new: the steps from init to a first Claude Code turn that
ends green, and what that turn looks like with the local stack running and without it.
update plants it (owned), and nothing is left to you.
The brief says when the local database is not answering
Section titled “The brief says when the local database is not answering”Through 2.0.5 an agent learned that no local stack was running at its first Stop, when
rls-isolation failed closed. The session-start brief now ends with
local database: not answering, … when supabase/migrations/ exists and nothing answers on
supabase/config.toml's [db] port. update plants tools/lib/harness-brief.mjs and the
doctrine, both owned, and nothing is left to you.
RECOVERY — when an update is interrupted or fails
Section titled “RECOVERY — when an update is interrupted or fails”Every real update (0.9.0+) records the pre-update state of every path it
could touch as one blob under .harness/rollback/ BEFORE its first disk write,
and its writes are atomic (staged to a dot-tmp beside the destination, then
renamed) — a file is either its old bytes or its new bytes, never a truncation.
The ordering is: snapshot → deletions → file writes → manifest LAST. What to do,
by symptom:
- Commit before you update. The snapshot is the harness's recovery point; your commit is yours. Both existing is the posture every step below assumes.
- The update stopped partway (crash, ^C, ENOSPC). Re-run
update— the sweep is idempotent: files already rewritten re-record, files not yet reached are written, and the second run's report sayswritten: 0when there was nothing left. This is the default remedy. - You want the pre-update tree back (the update revealed a red you are not
ready to sweep, or you suspect damage):
npx next-expo-supabase-agent-harness@latest update --rollback— restores every recorded path byte-for-byte (files first, manifest last), deletes files the update created, and keeps the blob so a repeated rollback is a no-op. The nextupdatereplaces the snapshot;graduatedeletes it (restoring a pre-graduation tree would silently regressbaseVersion). check-gate-integrityreports a sha mismatch right after an update, ordoctorreports drift on a harness-owned file you never edited. Before reading it as tampering: an update that did not complete leaves exactly this shape. Re-runupdate(heals the partial state), orupdate --rollback(restores the recorded tree) — the gate and doctor messages name both.- A DRIFT report parks a file you never edited (
.harness/pending/<path>after an interrupted update on an older harness whose writes were not yet atomic): the parked copy is the harness's CORRECT version and the in-tree file may be torn. Compare them; if the in-tree file is truncated, take the parked copy (mv .harness/pending/<path> <path>), then re-runupdate. A torn file under.claude/hooks/is still the case to treat first, and since 1.0.0 it BLOCKS instead of disarming: every hook is invoked through the fail-closed launcher (launch.mjs), so a hook or library file that cannot LOAD exits 2 and refuses the action rather than failing open (the pre-1.0.0 behaviour, where Claude Code read the load-failure exit 1 as non-blocking and the agent-time layer was silently gone). The honest residual: a tornlaunch.mjsITSELF still fails open — the class cannot be closed from inside the process it disarms; what shipped is a shrink of the fail-open surface from every hook and library file to one tiny import-free file, re-probed at every Claude Code pin bump per CONTROL-PLANE-FACTS.
How to graduate
Section titled “How to graduate”- Sweep. Run
pnpm validateand fix everything the ramped check reports in its NOTE lines, exactly as if they were reds. Pull any new exemplars you want first (npx next-expo-supabase-agent-harness@latest update --refresh-seeded <path>— the update report names them). - Bump
baseVersionin.harness/manifest.jsonto the version the NOTE names (or the current release). This is a HUMAN decision: the file is write-guard-protected against agents, so edit it outside an agent session (a plain editor is fine), or runnpx next-expo-supabase-agent-harness@latest graduate— it runs the ramp-aware validate and advancesbaseVersiononly when zero ramp NOTEs remain. Do not bump past checks you have not swept — every ramped check at or below the newbaseVersiongoes live at once. - Re-run
pnpm validate. The NOTE is gone and the check is live: from now on a violation is a red, which is the point.
A corrupt manifest never ramps anything — the gates fail closed on unparseable
JSON (restore the file from git history; do NOT re-run init).
Content-conditional checks (data-shape ramps, no baseVersion involved)
Section titled “Content-conditional checks (data-shape ramps, no baseVersion involved)”Some checks key off the SHAPE or PRESENCE of a seeded data file instead of
baseVersion. Those files are seeded — update never rewrites them — so your
install keeps its old shape (and gets a NOTE naming the newer one) until you
pull the file deliberately:
npx next-expo-supabase-agent-harness@latest update --refresh-seeded tools/perf-budget.jsonIn this harness the pattern covers, among others:
tools/perf-budget.json— thesubjects[]render budgets and the dense-feature closure. Pull any exemplar the shape references first (apps/mobile/src/features/matrix/ships the workedperfSubject.tsx).tools/startup-budget.json—seedOnInitOnly: its rows name YOUR routes, soupdatewithholds it and the mobile-perf floor self-disables with an adoption NOTE until you write rows for your own screens.tests/rls/db-context.ts—seedOnInitOnly: itsISOLATION_TARGETSname YOUR tables; absent, the runtime isolation suite has nothing to iterate.tools/mutation-baseline.json—seedOnInitOnly: one project's accepted survivors must never become another's; the mutation ratchet notes its absence.
Graduation here is the file pull (or hand-authoring) itself — no baseVersion
bump.
Adopting the gzip ratchet (tools/perf-baseline.json)
Section titled “Adopting the gzip ratchet (tools/perf-baseline.json)”The build gate's byte-true ratchet keys off the PRESENCE of
tools/perf-baseline.json. It is seeded + seedOnInitOnly, so update never
plants it: without it the absolute bundle caps apply alone and the build gate
prints a NOTE naming the file. To adopt, generate the baseline from your own
bundle's real bytes — do NOT pull the template's shipped baseline, its numbers
describe the fresh scaffold, not your app:
pnpm perf:baselineReview the printed measurements, commit the JSON, and from the next validate the
build gate fails on measured gzip > baseline × ratioCap. Re-baseline after any
DELIBERATE size change with the same command in a reviewed commit — the file is
write-guard-protected against ad-hoc agent edits.