Repository navigation
Port the logged-in-pages audit branch onto main, one fix at a time (PORT-001) - #586
Conversation
Brought the local Supabase stack up healthy and applied all migrations, so this pass could judge the database by executing against it. marketplace_create_circle has never worked. It is security definer and pinned `set search_path = public`, but Supabase keeps pgcrypto in `extensions`, so every call since 0176 raised 42883 and no family has ever created a sharing circle. 0314 fixed a real ambiguous-character bug in a generator that never reached the point of generating. The guard that should have caught this was watching the exact line and asking the wrong question — it verified that a definer function PINS a search_path, which this one did. It now also asserts the pin REACHES what the body calls, resolving each function's effective definition so it judges the schema as it stands. The locator's own comment says "Strictly self-only — a member can only post their own location", and the server action honours it. That was the only place it held: both location tables carried one FOR ALL policy with no member_id condition, while the browser subscribes to them directly. Measured as a child: rewrote the parent's live location, deleted it, and fabricated an arrival event attributed to them. 0319 scopes writes to self-or-manager — deliberately not managers-only, since a device posting its own position is the ordinary path and a manager-only guard would have passed every "a child cannot" test while ending location reporting for everyone who is not a manager. AUTHZ-003 stood at FAIL for a cycle on source analysis alone, recorded as "no live unauthorized request was performed". Performed it: a read_only adult deleted their own restriction and became a marketing_manager with publish_posts and connect_accounts. social_role_for is a coalesce over a deletable row, and a coalesce over a deletable row is a privilege that grows back. 0320 gives DELETE the same rule INSERT and UPDATE already had. Running the probe suite twice also exposed three probes that were not proving what the green count implied: one passed on a virgin database and failed forever after, one errored rather than ran on every Supabase database it was ever pointed at (so the wallet overspend race had never once been exercised), and one aborted during fixture setup before any assertion ran. Suite: 41/41, stable across repeats. 16,704 unit tests pass on Node 24.21.0, the version the repo declares; the three failures seen under this container's Node 22 are PERF-002 and disappear under 24. Every new guard was reverted and watched go red. 0318, 0319 and 0320 are recorded as pending production migrations for a human to apply. Ported from 0c4ad3d on claude/logged-in-pages-supabase-7q6vtf onto main. Here: the definer fix lands as 0388. The location (0319) and social-restriction (0320) fixes are not ported: main's 0335/0379 and 0319/0344 already close both, more strictly, with their own probes. The three probe edits give way to main's re-controlled versions of the same probes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
invest_decide_order has two outcomes and only one has ever worked. The ledger
insert on the approval path writes `direction` from a CASE over two string
literals. A bare literal is `unknown` and coerces to the enum — which is why
'adjustment' and 'completed' on the two lines above it are fine — but a CASE
over two literals is `text`, and `text` does not implicitly cast to an enum.
So `type` and `status` are correct and `direction` is not, on adjacent lines
of one INSERT, which is why it survived review.
The asymmetry is why nobody noticed. Rejection returns before that INSERT, so
a parent could decline a child's investment for ever and the feature looked
alive. Only approval threw, and it has thrown since 0196 created the function:
no child has ever had a buy or sell order filled. 0321 adds the cast and
changes nothing else.
What found it is the more durable part. Both this and the circle bug hid
behind an early return — a membership check, a reject branch — so calling the
function with dummy arguments reports success without reaching the broken
line. A plpgsql body binds its SQL at CALL time, so it can be catastrophically
wrong and still install, deploy and pass CI. The new probe resolves all 70
plpgsql bodies in public against the live catalogue, trigger bodies against
each relation that fires them, and reports one error: this one.
The behavioural probe exercises both branches, because buy takes 'debit' and
sell takes 'credit' and a fix casting only one would leave half the feature
broken while a buy-only probe called it green. It asserts on the ledger
balance and the shares on the books rather than the returned {"ok": true},
which is a claim rather than an outcome.
Also checked and clean, recorded as negative results rather than left
ambiguous: all 42 statically-checkable RPC call sites match their signatures;
all 491 RLS tables are readable without a policy raising; the 57 fail-closed
tables are each reached only through the service role, as their own comments
say; a refused upsert RAISES on both the insert and the conflict path, so the
medical-profile save reports failure correctly without asking for rows back.
OPEN-001 records five member-data tables left deliberately unchanged. The
locator and AUTHZ-003 were fixed because the code declared a restriction the
table did not enforce; here nothing declares one, so changing the boundary
would be choosing the product's behaviour rather than repairing it. The
recommended shape and the reasoning are written down for whoever decides.
Probes 43/43. Unit suite 16,705/16,705 across 1,305 files on Node 24.21.0,
the version the repo declares. Types clean; lint at its documented baseline of
three pre-existing warnings.
Ported from 56cb936 on claude/logged-in-pages-supabase-7q6vtf onto main.
Here the migration is 0389 (was 0321; main claimed that number). Applied on a harness built from main plus 0388: the fill probe, the plpgsql-bodies probe and the refused-write probe pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
A single-choice poll took every choice. voting-module.tsx enforces the single/multi difference in the browser and its comment is exactly right about the consequence — "if this fails, do NOT insert or the single-choice poll ends up with two votes for this member" — but family_poll_votes carries UNIQUE (option_id, member_id), which is the right rule for a multi poll and no rule at all for a single one. Measured as a child: one member cast 3 votes across 3 options. voterCount and the result bars read straight from those rows. 0322 uses a trigger because the rule depends on family_polls.kind in another table and no index can reach across one. That difference also decides how pre-existing rows are handled, deliberately rather than as a softening: 0316 had to refuse to apply while duplicates existed because a unique index cannot be created over rows that violate it; a trigger governs new writes only, so this installs regardless, leaves existing rows alone, and reports what is already there. It takes `for update` on the poll, because a trigger that only SELECTs before it INSERTs is 0317's defect again. It fires on UPDATE too, because without that arm a member votes once legally and re-points a second row at the same poll — the probe confirms that path is real. families.timezone had no constraint of any kind. Both application paths already refuse a bad zone, but families_update is reachable by any manager's JWT, and a typo saves silently and puts the family on Greenwich time because Intl throws and every call site degrades to UTC by design. The interesting part of 0323 is what the guard must NOT do. Checking pg_timezone_names alone would be stricter than the application and reject CST and PST, which the form offers — closing the product instead of the hole. Postgres keeps IANA names and legacy abbreviations in two catalogues and Intl accepts both; measured on nine zones, the union agrees with Intl on every one, including each case where the two catalogues disagree. A trigger rather than a CHECK because a CHECK may only call IMMUTABLE functions and the zone database changes with the server's tzdata — an IMMUTABLE lie survives until a dump/restore revalidates it against a different catalogue. An earlier draft of the timezone probe asserted a null write should succeed and failed against the real schema: the column is NOT NULL with a default of UTC, so the column is the boundary there. The probe now says so and the trigger leaves null alone rather than raising a second error for one thing. Probes 45/45, stable across repeats. Unit suite 16,705/16,705 across 1,305 files on Node 24.21.0. Types clean; lint at its baseline of three warnings. Ported from d24509e on claude/logged-in-pages-supabase-7q6vtf onto main. Here the migrations are 0390 and 0391 (were 0322 and 0323). The timezone probe's control now asks the server whether it can resolve each zone first: on a host without tzdata-legacy (Ubuntu 24.04) US/Central is unknown, a family saved with it would run on UTC, and the guard refusing it is the correct answer. Both migrations applied and both probes pass on a harness built from main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
circle-join-code-check.sql calls marketplace_create_circle and asserts it works. It ran on every pull request. It passed on every pull request. Creating a sharing circle had been dead since 0176. The reason is one line in pg-bootstrap.sh: `create extension if not exists pgcrypto;` with no schema, which lands it in public. A real Supabase project puts pgcrypto, uuid-ossp and vector in `extensions` and gives the postgres role search_path = "$user", public, extensions. The broken function pinned search_path = public and called gen_random_bytes: fine on CI, 42883 on production. Measured on a fresh container replicating the CI job. With the pre-0318 definition restored and the old shim: probe exit 0. Same experiment after the shim was made faithful: exit 3, function gen_random_bytes(integer) does not exist. A guard that cannot fail in the environment it runs in is not a guard, and this is that defect in the test environment itself. The search_path half is not optional. Once pgcrypto leaves public, DDL that resolves an extension function at CREATE time — invites.token default encode(gen_random_bytes(24),'hex') — cannot find it, and every migration declaring one would fail. Matching production means matching both halves. Second: the runner reported a skipped probe as a pass. It branched on psql's exit code, and a skip exits zero. Two probes skip by design, so the wallet overspend race printed PASS while asserting nothing, and the new plpgsql body resolver would have skipped silently in CI from the day it landed. Skips are now counted separately, raised at WARNING so each probe's own client_min_messages=warning cannot swallow them, and PROBES_REQUIRE_ALL=1 makes them fatal. CI installs plpgsql_check into the service container and sets it, so if that step ever stops working the suite says so instead of running one probe fewer. The skip detector was tightened after it reported two false positives on its first run: family-self-read's own OK line says "197 empty, skipped", and Postgres narrates drop policy if exists as "does not exist, skipping". The marker is now the exact token PROBE-SKIPPED:. A detector that cries wolf is worse than none, because it teaches everyone to ignore the line. All 323 migrations replay onto the faithful bootstrap. 45/45 probes pass with 0 skipped on both the CI replica and the Supabase stack, strict mode on. Unit suite 16,705/16,705 across 1,305 files on Node 24.21.0; types clean. An earlier run showed five failures, all of them 5s timeouts from CPU contention while the daemon and eleven containers booted alongside it — they pass in isolation and in a clean full run. Ported from 831cb19 on claude/logged-in-pages-supabase-7q6vtf onto main. Here: the harness half (pgcrypto in `extensions` and the role search_path, as a real Supabase project has them) and the CI step that installs plpgsql_check are ported; main already runs 402 migrations cleanly with them. Main's runner already fails a skipped probe unless PROBES_ALLOW_SKIP=1, so this branch's PROBES_REQUIRE_ALL flag is not ported, and the plpgsql probe now announces a skip the way main's runner reads one (an uppercase SKIPPED on a NOTICE line) instead of a WARNING it would have counted as a pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
…rule vote-owner-check forged teen A's vote as sibling B in a poll where B had already voted. With 0390 (a single-choice poll takes one vote), the BEFORE INSERT trigger refuses B's second vote with 23505 before RLS's WITH CHECK runs, and the probe reads 23505 as "reached the unique index" and reports a breach. Nothing was written; the probe just could not tell which rule refused it. The forgery now goes into a second poll B has not voted in, where ownership is the only rule that can refuse it. A real breach still lands there and is still reported. 120 probes: 119 pass on the harness built from main plus the ported migrations, and plpgsql-bodies skips locally for want of plpgsql_check, which CI installs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
family-delete-cascade-check proves a family CAN be deleted — 0287 exists because sync_log_change() fired AFTER DELETE and wrote a row whose foreign key the just-removed parent could not satisfy. It names one table. Nothing asked whether anything was left behind. Measured against the fully seeded anchor family: 71,192 rows across 199 family-scoped tables, deleted in 0.64s, 0 survivors. The structure agrees — of 396 tables carrying family_id, 387 cascade, 8 use SET NULL and one has no foreign key, and all nine exceptions are deliberate: a push device belongs to a user who may join another family, support tickets and reviews and CRM contacts and affiliate commissions belong to a person or to the business, and move_date_recalculations is an idempotency ledger holding its own snapshot. They are now named in the probe so a tenth is a decision someone makes. The probe enumerates every family_id table at run time rather than listing them, because the failure mode is a table added later without a cascade and a pinned list is the guard that stops growing while the schema keeps going. It does both halves: structure cannot see a trigger that fires AFTER DELETE and aborts the cascade, so it also deletes a populated family inside a subtransaction it rolls back. Removing the cascade from notes.family_id proves it red, and that revert test earned its keep twice by finding two bugs in the probe itself. confdeltype is "char", so building the offender message raised `operator is not unique: text || "char"` — on the reporting branch only, which never ran while everything cascaded. And the handler caught raise_exception when a broken cascade actually surfaces as a foreign-key violation, so the diagnosis came out as an unhandled error instead of the message the probe exists to print. A guard whose failure path has never executed is a guard whose failure path does not work. Also checked and recorded clean: the 54 server-side writes to manager-gated tables that never ask for rows back are all either service-role, where RLS cannot refuse, or role-gated before the write. The components-only scope of the refused-write guard is a real boundary, not an oversight — components had no gate but a hidden button. Three static passes gave three different counts before client provenance was traced; the number a detector prints is not a finding until each site has been read. Probes 46/46 with 0 skipped on both the Supabase stack and the CI replica. Unit suite 16,705/16,705 across 1,305 files on Node 24.21.0. Ported from 7ed9c92 on claude/logged-in-pages-supabase-7q6vtf onto main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
no-dev-markers-in-shipping-code stripped every comment from each file and then searched the remainder for TODO / FIXME / HACK / XXX. The reason was sound — a comment saying "no TODO stubs here" should not be flagged as a TODO. But a developer marker lives in a comment by definition; the file's own header called it "the comment-marker convention". Stripping comments removed the guard's entire search space, leaving it able to match only a marker inside a string literal or an identifier, which is nowhere. So it reported, truthfully and uselessly, "0 hits across the whole tree" while app/, lib/ and components/ carried nineteen. This is the third shape of the same defect this cycle keeps finding: not a scope too small (TEST-007), not a premise that missed the bypass (SEC-006), but preprocessing that deletes the thing being searched for. The state underneath is good and that is worth saying plainly: all nineteen are owned — TODO(M6 undo), TODO(M35 dead-letter), TODO(migration, owner approval required) — and several say owner approval is required because they need a migration. The repository already keeps the discipline the guard claimed to enforce. The guard was not the reason, and would not have noticed if it stopped. It now scans comments and enforces that convention: a marker must lead its comment and carry a tracker reference. Leading is what separates a marker from prose about one, and it matters, because three of the nineteen are references inside a sentence — "shipped; see the TODO below" points at a real owned marker twenty lines down. A guard that flagged those would be deleted as noise within a week, which is the other way a guard stops working. The matcher's own behaviour is asserted directly, owned and unowned, marker and prose, comment and string literal, because the failure path had been broken for this guard's whole life. Appending a bare TODO to lib/utils/cn.ts turns it red. Build gate now carries current evidence: the tree compiles in 3.4 min and generates 252/252 static pages on Node 24.21.0, exit 0. Unit suite 16,706/16,706 across 1,305 files. Ported from b87a8c5 on claude/logged-in-pages-supabase-7q6vtf onto main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
The record carried the caveat that "current applied catalog/access has not been independently verified in this discovery". It has been now. Against a running stack, one object uploaded as the service role and then fetched with no JWT, no anon key and no cookie: upload to family-media -> HTTP 200 unauthenticated GET of family-media object -> HTTP 200, "secret family photo bytes" unauthenticated GET of documents object -> HTTP 400, "Bucket not found" Same bytes, same path shape, same request, both buckets seeded identically. The only difference is storage.buckets.public. family-media carries a correct policy — is_family_member((storage.foldername(name))[1]::uuid) — and the public URL never consults it. The policy guards the authenticated Storage API; the public URL walks past it. The mitigation is real and was checked rather than assumed: every object in a public bucket gets 122 bits from crypto.randomUUID, four assertions hold that, and no upload path in app/, lib/ or components/ still builds a name from a clock. The objects are not enumerable. What remains is that a URL once obtained — cache, referrer, forwarded link, the service-worker retention this record already documents — is permanent and unrevocable. No change to the bucket flag. Closing it needs every consumer moved off getPublicUrl onto signed URLs first; flipping it alone would blank every photo, avatar, attachment and album cover in the product, and the rollout order is already written down here. What is new is a probe that holds the shape in both directions. It does not assert family-media should be private. It asserts the internet-readable set is exactly the set someone declared, with reasons. Mutation-tested both ways: a fifth public bucket fails it, and flipping family-media to private fails it with a message naming the SEC-001 rollout and telling whoever did it to confirm the consumers moved. So the rollout cannot land quietly and a new public bucket cannot appear unnoticed — neither of which the existing test could catch, since its PUBLIC_READ_BUCKETS is a hardcoded list of four that cannot see the migrations creating buckets. Probes 47/47, 0 skipped. SEC-001 stays FAIL, because it is. Ported from 0f2c1f3 on claude/logged-in-pages-supabase-7q6vtf onto main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
wallet-concurrency-check left a member called "Race Child" and wallet-write-rls-check left one called "Probe Kid", an auth user, and a seeded audit row — the latter dated a week earlier, so it had been there across many runs. Both cleared their fixtures at the START of a run, which makes a probe repeatable and does not make it clean. Neither was found by looking for it. The cross-family sweep counted a family_members row with a null user_id in the anchor family that none of its own fixtures had created, and tracing that row led to the first; enumerating the anchor family's members to check for anything else found the second. This matters because of what that family is: 71,192 rows across 199 tables, what the erasure probe deletes, what the demos render, what other probes assert household shape against. A phantom child in it means anything counting members or listing children sees somebody nobody added. Both now clear their fixtures at the end as well as the start. No assertion changed; the cleanup is additive. The part that matters more is that the suite notices by itself now. run-probes.sh snapshots the anchor family's row counts across all 396 family-scoped tables before and after, and names any table whose count moved. Reported rather than failed, because a probe may one day have a good reason to seed there — but it will have to say so out loud. It earned itself on its first run, finding a third leak the two manual fixes had missed and that was larger than both: wallet-write-rls-check APPENDS two audit rows every run to prove wallet_audit_logs is append-only, which is as deliberate as it is permanent. Thirty-one copies of each had accumulated, one per suite run. Removal is matched on the exact detail strings so nothing a real family wrote is touched. A first draft of the drift check did not work at all — it carried a temp table between separate psql invocations, which do not share a session, and read a GUC never set. Rewritten as one query returning table=count lines, diffed, then tested by removing a cleanup and watching the drift appear. 47/47 with no drift on two consecutive runs on both databases. The replica's first run after the fix reported drift downward as the cleanup removed what earlier runs had left, then went quiet — which is what confirms a fix rather than the mere absence of a complaint. Unit suite 16,706/16,706. Ported from 56ff9b7 on claude/logged-in-pages-supabase-7q6vtf onto main. Here, onto main's reworked versions: main's wallet-concurrency probe still left "Race Child" in the anchor family, so the end-of-run cleanup is added to it, and main's runner gets the anchor-family snapshot. On the harness the first run reported the left-over fixtures being removed, as it should once. 121 of 122 probes pass; plpgsql-bodies skips locally. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
Fixing marketplace_create_circle — dead from 0176 to 0318 — raised the obvious question. Cross-referencing the 38 .rpc( names in app/ and lib/ against every docs/audit/*.sql and every file in tests/ left five, and three were in the same family: marketplace_leave_circle, marketplace_negotiation_offer, marketplace_negotiation_respond. All three work. Worth having proved rather than assumed — the last dead feature was hiding in exactly this gap, and it stayed hidden because nothing walked the path end to end on a production-shaped database. BUYER offers 8000 -> ok, negotiation opened at 8000 SELLER counters 9000 -> ok, amount 9000 BUYER accepts -> ok, status agreed, an order_id, 9000 listing status -> claimed Plus circle create, join by code, and leave, including the owner leaving taking the circle with it. The probe is the deliverable, and two things caught it rather than the product. Argument order: the first run reported not_authorized and looked like a real finding, but the signature is (p_listing, p_buyer_member, p_buyer_family) — member before family — and the call had them swapped. The app is immune because it calls by name. So the probe now asserts auth.uid() took AND that marketplace_member_id resolves to the member being passed, before believing any result. Then the on_family_created trigger: creating a family fires handle_new_family, which inserts the creator's membership, and the probe was inserting it too. It upserts now, stating the role it needs without fighting the trigger for it. Load-bearing: restoring the pre-0318 definition fails it with function gen_random_bytes(integer) does not exist. Had this probe existed, and had CI's database been shaped like production, DB-FN-001 would have been caught the day it shipped. 48/48, 0 skipped, no anchor drift, on both databases. Ported from e13e7df on claude/logged-in-pages-supabase-7q6vtf onto main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
signFeedToken read its key as `process.env.SYNC_TOKEN_KEY ?? ''`. On any
deployment that had not set the variable, verifyFeedSignature would have
accepted a signature an attacker computed the same way, and said nothing —
the exact shape hasCronAuthorization exists to avoid, where a missing secret
quietly becomes a usable one.
Every other use of this key already fails closed, which is what makes it an
omission rather than a choice: getKey() throws with instructions for generating
one, both sync callbacks carry the comment "fails closed without
SYNC_TOKEN_KEY", and architecture.md records the mechanism as fail-closed.
Nothing calls either function — exported and unreferenced across app/, lib/,
components/ and tests/. So it was never an active hole, and that is the reason
to fix rather than ignore it: a plausible-looking "constant-time signature
verifier" is what somebody reaches for later, and whoever wires it up first
inherits the fail-open. signFeedToken now throws on a missing key;
verifyFeedSignature returns false rather than throwing, so a caller using it as
authorization fails closed on every path instead of 500ing on one and admitting
the request on another.
Found by asking which of the 146 API routes no test refers to. That produced
20, and most were a measurement artifact — the routes are thin wrappers over
logic that is tested; sync/[provider]/callback looked untested while
sync-oauth-csrf.test.ts covers its state check, which is the part that matters.
Reading the remainder is what turned up the dead pair.
The test needed rewriting too. Its first draft asserted a genuine signature is
rejected while the key is missing, which returns false either way and so passed
against the very bug it was written for. The assertion that matters is the
forgery: compute HMAC('', token) as an attacker would and require a refusal.
Restoring the fallback now fails 2 of 4.
16,710/16,710 across 1,306 files; types clean.
Ported from 249eaba on claude/logged-in-pages-supabase-7q6vtf onto main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
…-001)
A layout guards a page, not a server action. app/(app)/admin/layout.tsx
redirects a non-super-admin to /dashboard, which is correct and is not the
control: a 'use server' export is its own endpoint with its own id, invocable
by anyone holding a session whether or not they ever render the page it was
written for. So each of the 138 exported admin actions has to refuse on its
own authority, and today each of them does.
Establishing that took three passes. Grepping each action's own body for
isSuperAdmin reports 38 failures, all false: the admin code gates through
helpers. One level of local indirection clears 35. The surviving three are the
support-ticket actions, two hops deep —
resolveTicketAction -> updateTicket() -> guard() -> isSuperAdmin()
— so the check walks the call graph to a fixpoint instead of grepping each
function, and 0 is the true answer.
The walk is by directive, not filename. All 31 files exporting actions today
have `actions` in the name, and a filename walk answers correctly on today's
tree — and would go on answering "all clear" the first time someone writes
'use server' into admin/<thing>/server.ts. A separate assertion fails if the
directive walk and the filename walk ever disagree; today they agree exactly,
31 = 31.
Two things it does not take on trust: comment bodies are blanked before the
search, so a gate named in prose is not a gate; and bodies are sliced at the
next top-level declaration of any kind, so a plain const between two actions
ends the first one's body and the second cannot inherit its gate. Arrow-form
actions are matched too, though none exist yet.
Mutation-tested twice. Stripping the gate from adminCreateUserAction, and
adding a 'use server' file under a name the filename walk would never open,
each turn it red naming the offender; the second is the one that matters,
because against a filename walk it stays green.
138 exported actions, 0 ungated. Suite 16,714/1,307 green on Node 24.21.0,
tsc --noEmit clean.
Ported from e9578f7 on claude/logged-in-pages-supabase-7q6vtf onto main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
SEC-010 (F-E07) — a signature check that ran only in a production build. Eight of the ten Twilio-facing webhooks wrapped their signature check in `if (process.env.NODE_ENV === 'production')`. Next.js inlines NODE_ENV at build time, so a `next build` artifact does verify — but the condition encodes "a production build" where the question is "may this caller in", and any deployment running without that served eight unauthenticated endpoints that write guardian rows, trigger scam detection, notify families and fan out SMS and voice. The ten copies also rebuilt the signed URL from NEXT_PUBLIC_APP_URL, and did not agree with each other on how: one stripped every trailing slash, three stripped one, six stripped none. Twilio signs the exact URL it requested, so a single slash in that variable yields `https://host//api/...` and a 401 on every inbound call for as long as it stays set — Guardian dead, wearing a provider-problem shape. lib/server/twilio-ingress.ts replaces all ten. The condition is gone: the gate asks whether this deployment holds TWILIO_AUTH_TOKEN, and with none it answers 503 rather than admitting the request. The single bypass is ALLOW_UNSIGNED_TWILIO_WEBHOOKS=1, set in vitest.config.ts and nowhere else, and powerless once a token exists. The signed URL comes from the request the platform received, with the configured value as a fallback; each candidate is still a full HMAC under the shared token, so offering two grants nothing. The guard that should have caught this asserted each route's source CONTAINS `validateTwilioSignature` and a 401. All eight did. It now fails on any NODE_ENV in these routes, and covers the Contact Center webhooks it never looked at. Three mutations turn it red: the wrapper restored, the gate made to admit with no token, the trailing-slash strip removed. SEC-011 (F-E08) — shared secrets compared with ===. Cron, internal server-to-server, the emergency SMS and voice blast to every parent, and the inbound-email ingress all decided on string equality, which returns in time proportional to the matching prefix. lib/server/secret-compare.ts uses timingSafeEqual at all four; bearerMatches builds the `Bearer ` prefix itself and refuses before doing so when the secret is absent, so the matchable "Bearer undefined" is never constructed. The fail-closed property is asserted five ways, because a constant-time compare that matched two empty strings would be worse than the bug it replaced. F-E06 is recorded as accepted, not fixed: the runbook documents the ?key= form as the primary integration for inbound-email providers that cannot set headers. The header is now preferred and the reasoning is at the call site. API-MKT-002 (F-E09) — a permissions refusal reported as an OpenAI outage. The AI route searched the thrown message for 'Forbidden', a word requireMarketingAdmin has never said, so a non-admin was answered 500 "Check that the OpenAI API key is set" — and the refusal was logged as an application error. MarketingAuthorizationError carries its reason, and the three catch blocks ask the type. An Error carrying the same words, and a provider fault containing 'sign in', are each asserted not to be a refusal. AUTHZ-007 (F-E04) — stored OAuth credentials answered client reads. PENDING PRODUCTION MIGRATION 0324. social_account_tokens held four client policies; sync_tokens, holding the same columns, has always been qual=false. Measured as a parent: read the stored credentials, rewrote the OAuth claim state, repointed the provider account, deleted the row, inserted one. Nothing loses access — every path already runs as the service role. Two upstream claims are corrected in the record: the policies read can_manage_family (a child was already refused), and the table is no longer unused. docs/audit/sensitive-role-boundary-check.sql asserted the opposite — that an adult could insert a token row. That expectation was the shape of the policy asserted back at itself, and is replaced by the control that matters: the service role still reads and writes, so closing the table cannot be confused with breaking the feature. Five existing guards needed repair, not just re-running: each identified a control by the spelling of the function implementing it, so moving identical verification one call away turned them red. Same reason the signature guard stayed green through eight conditional checks. Suite 16,747/1,310 green on Node 24.21.0; probes 49/49, 0 skipped; tsc clean. Ported from 33567c8 on claude/logged-in-pages-supabase-7q6vtf onto main. Migration 0324 is 0392 here, and it drops the policy without adding a deny-all replacement: RLS on with no policy refuses every client role the same way, and it leaves the table in the shape #556's 0361_social_tokens_service_role_only asserts, so either can land second. Main's sensitive-role probe asserted a manager could read and insert tokens (controls b and c); those now assert the refusal, matching #556's rewrite, and the adult block ends in a service-role read and write that proves the feature still works. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
PENDING PRODUCTION MIGRATION 0325 — it must land together with this code.
feedback-attachments was public = true AND carried an unscoped read policy:
objects | Feedback attachments are publicly readable | SELECT | (bucket_id = 'feedback-attachments')
So every object was readable twice over — by the public path, which does not
consult storage.objects RLS at all, and by the authenticated path, whose policy
asked only which bucket the object was in. Writes were already scoped to the
uploader's folder; reads were not scoped at all.
These are screenshots taken at the moment something in the product went wrong,
which is to say screenshots of a real family's calendar, children's names,
balances or documents. Object names are UUID-based so they are not enumerable,
and that was the only thing limiting this.
Measured against the running stack — same object, same path, no credentials:
unauthenticated GET, bucket public -> HTTP 200, 67 bytes
unauthenticated GET, bucket private -> HTTP 400, "Bucket not found"
unauthenticated GET of a signed URL -> HTTP 200, 67 bytes
Why this closes where SEC-001 cannot: exactly one surface renders these
objects, the super-admin triage console, reading through the service role. The
public Idea Board selected image_url and never drew it. So there was no fleet
of getPublicUrl consumers to migrate — one page learns to sign, and the board
stops fetching the column, because shipping every attachment's location to
every signed-in user is the same exposure in miniature.
Three things make the flag flip safe rather than destructive: the console mints
one 10-minute signed URL per object per render; the uploader now records the
storage path, since getPublicUrl on a private bucket returns a string that
resolves to nothing; and feedbackAttachmentPath reads either form, so rows
written before 0325 are not rewritten and keep working. That is the whole data
migration: none.
A value that cannot be resolved comes back null, never passed through — that
would put a dead URL into an <img src> and make a private bucket look like a
broken one, and is the only way an external URL could reach that tag.
The replacement policy is owner OR is_super_admin(), and the reasoning needed
checking first: the Idea Board is deliberately cross-user, so a reader-scoped
policy would have broken the product if the board drew these images.
bucket-visibility-is-declared-check.sql reported DECLARATION STALE the moment
the flag changed, which is what it was written to do; its declared list now
names three buckets. public-bucket-objects-are-unguessable keeps this bucket in
its own list on purpose — an object name is the last thing standing if a policy
is ever loosened again.
Mutations: flipping the bucket back is red on the flag; restoring the blanket
policy is red twice, on the policy's shape and on a real cross-user read.
Suite 16,756/1,311 green; probes 50/50, 0 skipped; tsc clean; lint clean.
Ported from 8ec80f8 on claude/logged-in-pages-supabase-7q6vtf onto main.
Migration 0325 is 0393 here. Main's 0369 had already dropped the unscoped read policy and installed an own-folder read, so 0393 is reduced to what 0369 left open: the bucket's public flag. The owner-or-admin policy the original added is not carried over, because the admin console reads through the service role and 0369's owner policy already covers the uploader.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
…AUTHZ-008)
PENDING PRODUCTION MIGRATION 0326.
requireAal2 protects 19 server-rendered pages with redirect(). The data those
pages show is not fetched by those pages — it is fetched by client components
straight from PostgREST with the browser's own session:
components/modules/passwords-module.tsx:66
sb.from('family_credentials').select('*').eq('family_id', familyId)
An aal1 session is a fully valid Supabase JWT. The redirect only fires if the
browser asks Next.js for the HTML page, which someone holding a stolen session
cookie has no reason to do. Before this migration the live catalogue had zero
policies mentioning aal. Step-up was presentational.
session_meets_assurance() mirrors needsStepUp in lib/auth/mfa.ts rather than
inventing a second policy in SQL: a session passes at aal2, or when the account
holds no verified factor to step up with. Getting that backwards would lock
every family without an authenticator out of their own passwords, and the probe
asserts exactly that case — the upstream's suggested helper, which checks only
the aal claim, fails it.
Three tables carry the restrictive guard, and the set is the measurement, not a
preference: family_credentials, tax_documents and household_info are each read
by one module, rendered by one page, and that page already calls requireAal2.
The family hub's reference is a head:true count, so the only effect outside the
vault is a tile reading 0 until a code is entered.
bills, documents and paperwork_items are deliberately NOT guarded. They are
read by readiness, agents, planning, command-center, needs-you,
finances-module, billing-module, files-hub-module, lib/inbox/server.ts,
lib/contact-center/server.ts and three AI routes, none behind requireAal2.
Guarding them today would blank all of that for an enrolled aal1 session with
no explanation — the silent-empty-read defect this audit spends its time
removing. F-E02 stays partly open, with that list recorded rather than implied,
and the probe asserts the guarded set IS the contained set in both directions.
The upstream also proposed wiring aal2Verdict into the route handlers behind
these pages. Measured: the vault has no route handler and no server action, so
for these tables the database policy is not one of two layers, it is the only
layer there can be.
Also recorded, not fixed: SEC-013. family_credentials.secret is text and holds
what was typed — stored and read back as "hunter2-the-actual-wifi-password",
33 bytes for a 32-character password. That is the half of F-E01 that 0296 does
not fix; 0296 narrowed who may read the row and says so in its own header. RLS
has nothing to say about a pg_dump, a backup, a replica or the service-role
key. Encrypting needs the write path off the browser and a key-management
decision, so the analysis and a recommended shape are written down for the
owner instead of half-built.
Suite 16,756/1,311 green; probes 51/51, 0 skipped; tsc clean.
Ported from f6b27a3 on claude/logged-in-pages-supabase-7q6vtf onto main.
Migration 0326 is 0394 here, and it reuses main's session_cleared_step_up() (0382) instead of defining session_meets_assurance(): the two encoded the same rule, and one definition cannot drift. The header now records the AI insights route as a third reader of tax_documents and household_info (reachable only from the two gated pages), and the probe sets request.jwt.claim.sub as well as the JSON claims so it impersonates under main's auth.uid() stub.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
F-F01 fixed lib/supabase/read-all.ts: it now reads one row past the caller's
max and returns
readAll reached the caller's max of 2000 rows and more remain.
These rows are a PREFIX, not the whole set — treat this as a failed read.
That signal is worth exactly as much as the call sites that read it, and eleven
did not. Every one carried a comment explaining why a capped read would be
wrong, then destructured the rows and dropped the error saying it happened:
family-digital-twin "a capped read understates spending"
-> the simulator says a purchase fits a budget it does not
playbook (x3) "a capped read does not shorten a list, it reports the
wrong number"
-> suggestions presented as what the family's history supports
sync/feeds/[token] "a busy calendar published 1,000 events and called that
the feed"
-> a subscriber's client reads missing events as cancellations
github-sync "would have started re-opening issues it believed unlinked"
-> writes, against ideas that already had one
matches-server -> matches computed from part of the catalogue, then persisted
marketplace pages -> "0 saves", "0 open offers", rendered as facts
ai/wallet, ai/wallet/child, ai/habits
"Money, so a quietly truncated read is a wrong balance"
-> a wrong number handed to a model that restates it as prose
All eleven now act on it, each in the shape its own contract already had: a
refused verdict in the simulator, an abandoned playbook refresh, 503 on the
calendar feed and the three AI-coach routes, an early return before the GitHub
sync's write loop, an empty result rather than a persisted partial snapshot,
and ErrorState on the two marketplace pages.
tests/a-capped-read-is-not-a-silent-one.test.ts makes it permanent, and its two
detectors are deliberately not equally strict. The destructure form — the shape
that went wrong eight times — is checked exactly. A named batch binding is
checked forgivingly, because the strict rule reported five handled reads on the
admin reports page and one in the Autopilot scan, which collect their results
and ask once across statements and lines. A guard that reports correct code is
a guard somebody deletes.
The detector took four passes and over-reported on the first three: 54 on a
name-only scan (lib/metric/strategy-server.ts has its OWN local readAll and
contributed nine false positives), 17, 15, then 11. A detector's first number
is a hypothesis, not a finding.
Also found: tests/ai-habits-journal-observability-outcomes.test.ts stubs the
query builder with no `range`, which is what readAll pages with. That call
threw, readAll turned it into { rows: [], error }, and all seventeen cases ran
the route's failed-read branch while asserting the successful one — passing
only because the route discarded the error. The stub now models `range`.
Five i18n keys added across all seven populated catalogues; the ungated surface
is back to its ceiling of 2,811, unchanged.
Suite 16,761/1,312 green on Node 24.21.0; tsc clean.
Ported from 0dbc459 on claude/logged-in-pages-supabase-7q6vtf onto main.
Main had already made the two AI wallet routes, the ICS feed and the playbook's three reads act on their errors (the wallet routes on every read in the batch, the feed with a cap-versus-failure distinction), so main's version is kept in all four. The playbook's three truncation refusals now return the translated message instead of the raw database error. The ported ratchet then found one more batched capped read with its error dropped, in app/api/ai/invest/route.ts (the portfolio value the model is told), which now refuses with 503.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
…F-F02) `new Date(); d.setHours(0, 0, 0, 0)` is midnight WHERE THE PROCESS RUNS. In a browser that is right — the browser is the family. On a server it is whatever zone the host is in, so on a UTC deployment a Californian family's "today" ran 17:00 to 17:00. Measured with the two halves separated: 8 client files, where it is correct, and 19 server files, where it is not. The kids page is the one F-F02 names: a child's day came from the host's midnight and filtered calendar_events by it, so last night's events sat on this morning's list and tonight's were missing from it. The moments page shows this was drift and not a decision — one line reads the day as dayKeyInTz(now, ctx.active.family.timezone) and the next computes "tomorrow" from setHours(0,0,0,0). Two notions of a day, adjacent. lib/server/notifications.ts shows how far a wrong zone travels: timeLabel decided today/tomorrow from the host's midnight AND formatted the clock with no timeZone, so a 7 p.m. appointment read as "tomorrow at 3:00 AM" — wrong twice in one sentence, in the copy of every event, chore, sports, school, reminder and conflict notification. startOfLocalDay / startOfNextLocalDay now carry it, and two details are why they are functions rather than an inline expression. The end of a day is the next local midnight, not +86_400_000: a day is 23 or 25 hours on the two changeovers, and a fixed day lands an hour inside the next one (spring) or an hour short of this one (autumn) — asserted against real Los Angeles dates. And a day can begin at 01:00, because in some zones the clocks jump at midnight and 00:00 does not exist that date; instantForLocalTime walks to the first minute that does. An unusable zone falls back to the old behaviour, because a correction must never be why a page stops rendering. Corrected: kids, guardian's four "today" counts, moments' tomorrow window, and dayBounds() in both dashboards — where the week boundary moved too, since getDay() on a host-zone Date can name a different weekday than the family is living. 19 server files down to 14, and the rest are DECLARED with a reason each: nine relative-day helpers parameterised on `now` and shared with client components, where the fix belongs at the server call sites; four AI daily-quota buckets, where moving the reset is a quota decision not a display fix; one is the new helper's own fallback. The declaration fails in both directions, so the list stays exactly the set. Suite 16,772/1,314 green under TZ=UTC and again under TZ=America/Los_Angeles — a timezone fix that only passes on a UTC host has proved nothing. tsc clean. Ported from bfc19ef on claude/logged-in-pages-supabase-7q6vtf onto main. Import lines merged with main's getLocaleContext and loadMomentPrep. lib/family/signals.ts is dropped from the ratchet's declared host-zone list because main had already moved it onto the family's zone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
…F03)
app/(app)/missions/page.tsx built proof-photo URLs in a loop inside a loop:
for (const s of subs) {
for (const path of (s.media_paths ?? []).slice(0, 4)) {
const { data } = await supabase.storage.from('chore-proof').createSignedUrl(path, 600);
One request per photo, in series, up to four per submission across the whole
queue — a few hundred sequential round trips on a busy Saturday, before the
page renders. This is the parent approval queue, the page a parent opens most.
The batch call was already in the codebase and already used correctly, one
directory away in admin/marketing/assets. Nothing stopped the singular one
being reached for in a loop, which is what the new guard is for.
Paths are now collected, deduplicated and signed in chunks of a hundred —
chunked because the request carries every path in its body, so a large queue
should be a few requests, never one enormous one and never one per photo. Two
details come from the existing correct call site rather than invention: the
per-entry error is read, so an object that cannot be signed is left out instead
of becoming an <img> with an empty src; and a signing failure logs and breaks
rather than failing the page, because a proof photo that will not load is a
missing photo and the parent still needs the queue, the note and the summary.
tests/signing-media-is-one-round-trip.test.ts reports a singular
createSignedUrl whose enclosing code will run it once per item, and never
reports the batch form or the three remaining single-object calls, which are
correct. Restoring the loop turns it red at missions/page.tsx:106.
A second guard noticed without being asked: silent-empty-read-ratchet keeps a
BASELINE of files that drop a read's error and fails when an entry is stale,
because a stale entry hides a slot a regression could slide into. Batching took
the discarded per-photo error with it, so missions came off that list too.
Suite 16,776/1,315 green; tsc clean.
Ported from 44df1f9 on claude/logged-in-pages-supabase-7q6vtf onto main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
The viewer was a `fixed inset-0` div with no role, no aria-modal, no Escape, no focus trap and no focus restore — and the tiles that opened it were `<div onClick>`, so a keyboard could not reach it at all. That is the more complete failure: a trap you cannot enter is not an improvement over one you cannot leave. The behaviour it needed already existed. lib/a11y/use-dialog-behavior.ts was extracted precisely because Modal coupled the dialog behaviour to its own chrome, and its docstring records that eleven overlays declared aria-modal while implementing none of it. The lightbox is the case that extraction was for: a full-bleed viewer with its own controls, which could never take Modal's title bar and bottom-sheet layout. The guard that should have covered it was an enumeration — four overlays named by hand in mobile-overlay-dialog-a11y — so anything not on the list was never looked at. Enumerated with no scan, the third shape of this repo's signature defect. Now: role="dialog", aria-modal, an aria-label from the caption, and the hook keyed on the REAL open condition rather than `true` (the panel only exists while open, and the hook's own docstring warns an effect keyed on a literal runs once on mount and never again). Tiles are role="button" with tabIndex and Enter/Space rather than <button>, because each carries hover controls and nesting buttons is invalid. Arrow Left/Right move between photos, clamped. The replacement guard is a scan and an equivalence in both directions: everything declaring aria-modal uses the hook, and everything using the hook declares it — a trapped, Escape-closable overlay that never says aria-modal leaves a screen reader announcing a background it can no longer reach. 13 declare it, 13 implement it, and the sets are identical. A file-wide scan was tried first and reported 32, almost all dropdown dismiss-catchers, so the narrower exactly-checkable property is what is asserted. An allowlist also retired itself. consent-preference-centre-focus carried eleven components permitted to hand-roll aria-modal, written when only one of them called .focus() at all, with the note "adding to it is the finding". All eleven now delegate to the hook, so its premise is spent: the two cases are replaced by the property they were standing in for, plus non-vacuity on the walk. A twelfth entry would have been the wrong answer. Suite 16,783/1,316 green; tsc clean; lint clean. Ported from 18dbc7e on claude/logged-in-pages-supabase-7q6vtf onto main. Main had already rebuilt the lightbox for the keyboard: native buttons open it, useDialogBehavior traps and restores focus, and arrow keys move between photos. Main's component is kept. The ported scan found one gap: the dialog never declared aria-modal, which is now added. The opener assertion accepts a native button as well as a role=button tile, since main's shape is the better one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
…ee (F-D10)
.eslintrc.json was three lines. `next/core-web-vitals` carries a SUBSET of
jsx-a11y — alt-text, aria-props, role-has-required-aria-props and a few more —
and not label-has-associated-control. F-D10 named that as the root cause of the
detached labels and unnamed controls, and it was right: nothing in the
toolchain could see either class.
label-has-associated-control is now enabled, at depth 4. The setting was
measured before it was chosen: at the rule's default depth of 2 it reports
exactly three files, and all three are correct markup whose text sits one level
deeper than the default allows —
<label><input type="checkbox" /><div><p>Emergency contact</p><p>…</p></div></label>
At depth 4 the tree is clean, so raising it removed false positives rather than
findings. The 55 labels F-D02 counted have genuinely been repaired since; the
rule is what keeps them repaired.
control-has-associated-label is deliberately NOT enabled. It reports 561
violations here and the first one sampled is
<label className="block"><span>OpenAI API key</span><input type="password" /></label>
which is correct. A rule whose findings are mostly wrong is a rule everyone
learns to skip past, and skipping past it is how a real finding hides — the
same reason DATA-011's weaker half was made deliberately forgiving. Turning it
on would have looked like rigour and produced none.
The genuinely unnamed controls it was reaching for are counted instead, by a
check that cannot be satisfied by nesting depth: 71 of 144 <select> elements
have no accessible name, announced as "combo box" while the page changes under
them. F-D03 counted 65; it is 71 now.
Naming them is 71 pieces of product copy in seven languages, which is a writing
task for whoever owns the product voice rather than something to invent in an
audit — the same call recorded for OPEN-001 and SEC-013. What ships is the
measurement and a ratchet pinned at 71: it cannot grow, and every reduction is
deliberate.
next lint clean but for the three pre-existing react-hooks warnings.
Suite 16,787/1,317 green.
Ported from a77f5ea on claude/logged-in-pages-supabase-7q6vtf onto main.
On main the scan read 72, not 71: main had added a fourth page-type select to the admin marketing platform page. All four of that page's selects are named here with fieldName.pageType and fieldName.status (the keys a later commit in this series uses, translated in all seven catalogues), and CEILING is lowered to 68. next lint reports no errors with the rule switched on.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
F-F06 — a test satisfied by the defect it was written to prevent.
tests/seed-failure-safety.test.ts was named "fails closed" and asserted only
that two bad shapes were absent. Deleting a script's error check entirely makes
it match neither, so the test went GREENER: a seed could stop looking at its
write results without a single case going red.
It now asserts the shape it claims — every script checks its error, and every
check it makes ends in a throw, located by position rather than by the absence
of a counter-example. The two original not.toMatch cases stay behind those as a
second net rather than as the argument, and the one tolerated error (seed-medical
ignoring a cleanup against a table that does not exist, because production's
ledger stops short) is asserted to stay exactly that narrow.
Both mutations are red, and the first is the point: deleting an error check now
names the script, where before it removed a finding.
The rewrite's own first run caught a bug in itself, which is the same class: a
single /g regex shared between .test() and matchAll carries lastIndex, so it
reported every other script as having no error check. Two constants now.
F-F05 — no timeout configured, and 96 cases shaped to need one. Everything ran
on vitest's default 5,000ms while 96 cases across 12 files share a cold
`await import('@/app/…')`. Measured twice this cycle: a marketing-route case
timed out at 5,007ms cold and passed in 422ms warm; a full run under CPU
contention timed out five unrelated cases at exactly 5,000ms and passed them all
on a re-run. testTimeout and hookTimeout are 20s — a timeout that fires on load
teaches everyone to re-run the suite, and that is how a real hang gets re-run
too.
F-F12 — a dead config block and a comment that had it backwards. The note said
esbuild was live and oxc was "kept for forward-compat with vitest 3"; this
project is on vitest 4.1.10, which transforms with oxc, and every run printed
"esbuild options will be ignored" to say so. The dead block is gone, the note
corrected, and that warning no longer prints.
F-F11 was already closed as part of PERF-004 and its row is updated; F-F04 was
closed in Pass I and its row said OPEN.
Suite 16,791/1,317 green; the esbuild warning appears 0 times in the run.
Ported from 6a2ce50 on claude/logged-in-pages-supabase-7q6vtf onto main.
Main had already removed the dead esbuild block from vitest.config.ts (F-F12), so main's comment is kept there; the 20s test and hook timeouts (F-F05) are carried over. The seed test keeps this commit's stronger cases (every script checks the error, every check throws) and gains main's extension-filter floor inside its non-vacuity case.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
PERF-005 (F-F09) — a fan-out whose width nobody chose.
const results = await Promise.all(located.map((e) => fetcher({ … })));
Promise.all over a mapped array starts every item immediately. For pure work
that is free; here each item is a request to an external routing provider, so
the width was whatever the calendar happened to be — a family with two hundred
located events opened two hundred simultaneous requests.
mapWithConcurrency bounds it to six lanes. Three decisions inside it each have a
wrong version that looks identical from outside, so each is tested directly:
order is preserved (the test reverses the delays, so a push-as-they-arrive
implementation returns them backwards); lanes not chunks (a 40ms item among
nine 1ms ones tells the two apart, since chunking idles on the slowest); and it
rejects like Promise.all rather than swallowing, because swallowing puts
undefined into an array the caller reads positionally — tolerance belongs inside
`work`, which is what firstDriveTime already does.
The assertion that matters is behavioural: restoring the old line fails with
"expected 40 to be less than or equal to 6", counted inside the fetcher, not
with a grep for the helper's name.
DATA-013 (F-F08) — re-measured, premise no longer holds.
F-F08 recorded addFundsAction as "the one money mutator that writes the balance
directly". It does not: it inserts rows into wallet_transactions, and the schema
has no wallet balance column for anything to write. Every %balance% column in
the database is an external figure, a third-party total, a historical reading, a
named cache, or a reward catalogue field. Goal funding goes through the
wallet_fund_goal RPC, so the ledger row and the goal total move in one
transaction rather than a read-modify-write two parents could interleave.
No product change. What is added is the property, as a check no
absence-of-counter-example can satisfy: nothing in app/ or lib/ may set
saved_cents, spendable_cents, balance_cents or total_cents in an insert, update
or upsert. lib/stripe/treasury.ts is exempt for a stated reason rather than by
name — a cache of an external system's number is not a derived total. Making
addFundsAction write a running total turns two cases red.
Suite 16,806/1,319 green; tsc clean; lint clean.
Ported from 636ed12 on claude/logged-in-pages-supabase-7q6vtf onto main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
F-F13 recorded "sixty-nine test blocks assert only the absence of a pattern".
Measured three ways, because the first two definitions were wrong:
any block whose assertions all look negative 1,165 of 11,341
source-reading blocks whose assertions are .not.* 31
FILES where every assertion is negative 1
The first is useless — toEqual([]) on an offenders list is the correct positive
assertion for a no-offenders guard, and it is what every guard in this cycle
uses. The second is still wrong — "leaves the presence channel alone" is a
genuinely negative claim, and correct, when a sibling case asserts the positive.
The third is the granularity the defect lives at, because it is the only one
where nothing in the file asserts the thing exists.
That one file was tests/database-error-boundaries.test.ts: a single `it` over a
hand-written list of 27 route paths — two of them the same path twice — with
three .not.toMatch assertions and nothing else. Two ways to green it without
fixing anything: delete a route from the list, or stop the route returning JSON
errors at all.
The list is gone. All 146 app/api routes are read, 124 answer with JSON, 102 of
those catch. The positive half it never had: every catching route's answer is
something the route wrote itself — a literal or translated message, a
structured status carrying no message ({ ok: false, failed: 1 }, the most
leak-proof answer a cron endpoint can give), an empty body, or a fixed module
constant. Four shapes, each found by reading the routes that use them rather
than by widening a pattern until the count hit zero. That assertion gets HARDER
as routes are added, where the old one got easier as routes were removed.
Zero leaks across all 124. Making the walk match nothing fails two cases rather
than passing silently, because the non-vacuity counts are asserted.
One note on method. The first leak mutation passed, and the honest reading of
that is "the guard does not work". It was not: the target file's catches are all
`catch {` with no binding, so the injection never landed — grep -c returned 0.
Re-run against a route that does bind, it fails immediately and names the file
and the pattern. Checking that a mutation applied, rather than trusting it did,
is the difference between testing a guard and reassuring yourself about one.
Suite 16,809/1,319 green.
Ported from 99cf6a5 on claude/logged-in-pages-supabase-7q6vtf onto main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
PENDING PRODUCTION MIGRATION 0327 — it must land with this code. F-F07 counted "37 of 51 money-area actions with no test". Re-measured at 38 of 53. Coverage is the symptom; asking what property was going unchecked found four live authorization defects, all the same confusion. submitProofAction loaded the assignment filtered on family_id only, then wrote member_id: assignment.member_id — the assignee, not the sender. Any member could submit proof against any other member's chore: complete a child's chore for them, or file a failing photo and have the parent's queue reject it in their name. disputeSubmissionAction was the same read and worse in what it recorded — the dispute row AND the event log were both attributed to submission.member_id, so a sibling could open a dispute the ledger said the assignee had raised. requestAllowanceAction verified the wallet was in the family and nothing more, so any member could raise a request against any child's wallet. requested_by recorded the truth all along, which is exactly what made it invisible: the audit trail was right while the request the parent saw was wrong. requestRedemptionAction takes a caller-supplied memberId checked only to be in the family. Redemption debits that member's tokens on approval, so any member could spend another child's balance. The last two were found by the guard written for the first two, on its first run. That is the argument for writing the property down rather than fixing the cases you already know about. The rule everywhere is the row's own member OR a manager — not managers-only. Submitting proof of your own chore, asking for your own allowance and spending your own tokens are the child's half of this product, and a parent acting for a child is kept. Same shape 0319 put on member_locations after SEC-006. 0327 adds restrictive INSERT and UPDATE guards on chore_submissions and chore_disputes, closing the direct-PostgREST path too. UPDATE as well as INSERT, because without it a member could insert a correctly attributed row and then re-point it — which the probe checks. 0222's rule is untouched; this narrows whose. The mutation worth reading is the over-correction: making it managers-only reports "CONTROL FAILED: the assignee was refused their own submission — the fix took the kids page away". A guard that only tested the breach would have called that a pass. Also fixed: a type error I committed in the previous change. tests/a-fan-out-has-a-width-somebody-chose.test.ts used source: 'test' where the union has three values. vitest does not typecheck, and tsc was run before that file was written rather than after. Recorded in finalaudit.md rather than quietly corrected. Suite 16,814/1,320 green; probes 52/52, 0 skipped; tsc clean. Ported from 5dfa4bc on claude/logged-in-pages-supabase-7q6vtf onto main. Migration 0327 is 0395 here, reduced to chore_disputes: main's 0375 already rebuilt chore_submissions' policies on the same own-or-manager rule, so the restrictive copy for that table is not carried over. The probe still asserts both tables (the submissions legs now hold 0375) and sets request.jwt.claim.sub for main's auth.uid() stub. Main's submitProofAction and disputeSubmissionAction already had the assignee check; the dispute's event now logs the real actor, and the test reads main's form of the check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
The message header carried two buttons — video call, voice call — whose
entire behaviour was to raise an error toast. They rendered
unconditionally, and were styled identically to the working "About this
chat" button beside them, so nothing distinguished a control that does
something from one that only explains that it does not.
The repo already states the rule about itself, twice, in
lib/constants/navigation.ts: no "coming soon" stubs. These were the
counter-example.
Both removed rather than disabled: a greyed-out control still makes the
promise, and the absence of one makes none. The reasoning is left in a
JSX comment where they were, and the two now-unused lucide imports are
dropped.
tests/no-control-apologises-for-itself.test.ts holds it by shape —
`onClick={() => toastError(` — rather than by judging copy, and asserts
that a handler which does work and then reports a failure does not
match. Mutation: restoring one stub turns it red at
components/modules/messages-module.tsx:717.
Suite: 16,818 tests across 1,321 files, zero failures. tsc --noEmit clean.
Ported from 8ce6438 on claude/logged-in-pages-supabase-7q6vtf onto main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
billing_customers was written in three places with the same three columns,
and only the Stripe webhook named a conflict target.
A PostgREST .upsert() without onConflict gets `on conflict (<primary
key>)`. billing_customers keys on `id uuid default gen_random_uuid()` and
carries a SEPARATE `unique (family_id)`. The payload never supplies an id,
so the clause can never fire, the insert is attempted in full, and it
collides with the other constraint instead:
23505 duplicate key value violates unique constraint
"billing_customers_family_id_key"
Measured against the local PostgREST, not inferred: the first write for a
family succeeds and every write after it fails. Both call sites sit after
stripe.customers.create has returned, so each 503 left a real Stripe
customer the database never recorded, and the retry minted another one.
Two ways in — a row whose customer_ref is NULL can never leave that
branch, and two concurrent requests for one family both create a customer
and the second write fails.
lib/billing/customer-ref.ts is now the only writer, and it names
family_id. All three call sites go through it.
What the fix does not do is stated rather than implied: the first draft of
its doc comment claimed the callers converge on one customer because it
reads the row back. The race probe falsified that — .select() returns the
statement's own RETURNING row, so two racing callers both succeed and each
reports the id it wrote. The comment now says what was measured.
The rule is a scan over all 136 upsert call sites, with primary keys parsed
from the migrations: 491 tables, agreeing with the live pg_constraint
catalogue on every one. The other five bare upserts are correct and the
scan says so.
Two of my own rules were wrong first, both found by mutating the fix:
a helper assertion that a doc comment satisfied on its own (now matched
against comment-stripped source), and a strengthened named-target rule
that flagged correct code in lib/server/profiles.ts, where the payload is
a const three lines up (the scanner now resolves it).
Two neighbouring tests repaired. billing-read-boundary asserted
toContain('customerWriteError') — a variable name — and failed on the
rename while the guard was still there and stricter; it now derives the
binding and requires it tested in something that returns 503 before Stripe
is mutated. billing-price-verification stubbed upsert as awaitable but not
chainable, though the same file's update stub explains that a real builder
is both; modelled that way, no unit test in the suite could observe
whether an upsert's result was read at all.
Eight mutations, all red. 16,835 tests across 1,322 files, zero failures,
under TZ=UTC and again under TZ=America/Los_Angeles, on Node 24.21.0.
tsc --noEmit exits 0; changed files lint clean. No migration: the
constraint has been in 0002_tables.sql from the start.
Ported from a639dbc on claude/logged-in-pages-supabase-7q6vtf onto main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
Seven paged reads sorted by a column that does not decide the order, leaving the rest to the planner and the physical order of the heap. Reproduced on the local Postgres in one transaction, nothing concurrent: ten rows sharing one created_at (now() is transaction-start time, so every row inserted together ties exactly), page 1 = rows 1-5 and page 2 = rows 6-10. One UPDATE to an unrelated text column — no insert, no delete, created_at untouched — and row-03 moved from page 1 to page 2 while row-06 moved the other way. A reader who held page 1 and then asked for page 2 sees row-03 twice and row-06 never. The same run with `, id desc` appended: zero rows moved. The ties are not hypothetical. ai_requests.created_at and guardian_communications.started_at both default to now(). network_aggregates is unique on (scope, cohort_key, metric, value) while the sweep sorted on (cohort_size, cohort_key, metric). Four of the seven are readAll sweeps rather than screens, which is worse: the duplicate and the omission both land inside one array the caller treats as the whole table. lib/marketing/customers.ts already carries a comment about a short read mislabelling customers; its own sort could drop a row for a different reason four lines below. The tiebreaker is the primary key, not merely something unique. Three of the seven were already total by a unique column — and one of those, subscriptions.family_id, is unique only through uq_subscriptions_family, created by migration 0285, which docs/PENDING_PROD_MIGRATIONS.md records as not applied. In production that sweep has no total order today. A tiebreaker whose correctness depends on which migration is live is not one. This also caught an error in my own first scan, which judged totality against the live catalogue — the live catalogue contains pending DDL. Appending the primary key to an already-total sort changes nothing, so the three that were fine stay fine and the rule no longer has to know what is deployed. All seven queries were run against live PostgREST afterwards, including three that now order by an id they do not select. No SQL probe: the demonstration proves a property of Postgres, not an invariant of this schema, and needed a heap reorder an UPDATE happened to cause. The unit guard reads every paged read and requires the primary key in the sort. Four mutations, all red. 16,841 tests across 1,323 files, zero failures, under TZ=UTC and again under TZ=America/Los_Angeles, on Node 24.21.0. tsc --noEmit exits 0; the changed files lint clean. Also records what the earlier pass on the sibling class could not see: 0285 asked "given a named target, is there an index to infer it from?", so a bare upsert was never in its search space — a scan whose search space is defined by the presence of the thing being checked. Ported from a1f8250 on claude/logged-in-pages-supabase-7q6vtf onto main. The guardian history query sits inside main's settleAll, so the id tiebreak goes inside it. The ported ratchet found two paged invest_holdings reads main had added, ordered by asset_id alone (the wallet invest page and the AI invest route); both now end on id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
Applying the previous pass's lesson — a scan whose search space is defined by the presence of the thing being checked — to the guard that decides what family data reaches a language model. tests/context-policy.test.ts asserts that no file under lib/ai/context/slices selects from any of sixty-six deny-listed tables. It reads the slice FILE, and the policy's own comment says slices reach sensitive areas on purpose, through domain services that project the one narrow column a plan needs. That projection is the control, and nothing checked it. Following each slice's imports: six of fourteen reach nine deny-listed tables. No leak. Every one is either projected before anything reaches the model — medical_profiles to allergies, documents to an id for a count, financial_accounts reduced to a COUNT of overdrawn accounts and never a figure, family_messages to conversation_id and read_by with no body — or is not called at all. Two things recorded rather than re-derived. A view over a sensitive table would defeat a .from() scan completely; there are no views in public at all, so that hole is closed by the schema and not by the guard, and one migration could change it. And my own measure overstates reach: an import closure is not a call graph, and three of the nine pairs are a service module imported for one function that happens to export another. Which is why the ratchet is worth having. The reader for a family's emergency contacts is already one call away from a slice that renders lines into a prompt, and nothing fails if someone calls it — the depth-1 rule stays green because the slice file still names no denied table. tests/a-sensitive-table-is-one-call-away.test.ts pins the nine pairs with a reason each, so a new one fails with the slice, table and file, and holds the three projections the safety rests on. Two of my own assertions were wrong first, both caught by running the guard: a baseline note one character under the length floor the same test imposes, and a toContain on a comment that is line-wrapped in the source. Four mutations, all red, including a slice made to call the emergency-contacts reader. 16,847 tests across 1,324 files, zero failures, on Node 24.21.0. tsc --noEmit exits 0. No product change and no migration. Recorded as PASS, not as a fix. Ported from c18965a on claude/logged-in-pages-supabase-7q6vtf onto main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
lib/auth/redirect.ts exists to answer one question — is this a safe
same-origin path? It validated the string it was given and returned a
different string it never validated.
safeInternalRedirect('/..//evil.com', '/home') -> '//evil.com'
new URL('//evil.com', 'https://app.bubaly.com/login').href
-> 'https://evil.com/'
/..//evil.com passes every input test: one leading slash, no backslash, no
encoded separator. new URL() resolves it to the pathname //evil.com,
because .. cannot climb above the root and simply vanishes. The parsed
URL's origin is still the sentinel base, so the origin check passes too.
Returning pathname + search + hash hands the caller a protocol-relative
URL.
The raw value was harmless — a browser resolving /..//evil.com against the
origin keeps the origin. The sanitizer's own normalization manufactured the
dangerous string, which is why the stricter of the two functions here was
the unsafe one.
Reached, not theorised:
/login?redirect=/..//evil.com
-> resolveAuthSelection(params).next === '//evil.com'
-> components/auth/login-form.tsx:116
-> router.push('//evil.com') -> https://evil.com/
after a successful sign-in on the genuine domain, which is the whole value
of the primitive. The other three sinks took their value through the
sanitizer twice and the second pass rejected //evil.com on input — saved by
a round trip, not by a rule.
The fix is one predicate applied to what comes in AND what goes out; the
bug existed because those were judged by different code. isSafeReturnPath
was the second copy of the rule and now shares it, additionally requiring
the normalized form to be same-origin: it accepted /..//evil.com before,
which was genuinely safe because it returns the raw value, and stops being
safe the moment anything downstream normalizes.
27 attack strings, judged by where a browser actually goes: 3 escapes
before, 0 after, 6 legitimate paths unchanged, and the two sanitizers went
from disagreeing on 8 of 18 to agreeing on all 27.
The scan that would have caught it — which navigations take a binding
rather than a literal — found nineteen, each now recorded with its
provenance so a new unaudited one fails. The lead worth chasing was
data.redirect, pushed straight into router.push by three components: an
AI-chosen navigation target would be a prompt-injection sink. Every
producer is a server-built template with encodeURIComponent, so it always
begins /dashboard/. Verified healthy and now asserted.
Adding the shared import grew a module graph an e2e fixture pins, and
tests/medications-fixture-module-graph.test.ts failed with the module and
its importer named — a guard reporting a true consequence, with the fix in
its own message. The module was added to that fixture's source list.
Four mutations, all red; removing the output check fails five assertions
independently. 16,857 tests across 1,325 files, zero failures, under
TZ=UTC and again under TZ=America/Los_Angeles, on Node 24.21.0.
tsc --noEmit exits 0. No migration.
Ported from 9ccc858 on claude/logged-in-pages-supabase-7q6vtf onto main.
Main had already re-checked the normalized output, for // and backslash. The output now goes through the same isSameOriginPath the input does (which also refuses encoded separators), and main's account of the attack is kept as the comment.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
…ealthy)
tests/a-refused-write-is-not-a-success.test.ts proved that a restrictive
policy's `using` clause filters an UPDATE or DELETE rather than raising,
so a refused write arrives as a success, and it enumerates the 44 tables
where that applies. It scans ROOTS = ['components']. That scope is stated
nowhere, and it is not where all the writes are.
Measured: 67 update/delete calls on the guarded tables live outside
components/; 17 go through the cookie-bound client and are filtered by RLS
exactly as silently, most returning { ok: true } without asking for rows.
None is a defect. Every 'use server' action among them refuses a
non-manager before the write, and the lib/ functions were read by hand —
a compare-and-set that reads (data ?? []).length, cancelRun reading
(approvals ?? []).length, ledgerClient() being the service role, an
idempotent card-hold release, an ownership-gated retry. The property holds
by convention and nothing checked it: drop one isManager line and RLS
becomes the only guard, silently.
tests/a-server-action-does-not-need-rls-to-refuse.test.ts makes it a rule
where the rule is crisp — exported actions in 'use server' files under
app/: refuse a non-manager, read the rows back, or use the service role.
It reads the guarded-table list from the sibling guard and pins that
guard's components-only scope. It deliberately stops short of lib/, where
a rule would need four escape hatches, and a guard with four escape
hatches is false confidence.
My own detector reported safe code as unsafe five times, every one from
matching a name instead of a behaviour: canManage missing from the gate
pattern, createServiceClient() bound to `admin`, ledgerClient(), a
destructured binding assumed to be called `data`, and a search window too
short to reach past an error block. The binding is now derived. One
mutation passed twice before I checked it had applied; it had not, and
applied verifiably it goes red.
Five mutations, all red. 16,886 tests across 1,327 files, zero failures,
under TZ=UTC and again under TZ=America/Los_Angeles, on Node 24.21.0.
tsc --noEmit exits 0. No product change and no migration.
Ported from d128a01 on claude/logged-in-pages-supabase-7q6vtf onto main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
…-022) The home page, its "Focus now" strip and the personal, family and AI home dashboards render on the server, in UTC, and chose morning, afternoon or evening from its clock: at 8 pm in Los Angeles the page said "Good morning" and offered the morning shortcuts. dayPhase(now, timezone) reads the family's hour, and all five pass the family timezone. The greetings, blurbs, focus headline and focus labels were English constants in lib/; they take a translator and read 33 catalogue keys, with twelve whole greeting phrases so the name, comma and emoji follow each language. The timezone test is red with the old server-hour logic. Ported from e2e3148 on claude/logged-in-pages-supabase-7q6vtf onto main. The AI home dashboard keeps main's locale-aware date label beside the family-hour greeting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
Batch 3: insurance renewals and lapses, inventory, the language module's tabs and streaks, moving, sleep, screen time, subscription usage and price history, trust grant states, the watchlist, relationship, school and playbook. Counts get one/many pairs; grant effects read through a TRUST_STATE_KEYS table. 71 keys in seven catalogues; the ratchet goes 57 -> 27. tests/subscription-price-history-ui.test.ts's translator mock dropped params; it interpolates through the real catalogue now. Ported from 7603067 on claude/logged-in-pages-supabase-7q6vtf onto main. Insurance, language and moving keep main's locale-bound fmtDate inside the translated sentences. On this tree the sentence walker goes 56 -> 26. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
…ST-014) Batch 4: the assistants page's copy, approval expiry, the assistant's result chips, refill badges, wallet bucket labels, sync, social, referral sharing and vacations. 70 keys in seven catalogues. The sentence-template walker is a zero guard now; the regex ratchet goes 2,646 -> 2,627. Translating expiry exposed a latent bug: the approval card decided it had expired with `expiry === 'Expired'`, comparing its own label, which in any other language never matches and would leave Approve/Decline live on a request the server refuses. It compares the timestamp now. TEST-014: the test meant to catch that could never fail. It counted disabled buttons with /<button[^>]*disabled/, which matches the Tailwind class `disabled:cursor-not-allowed` on every button. Both assertions match the attribute now; a German render is red with the old comparison. I18N-003 closes: all 140 attribute templates and all 117 sentence templates are translated, each shape guarded. Ported from cd35e83 on claude/logged-in-pages-supabase-7q6vtf onto main. Main had already translated the billing service-fee notice, so main's is kept and the key that left unused is removed. The approval card keeps main's locale-bound formatters. Main's formatExpiry returned English, so its check `expiry === 'Expired'` held there; translating the label here would have broken it, so the same commit decides expiry from the timestamp (the German render test is red with the label comparison restored). The German case passes its child as a prop for main's LocaleProvider type. On this tree the sentence walker goes 26 -> 0 and the regex ratchet 2,636 -> 2,617. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
Every signed-in, auth and public-link page set metadata.title to an English literal, so a German family's tabs, history and bookmarks read "Orders · Marketplace". 248 pages now build their title in generateMetadata() from the catalogue, one key per segment of a composed title (51 new pageTitle.* keys; the rest reuse existing entries). The root layout already reads the request, so nothing that was cached stops being cached. 50 titles also ended in "| Bubaly" while the layout template appends " · Bubaly": tabs read "Trip Planner | Bubaly · Bubaly". The brand comes from the template alone now, including three files pages that added it by hand. Left on purpose: staff-only /admin titles, the layout's brand line, and /offline, which must stay static for the service worker. The regex ratchet goes 2,627 -> 2,379. Its non-vacuity check counted files WITH findings (> 400), which falls with every translated page; it counts files read now (1,232). Ported from 3dee9ba on claude/logged-in-pages-supabase-7q6vtf onto main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
The command bar showed the router's English labels (nav names, "Added task: ...", "Ask Bubaly: ...", nine intent calls to action, "n items added", Undo) and matched the query against English nav labels only, so a German family typing "Kalender" found nothing. It now matches translated nav labels and renders every result through the catalogue; the router keeps its English identifiers. 12 keys in seven catalogues, and a guard that every intent the detector can return has a translation. Ported from abb62f6 on claude/logged-in-pages-supabase-7q6vtf onto main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
Main's own guards caught four things the ported commits brought in:
- The Bubaly AI panel test drives the component through a hook-slot shim
that knew useState, useEffect and useCallback. The ported panel keeps its
translator in a ref (so lint passes with --max-warnings 0), and the shim
had no useRef. It now hands out a ref from the same slot list, the way
React keeps hooks in one list.
- The compound-growth calculator's translated labels built "$50" by hand,
which the currency-literal ratchet counts. They go through the view's
locale formatter, as the totals beside them already do.
- The assistants page's translated copy carried the file name
0283_assistant_links.sql inside the catalogue, which the internal-file
guard refuses. Main names the migration on purpose on this pre-migration
page (its render test and the library page both do), so the sentence
keeps naming it, with the file passed as a {file} parameter instead.
- The Guardian SMS recovery E2E set up its unverified row by having a child
write guardian_communications. 0398 refuses that write (screening is the
parents' decision), so the spec now asserts the refusal (42501) and seeds
the unverified row with the service role. What it measures is unchanged:
a row without recovery authority is never recovered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
finalaudit.md's PORT-001 now reads as complete: all 104 audit-branch commits handled (86 ported, 14 audit-only, 4 deferred for owner decisions or #548), migrations 0389-0404, the harness replaying 415 migrations and passing 136 of 136 probes, and three more things the port found on main, among them an approval card whose expiry check compared a label that this port translates. docs/PENDING_PROD_MIGRATIONS.md gains 0404's entry (a removed member's profile visibility), including that #548 installs the same policy. The i18n ceiling is tightened to this tree's count after the command bar (2,369 -> 2,368). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
Main landed #548's and #556's migration blocks as 0389-0391 and 0406-0443, which took the numbers this port had used. Every conflict was resolved hunk by hunk, keeping the fuller fix and combining where each side carried something the other lacked. The port's thirteen surviving migrations move as one block to 0446-0458. Three are dropped because main now carries the same rule: 0393 is main's 0406, 0395 is main's 0391, and 0404 is main's 0426. What the merge itself turned up: - The concierge approval race was open again (DATA-018). Main applied a never-gated plan and then compare-and-set the run, so a dismissal landing in between left real events behind a run recorded as dismissed. The claim is restored inside main's structure. The run is taken before anything is applied and handed back on a throw or an incomplete apply, and both the executed stamp and the release are confirmed by read-back. - actions.couldNotApproveThatRun, dropped from the catalogue union as orphaned, is back in all seven catalogues. - Two paged reads (the savings route, network benchmarks) now end on id. - The two timezone guards, PRIV-001's reach baseline and the lightbox's aria-modal tests now describe the merged tree. - Eight bare-indexOf ordering assertions now use at(). tsc clean. Lint: 0 errors, 15 warnings (main's jsx-a11y rules, pinned as the ceiling). 21,118 tests in 1,668 files pass in America/Los_Angeles and in UTC. The harness replays 441 migrations with none failing, and 173/173 probes pass. No migration was applied to production. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
Main moved 46 commits with no new migrations, so 0446–0458 stay put. 111 files conflicted: - 61 page titles: the port's translated generateMetadata is kept over main's brand-stripped static title. The two that end in the brand go through titleWithoutDoubledBrand. - 32 accessible names both sides added: main's page-specific keys are kept, so none is left unused. - The rest combine main's ownChannel (P-03), role="switch" and suppressHydrationWarning with the port's translations and its stale-refresh guard. - Three en-US keys the automatic merge dropped are restored, with main's wording in all seven catalogues. tsc clean. Lint: 0 errors, 15 warnings. 21,277 tests in 1,684 files pass in UTC and in America/Los_Angeles. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
The chores points picker was named on both sides; main's chores.pointsPeriod key is kept, as for the other accessible names in the previous merge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
|
Independent app-only review of exact head cfa20b7 found a concrete claim-ownership gap in I held the actual action at its materializer boundary, then called the actual
A fourth controlled case changes the original These are executed actual-module tests with a filter-faithful synthetic database and a held materializer; they do not establish live provider/RLS behavior. The original seven concierge controls and all 26 tool-executor tests passed; the four desired ownership cases failed (33 PASS / 4 RED). A TEMP-only two-statement repair makes the original 37 cases pass, and the related materialization/finance handoff checks bring the focused result to 59/59 PASS:
This preserves a later pause without claiming to cancel an already-dispatched domain write. Evidence is retained locally at The |
Two conflicts. /missions/new: both sides added a screen-reader h1; main's key is kept with the port's comment. finalaudit.md: the port's timestamp plus main's page-audit pointer. Two files picked up a duplicate getTranslations import from the automatic merge (dashboard/assistant, dashboard/grocery); one of each is dropped. The i18n ratchet moves 2,327 -> 2,339 for exactly one reason. Main's P-10 review makes thirteen withAiRequest call sites record a fixed English label rather than what the person typed, since ai_requests is readable by the whole family, and its guard holds them to literals. They are record labels, shown only on the English-only super-admin AI Activity page. tsc clean, lint 0 errors / 15 warnings. Full suite in UTC: 21,306 tests in 1,690 files, with the one ratchet failure now fixed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
|
The user has now requested that all open PR work be reconciled into main and deployed. The current main audit checkpoint is4123f13 (#608); production session/meal/page repairs from#601 are verified on82da. #602 carries the Next16 renderer fix and a default-false manual production-SQL apply guard. #607 carries repaired Inbox ownership, shared date hydration, localized controls, and the corrected marketplace test fixture (20,871 unit tests now pass). I am reconciling these branches for release. Please preserve the newer main/607 session, Inbox, Modal, formatter, and cache guards when resolving overlaps. Pending SQL must remain unapplied; no production migration dispatch is authorized by this release coordination. Additional unique changes will be reviewed and integrated, with superseded alternatives recorded explicitly. Please keep finalaudit.md claims and prior failures intact. |
19 files conflicted, almost all where both sides had translated the same thing: - Auth and public page titles: main's C1-S9-100 translated them too. Main's version is kept, and the duplicate imports both sides added are dropped. - /reviews/new star labels and the chores approval toast: same text, and main's keys are kept. - The auth-callback test keeps main's note, with the port's mock that passes parameters through. - Catalogues: the union. Six keys that both sides added twice are kept once, with main's wording where the two differed. - i18n ratchet: both histories kept. The merged tree measures 2,208, banked as the ceiling. tsc clean. Lint: 0 errors, 15 warnings. Full suite in UTC: 21,381 tests in 1,698 files, with the 12 duplicate-key failures now fixed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
finalaudit.md: main's Session B header lines are kept as they stand on main, so this branch stops editing a line every checkpoint rewrites. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
Main's #609 added 0444, found independently by the B6b form pass as its P-13. It installs the same marketplace_create_circle, with search_path reaching `extensions` where Supabase keeps pgcrypto, as the port's 0446. The port's file is removed rather than renumbered, as 0393, 0395 and 0404 were. The probes, the definer-search-path guard and 0447's comment now cite 0444. The pending-migrations entry keeps the analysis under main's number and lists twelve port migrations, 0447–0458. The next free version is still 0459. Harness: 441 migrations replay from nothing, none failing; 174/174 probes pass. tsc clean, lint 0 errors / 15 warnings, 21,388 tests in 1,699 files pass in UTC. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
24 files conflicted, nearly all where both sides translated the same module: - Assistant, concierge, finances, insurance, medical records, reminders, shopping, subscriptions and voice: main's burn-down (C1-S9-114…126) is kept, since it is the fuller version, with real plurals, category and frequency labels, and a greeting with no English fallback. Helpers of the port's that main's version replaces (the voice route labels) are removed. - The port's accessible names are carried onto main's lines: the food and planning "Open" links (with main's shrink-0 for P-12) and the trust module's emergency-kind select. - Tests take main's expectations; the reminder mock gains main's usePlural and keeps the port's parameters. - Catalogues: the union at 15,985 keys each, with main's wording where both sides carried a key. - i18n ratchet: both histories kept. The merged tree measures 1,922, banked as the ceiling. tsc clean. Lint: 0 errors, 15 warnings. 21,399 tests in 1,702 files pass in UTC. next build succeeds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
…-connect-45d8k6 Main's #586 port translated much of the same code. Main's side is kept on every overlapping hunk: - family-dashboard: main's phaseGreeting (family timezone) and One/Many suggestion keys; this branch's getPlurals/suggest.* keys are dropped. - declutter: main's SCORE_LABEL_KEYS and worst-spot keys; this branch's scoreLabel() is removed. The home summary sentence is still English on main (lib text), so this branch's catalogue wording stays. - planning: main's prepPlan keys; the English 'Could not generate plans' fallback keeps this branch's key. - food: main's tab-title key. Catalogues: main's order and values plus this branch's keys still used; orphans from the overlaps removed. Against main: additions only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01776xJyhVe8xJQvTGeNfT9T
…002) executeQueuedRunAction claims a queued run (status approved, state executing, approved_at = the claim), applies the plan, then stamps it executed or hands it back for a retry. The stamp matched status alone and the hand-back did not match state, so a parent's pause during the apply (pauseRun keeps status 'approved' and moves only state) was replaced by 'completed' or 'awaiting_approval', and an older action finalized a newer claim. Both writes now match the whole claim. Found by the owner's review of #586. The new test holds the materializer and applies every .eq to the row; it fails 4 of 7 on the previous code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
|
The claim-ownership gap from the review above is fixed in #620, recorded as CONC-002. It is the two-predicate repair the review proposed:
The four cases (pause during success, partial failure and throw, plus the re-claimed run) are in The Generated by Claude Code |
The port is complete, and
main(through #608) is merged in. All 104 of the audit branch's commits are handled:What this is
claude/logged-in-pages-supabase-7q6vtfhad drifted too far frommainto merge.mainhad already given to other files, so the production workflow would have skipped them as already applied.As the owner decided, this branch starts from
mainand re-applies each audit-branch commit on its own:mainalready fixes the same finding,main's version is kept and only the remainder is carried. Every commit message says which half came from where.main's highest.The full record is the PORT-001 section of
finalaudit.md.Migrations
0446–0458, all unappliedEvery one has an entry in
docs/PENDING_PROD_MIGRATIONS.mdunder "Ported from the audit branch", including a deploy-order table. None gates a deploy: the app on this branch is correct before and after each one. Applying them stays a human, credentialed step behind the F-001 ledger repair.044604470448044904500369scoped the policy)0451chore_disputes(main's0375covers submissions)04520453guardian_communications(main's0318/0345cover the rest)04540455045604570458Not carried, because
mainalready has the same rule:0406039104260350and0378The probes for all four are kept and pass against main.
Deferred as owner decisions, not dropped:
mainrecords both as the owner's call, and each entry points at the ready implementation.Left to #548 and now on main:
0440keys receipts per device, finer than the branch's design.0441is the same design.What the port and the merges found on main itself
0452's code read two columns that only exist after the migration.lib/marketplace/reserve-view.tsnow falls back on 42703 only.app/api/ai/invest/route.tstold the model a portfolio value computed from a capped or failed holdings read. It now refuses instead.invest_holdingspagers'Expired', so a German family's card would have kept Approve live on a request the server refuses.titleWithoutDoubledBrand. Where main (C1-S9-100) translated the same auth and public titles, main's version is kept.auth.*stubs and default function grants now match Supabase.Verification (at
689b47796)tscis clean.next lintreports 0 errors. Its 15 warnings all come from main's two jsx-a11y warn rules, and--max-warnings=15pins that as a ceiling (the same setting main has).plpgsql-bodies-resolveincluded. Main has added no migrations or probes since that run.PRODUCTION READY stays NO; nothing here changes F-001.
🤖 Generated with Claude Code
https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS