Skip to content

Production schema fallbacks: messaging, AI usage, provider sync, calendar feeds and bills work before their migrations - #982

Merged
NewWorldVenture merged 28 commits into
mainfrom
claude/roadmap-implementation-ld8bon
Oct 10, 2026
Merged

NewWorldVenture merged 28 commits into
mainfrom
claude/roadmap-implementation-ld8bon

Conversation

@NewWorldVenture

Copy link
Copy Markdown
Owner

Summary

Main (6d9d9207f and later, with #969) is live in production. Its code depends on database objects that production does not have:

  • 0475 and 0476, which were never applied;
  • 0488, 0490, 0493 and 0494, which are held in supabase/reserved/.

A read-only, adversarially verified review found that on production's schema:

  • every family sees an empty Messages inbox;
  • Google, Outlook and iCloud sync stops entirely;
  • calendar subscriptions neither refresh nor can be added;
  • Free-plan families lose the assistant;
  • bills due on the 28th–31st cannot be added or paid.

Per the owner's decision, each path now falls back to previous production behaviour (82f2db1fc) while its migration is absent. Once the object exists, the new path is used unchanged.

Absence is detected only from the exact missing-object answer that names the object (PGRST202/42883, PGRST204/42703, PGRST205/42P01). Every other error, such as permission, network or constraint, still surfaces as before. Each fallback warns once per process and names its migration.

Area Missing Fallback
Messaging (web and the assistant tool) 0475/0476 RPCs and is_family_chat Inbox lists every conversation, as before. Family chat ensure, create, send, duplicate probe, overview, mark-read (mark_conversation_read, 0163), reactions and presence use the previous paths.
AI usage held 0493 count_family_ai_requests_month Direct ai_requests count, as before, so Free keeps 10 requests a month.
Provider sync held 0494 ensure_sync_pull_container / create_sync_pull_item Direct container and item writes, as before.
Calendar feeds held 0490 calendar_feed_apply_sync Fenced direct writes, as before. Adding a feed no longer rolls back, and no internal path is shown to users.
Bills held 0488 bills.due_day 28th–31st bills can be added and paid (a clamp asks first, as before), and the forecast loads.

Some tests asserted the reversed "refuse until the migration lands" decision. Those assertions now expect the fallback, with a comment; non-missing errors are still asserted to fail. Nothing under supabase/migrations or supabase/reserved changed, and no migration was applied.

Not changed (owner decision pending): #969's stricter ICS parsing and recurrence admission still reject many feeds that previous production imported, such as VTIMEZONE feeds, Windows zone names and school or sports series.

Verification

  • tsc --noEmit: clean.
  • eslint on changed files: clean.
  • audit-migration-versions: next 0477.
  • Full vitest (UTC): 30,529 passed. The only failures are the two Node-22-only environment tests here (node-version-is-pinned, stream-cancellation-runtime); CI uses the pinned Node 24.21. The ordering-guards failure found in that run is fixed in 4300411.
  • Each area's tests pass in UTC, Los Angeles and Tokyo. Each area was implemented in isolation and adversarially reviewed, and the review findings were fixed.
  • Messaging browser specs family-messages-state and messaging-preserve-access: 79 passed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K


Generated by Claude Code

claude added 18 commits October 5, 2026 01:15
…ging are checked, with the feed claim held as reserved 0490

Messaging (#834, #853, #854, composed onto main):
- The canonical family chat is created new and adopts no legacy group
  (#854's decision); #834's tip (a stopped chat turn writes nothing
  more) is included; #853's later re-adoption merges are not.
- 0475_messaging_conversation_privacy_and_delivery and
  0476_messaging_notifications_preferences keep #834's reserved numbers
  and names under the owner's preserved map; next free is 0477.
- 0293 stays main's byte for byte; 0476 checks notifications.related_id
  itself and its notice policies read the key through a lookup, so 0293
  still re-applies after it.
- On production's schema without 0475/0476 the messages page and service
  fall back to main's behaviour, recognising only the exact missing
  object (lib/messages/schema-compat.ts, legacy-schema.ts).
- Messaging probes clear the JWT claims they set.

Calendar (#923, #908, plus the date and DST fixes):
- Planner, reminders, free slots, readiness and the assistants read every
  week of a series through readCalendarOccurrences, paged against an
  exact count; a page that repeats a row is refused.
- Feeds keep the TZID zone, attach RECURRENCE-ID exceptions to their
  series and drop cancellations; one sync runs per feed under a claim.
  calendar_feed_apply_sync is reserved as 0490 and held in
  supabase/reserved/; without it the sync takes its check-then-write path.
- DST nights follow RFC 5545 3.3.5 in the ICS readers and the expander.
- An all-day event is drawn on its own date on every clock; reminder keys
  carry the family's day.

No migration is applied by this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
Making family-media private is 0459's decision, which the owner has
deferred in production (SEC-001, kept public on 2026-09-30). 0475 also set
public = false, so applying messaging would have applied that deferred
decision. It now only adds the voice-note audio types, and only where the
bucket already has an allow-list (0418 sets one); a NULL list allows every
type and appending to it would narrow uploads to audio.

A new guard allows only 0459 to write the bucket's public flag; it fails
on the previous 0475. The messaging database verifier passes on a
disposable local cluster, including 0459's family-media probe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
An all-day row is stored at its date's Greenwich midnight
(lib/calendar/day.ts allDayDate). Two labels still read that instant on
the family's clock, so west of Greenwich it fell on the previous day:

- the reminder body (lib/server/notifications.ts timeLabel). In New York at
  08:00 on 21 September, the all-day 22nd read "today" and the 23rd
  "tomorrow";
- the "Get ready" moment label (lib/moments/prep.ts momentWhen). In Los
  Angeles, 5 July read "Today", while the moment's own dedupe key already
  said the 5th.

Both now use the stored date. Timed labels are unchanged, and a regression
asserts one beside the all-day cases. The birthday-label test that pinned
the instant reading now expects the stored date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…wered

Production serves main 82f2db1 (#970), and the smoke routes return 200.
The checkpoint states what #970 and #971 carry, the held 0488/0490, and
the owner-only items still open: the rejected service-role key and
migrations from 0177, allowance_rules, the Family Chat choice, and
re-running 0475/0476 on development databases. No audit rows are closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
Storage checks a bucket's allowed_mime_types only when the list has
entries, so an empty list, like NULL, allows every type. 0475 appended
the voice-note audio types to any non-NULL list, which would have
narrowed an empty one to audio only. It now updates only a list with
entries (cardinality > 0).

verify-messaging-database.mjs now runs the real 0475 against four bucket
states, each in a transaction it rolls back:
- public with a NULL list, and public with an empty list: both left as
  found;
- private with a list: stays private, and gains the six audio types once;
- no bucket at all: none is created.
The check fails against the previous condition (empty list) and against
the original 0475 (public flag).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
The calendar module and the recurrence expander now import
lib/calendar/day.ts (familyFetchRange, allDayDate, compareOccurrences). The
browser fixture in calendar-navigation.spec.ts loads only the modules it
lists and stubs every other import with functions that return null, so
familyFetchRange returned null and the module threw "Cannot read
properties of null (reading 'from')" in all ten cases. The fixture now
loads day.ts too. All ten pass locally in Chromium, and the unfixed spec
fails locally with CI's error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…osed, carried by #969)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
Production has not applied 0475_messaging_conversation_privacy_and_delivery.sql
or 0476. Until it does, each messaging path now does what the previous
production build (82f2db1) did, and takes the new path unchanged once the
object exists:

- The inbox lists every conversation the read returns instead of blanking
  and toasting when rows carry no is_family_chat; the first group that is
  not a DM opens as the family chat.
- Family Chat: without ensure_family_conversation, the existing group chat
  is reused (or one is created with every member), with no error toast.
- New conversations insert directly without create_family_conversation,
  retrying without participant_ids on a pre-0017 schema.
- Previews and unread badges come from the bounded 400-message scan
  without family_conversation_overview.
- Read receipts use 0163's mark_conversation_read (then a per-row append)
  without mark_conversation_read_through.
- Reactions update the message without toggle_family_message_reaction.
- Presence joins the old public topic; typing stays off, as before.
- The assistant/approval send (sendFamilyMessage) opens the oldest
  un-archived group, probes duplicates by the same words within ten
  minutes and inserts directly when ensure_family_conversation,
  find_family_message or send_family_message are missing, and accepts any
  family conversation when is_family_chat is missing.

A fallback runs only on PostgREST's exact missing-object answer naming that
object (code plus name in message, details or hint); permission, network and
constraint errors still surface. Each missing object warns once, naming the
migration file. Tests that pinned the old refusal now pin this decision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
Follow-up to the 0475 fallback, closer to the previous production build
(82f2db1) on a database without 0475:

- Starting a 1:1 chat with someone you already have one with opens that
  chat again instead of inserting a duplicate (matched on participant_ids,
  or member_ids for older rows); a failed lookup is shown as an error and
  inserts nothing.
- An assistant/approval send retried without find_family_message returns
  the row the same words already wrote, as before, even when its kind or
  reply differs; with find_family_message the drift refusal is unchanged.
- The archive action is hidden before 0475, as the previous build had none;
  archiving the unmarked family group would have split the family chat.
- Typing and presence wait for the first Family Chat ensure, so the old
  schema never attempts the private topics only 0475 authorizes.

The acting-member gate in sendFamilyMessage stays on every schema: it is a
deliberate hardening independent of 0475, so a scope without an active
member is refused on the old schema too (82f2db1 sent as Bubaly).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…0493

Production does not yet have count_family_ai_requests_month (held
supabase/reserved/0493). Every Free-plan AI request on web and mobile
(both go through assertAIAccess) was refused with 'Bubaly could not
check this month's usage'.

When the RPC answers with the exact missing-function error naming it
(PGRST202 or 42883), assertAIAccess now counts the month the way the
previous release did: a direct exact, head-only ai_requests count from
the start of the UTC month under the caller's RLS. It warns once per
process naming the migration file. Any other RPC error (permission,
network, a different missing function) still fails closed, and once
0493 is applied the RPC path is used unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
The missing-RPC warning for count_family_ai_requests_month now tells
operators to reload PostgREST's schema cache (NOTIFY pgrst, 'reload
schema') if 0493 has been applied. A stale cache still answers PGRST202
after 0493, and the direct ai_requests count would then miss private
rows under 0493's RLS. A new test also checks that a rejected direct
count query fails closed with 'could not check this month's usage'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
Production does not have supabase/reserved/0494_sync_atomic_pull.sql yet, so
ensure_sync_pull_container / create_sync_pull_item do not exist and every
provider pull ('Sync now', export and the provider cron) failed at container
admission. When PostgREST answers that exactly one of those RPCs is missing
(PGRST202/42883 naming it in message, details or hint), the pull now takes the
previous production path from 82f2db1: the sync_calendars / sync_reminder_lists
lookup-or-insert, the separate sync_calendar_events / sync_reminders insert
followed by its sync_external_mappings insert, and the calendar cursor written
as soon as the calendar pull succeeds, keyed by the mirror id. A warning names
the migration once per process per RPC.

Any other RPC failure (permission, lock, a missing helper inside the RPC, an
unnamed PGRST202, network) still fails the run with the cursor kept, and the
path with 0494 applied is unchanged.

Tests: the two atomic-pull tests that asserted the old refusal without 0494
now assert the owner's fallback decision; new cases cover non-missing
failures, legacy cursor order and the once-per-process warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…sking it

Without supabase/reserved/0494_sync_atomic_pull.sql, provider pulls ('Sync
now', export, the provider cron) take the previous direct writes. Two
refinements:

- The RPC counts as absent only when the PostgREST/Postgres message itself
  says that public.<rpc>( cannot be found / does not exist. A name in details
  or hint alone (a hint can suggest a different, existing function) no longer
  counts. PostgREST gives the same answer for a signature mismatch, so a
  mismatched 0494 is still treated as absent; this is documented in the code.
- After that answer the RPC is not called again for 60 seconds, so the old
  schema no longer pays one failed round trip per new pulled item. After the
  window the RPC is tried again, so an applied 0494 takes over. Other failures
  are never remembered and keep failing the run.

The path with 0494 applied is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…n 0490

Production has not applied held migration 0490, so calendar_feed_apply_sync
does not exist there. Every feed sync answered "Calendar sync is unavailable
until ... (supabase/reserved/0490_...)" - a repository path shown to the
family - subscriptions stopped refreshing, and "Add & Sync Now" rolled the new
subscription back.

Now, when the database answers that exactly calendar_feed_apply_sync is
missing (PGRST202 or 42883 naming it in message, details or hint), the sync
writes events the way production did before 0490: one upsert per chunk on
(feed_id, external_uid), no removals, so events the source cancelled stay as
they were (a cancelled event is still never written as a live one). The claim
taken on calendar_feeds.last_status/updated_at is kept and checked by reading
the feed row before each chunk, so a sync that has visibly lost its claim
writes and stamps nothing. A warning naming the migration file is logged once
per process; absence is probed again on every sync, so the atomic function is
used unchanged as soon as 0490 is applied. Any other error (permission,
network, constraint, a missing helper inside the function, another function's
name) still fails the sync and an add still rolls back. The archive path's
missing-function message no longer carries a repository path either.

Tests: the fail-closed block and three source assertions that pinned "no
direct upsert" are changed to the owner's decision; new cases cover both
missing-function answers, cancellations, a lost claim, non-missing errors, a
refused fallback upsert, and add-feed keeping its subscription.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…e them

Follow-up to the missing-0490 fallback. When the database does not have
calendar_feed_apply_sync, a feed sync now writes a cancelled event that carries
a start as an ordinary event, the way production did before 0490 (its
buildFeedRows never read STATUS). The event is updated in place or inserted on
(feed_id, external_uid) and counted in the feed's event count. Nothing is
removed, as before. Cancellations with no start are not written, as before. A
cancelled event is written only if the feed would admit it as a live event, so
a cancelled timed series is not written as a live weekly series and the
recurrence admission rules are unchanged. When 0490 exists the sync is
unchanged: it calls the RPC with the same arguments and makes the same writes
and removals.

The check for a missing function now reads only the message and details.
PostgREST's hint is a "Perhaps you meant ..." suggestion, so a 0490 applied
with a different signature now fails instead of quietly using the fallback.

Tests: the cancelled-event cases are changed to the 82f2db behaviour. New
cases cover an all-day cancelled master that is written, a timed cancelled
master that is not, a bare cancellation, a snapshot holding only cancellations
the feed never stored (no RPC call and no write, the same as with 0490), a
refused upsert of a cancelled event, and a PGRST202 that names the function
only in its hint, which must still fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
Production has not applied the held 0488 (bills.due_day). Until it has,
and only on the exact missing-column answer naming bills.due_day
(PGRST204 / 42703), bills fall back to the previous production
behaviour (82f2db1):

- Mark paid on a monthly bill due on the 28th-30th, or a recurring bill
  without a cadence, rolls it by its due date's own day (monthly when no
  cadence is named) instead of opening the schedule dialog. A row read
  without the column is checked with a side-effect-free probe first.
- A roll that must clamp to a shorter month (31st -> Feb 28, 30th ->
  Feb 28) asks the person whether to move the bill there; yes writes it,
  no leaves it as it was with the "needs a database update" notice.
- Recurring bills due on the 28th-30th can be created and their schedule
  edited; the write is repeated without due_day when the date carries it.
- The 12-week forecast and Family CFO dashboard load for families with
  such bills or a legacy cadence-less recurring bill.

Every retry goes through the same compare-and-swap write. Any other
error (permission, network, another column or table) is surfaced as
before. One warning per process names
supabase/reserved/0488_a_month_end_bill_keeps_its_day.sql. With the
column present the anchored behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
… month lacks

Follow-ups for recurring bills while bills.due_day (held 0488) is missing:

- Mark paid on an older schema no longer respells the stored cadence
  ("Monthly", "annually", "fortnightly" stay as stored); it writes only
  status and due date, as the previous production did. With the column
  present the normalized cadence is still written.
- Adding a recurring bill whose chosen day the first month lacks (day 31
  starting Apr 30) now asks whether to add it due on that date, and adds
  it without due_day on yes, instead of refusing with "schedule
  unavailable". No keeps the form open. Any other refusal is surfaced.
- Once the side-effect-free probe has answered that bills.due_day is
  missing, Mark paid writes without the column at once (one guarded
  PATCH instead of a refused one first), still asking before a clamp.

The compare-and-swap guard is unchanged on every write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
@vercel

vercel Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
bubaly Ignored Ignored Oct 10, 2026 12:58am UTC

Request Review

Copy link
Copy Markdown
Owner Author

Audit blockers at 4300411. The claimed owner reversal is not linked to verified decision evidence; existing audience, admission and held-migration decisions remain unresolved. Passing fallback expectations cannot by themselves close those holds.

Current PR owner, bounded next source/test tasks (keep existing ownership, no duplicate workers):

  1. FALLBACK-PRESENCE-BOUNDARY: a missing reaction/read RPC sets global legacy0475, which switches presence to a public channel (messages-module.tsx:510–537,878–879). One missing/stale RPC is not evidence that a privacy downgrade is safe. Preserve private presence or fail closed; test partially migrated schema and ensure no public identity/timestamp tracking.
  2. FALLBACK-PROVIDER-OWNERSHIP: persistence.ts:133–139 reuses reminder lists by family/provider/external_id without account/user ownership. Google '@default' is shared across accounts, so same-household accounts can reuse each other's list and subsequent two-way sync can export the wrong account's tasks. Retain account/owner isolation and test two distinct provider accounts with the same external ID. Separate item/mapping writes also need orphan-safe failure/retry behavior.
  3. FALLBACK-READ-RECEIPT: legacy read writes can fail/return zero rows while the wrapper returns success and marks messages locally read. Propagate failure and preserve retryable unread state; test denied and zero-row writes.
  4. Preserve cancellation, stale-owner and uncertainty guards in feed/AI/bill fallbacks. Review specifically cancelled feed occurrences becoming live, unavailable AI counts becoming zero, and unknown bill cadence becoming monthly. Do not credit inferred intent as verified financial scheduling. The retired bill-view write guard is already tracked by comment6062245198; extend that evidence instead of opening a duplicate.

Report exact repair commits and targeted negative controls before broad acceptance claims. Keep one finalaudit writer and all history. These are candidate source findings, not verified production incidents. No merge, deploy, production schema/configuration/credential changes or policy reversal requested.

Copy link
Copy Markdown
Owner Author

New exact-head CI blocker: 4300411 / run38003497146 / E2E job114066850170 failed smoke step20. Core, DB and Mobile passed. The failing test/cause is not established by step metadata.

Current PR owner: after your active slice, diagnose this exact smoke failure through your authorized CI access, reproduce the failing case, and report the test name, source repair commit and exact-head rerun. Preserve negative safety assertions and the open source findings in6090921876; do not resolve this by weakening guards or assuming the pending fallback-policy decision. Keep source-only audit scope and the single ledger writer. No merge, deployment or production changes requested.

…absent

A single missing or stale 0475 RPC (reaction toggle, read-through, family
chat ensure) set the module-wide legacy0475 flag, which switched presence to
the old public topic and published identity and timestamps there. Presence now
follows only the inbox read lacking is_family_chat; with the column present it
stays on the private topic (which fails closed if its policies are missing),
and nothing is joined until an inbox read has answered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
Before 0475 the read-through wrapper ran 0163's mark_conversation_read or
the per-row append and returned success whatever happened, so the module
marked every row read locally even when the append was refused (0463 rejects
one built from a stale read_by), wrote no row, or the security-invoker RPC
touched nothing. 82f2db1 never updated local state from this path.

legacyMarkConversationRead now reports an error and exactly which rows
landed: per-row appends stop at the first refusal or zero-row write, and an
RPC success is confirmed by reading those rows back. The module shows only
landed receipts, toasts the failure, and leaves the rest unread to retry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
On a database without count_family_ai_requests_month (held 0493), the
allowance falls back to a direct head count of ai_requests. That fallback
read `legacy.count ?? 0`, so a response with no error and no count
(supabase-js leaves count null when Content-Range does not come back)
was granted as an empty month: Free families got their allowance back.
The RPC path already refuses a null receipt; the fallback now does too.

Replaces the test that pinned null-as-zero with negative controls: a null
count under both missing-function codes, invalid counts (NaN, -1, 0.5),
and an error that comes with a count, plus a positive control for a real
zero.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…nthly

Without bills.due_day, the pre-0488 fallback read a recurring bill with no
or an unrecognised cadence as monthly: Mark paid probed the column and
rolled it a month, and the forecast loader stepped it monthly. That invents
a payment schedule nobody chose. Now, as with the column:

- cadenceBefore0488 returns only a named cadence; billBefore0488 leaves an
  unnamed one as read, so buildCashflowTimeline raises
  BillScheduleConfirmationRequired.
- billPaidPatchBefore0488 returns null for a flagged bill without a named
  cadence (no write), and saveBillPaymentBefore0488 returns null before any
  probe or await, so bills-view / billing-module open the schedule chooser
  and no new late-dispatch path exists.
- The Family CFO page catches BillScheduleConfirmationRequired and shows the
  same confirm-schedules notice the Money Timeline page already shows.

Tests: negative controls in the pre-0488 payment, markPaid/markBillPaid
callback, timeline-load and Family CFO suites fail before this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
Without held 0494 the pull looked up its calendar/task-list mirror by
household, provider and remote id only, as the previous production build
did. Google's '@default' task list has the same id for every account, so a
second Google account in a household adopted the first account's list: its
pulled tasks landed there and two-way sync exported the first account's
local reminders to the second account's Google Tasks. The fallback now
matches by account, as 0494's ensure_sync_pull_container does, and fails
closed on a mirror naming another household or owner, or on ambiguity.

The fallback's separate item and mapping inserts are also retry safe: a
failed mapping write removes the item it just inserted, and a retry adopts
an unmapped item for the same remote id in the account's mirror instead of
inserting a duplicate. An item another mapping claims is never adopted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
supabase-js can report an error for a mapping INSERT that committed. The
legacy pull then deleted the just-inserted item, leaving a mapping whose
local_id names a missing row, and every later run failed its local lookup.
The item is now kept: a committed mapping is found by the next run, and an
uncommitted one leaves an unmapped item that the retry adopts. Adoption also
clears deleted_at, as the RPC inserts a live row, and refuses an item that
names another owner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
Owner decision 2026-10-09 (PR #982): while held 0488 (bills.due_day) is
absent, recurring bills fall back to previous production behaviour.

recurring-bill-payment.spec.ts: for missing-column / missing-cache the
payment modal sends the anchored PATCH once, gets the missing-column
answer, warns once, and - with no confirmClampedDay for day 31 -> Apr 30 -
refuses before a second write: one PATCH, "not available yet" alert, row
unchanged. finish() now allows exactly that one 0488 warning for those two
outcomes only; every other outcome must stay diagnostic-free.

recurring-bill-authenticated.spec.ts (phase 'old'): Mark paid on a 31st
bill gets PGRST204 for the anchored patch and asks "Mark paid and move
this bill to Feb N?". Declining writes nothing and shows the "left as it
was" notice; accepting sends the same CAS with only status and due_date
(no due_day, stored cadence kept) and moves the row. Editing a 28th bill
to day 31 runs server-side with no one to ask and is still refused with
"not available yet"; the row and dialog stay. The 'modern' phase is
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K

Copy link
Copy Markdown
Owner Author

Replying to 6090921876 and 6091337155. Head is now 1d1b90c33.

Decision evidence. The fallback policy is the repository owner's decision, made in the session that authored this PR on 2026-10-09. After the owner saw the evidence that #969 left production's Messages inbox empty, stopped provider sync and calendar feeds, blocked Free-plan AI and blocked 28th–30th bills on production's schema, they chose: "Roll back, then fix: Vercel Instant Rollback to 82f2db1, then a PR that makes each area fall back to the old behaviour until its migration is applied." This PR is that fix.

Findings in 6090921876

Each finding was verified against the source, and every fix has a negative control that fails without it.

  1. Presence boundary: real; fixed in 7ad8c337b.
    • Presence now follows only the inbox read's evidence: rows without is_family_chat mean 0475 is wholly absent, and only then is the old public channel used.
    • A single missing or stale RPC no longer selects it. A partly migrated schema keeps the private topic, and an unknown state joins nothing.
    • Tests cover partly migrated, wholly absent, present and unknown schemas, plus a source pin. The source pin fails against the base.
  2. Provider ownership: real; fixed in d943047a9 and 07e025a4c. 82f2db1fc already had this bug, and the fallback had copied it.
    • Containers are looked up by account, provider and external id, as 0494 does. A family or user mismatch, or more than one match, fails closed.
    • Items are orphan-safe. A leftover unmapped item is adopted: deleted_at is cleared, and user_id must match the account owner.
    • The review blocked the first version: deleting the item after a mapping write that reported an error but had committed could wedge the pull. The delete is gone, and a committed-but-errored mapping test covers it.
    • 26 account-scope tests; 16 of them fail against the base.
    • Not repaired: mappings that the old household-scoped build already wrote. That needs a separate data check.
  3. Read receipt: real; fixed in f45accfd8. A legacy receipt that is refused or touches zero rows now returns a failure, and the messages stay unread and retryable. Tests cover a denied write and a zero-row write.
  4. Uncertainty guards:
    • Feed cancellation and stale owner: not real. The missing-0490 fallback never writes a cancelled occurrence as live where 82f2db1fc did not. It keeps the fence check before each write.
    • AI count: partly real; fixed in f9fb19615. A missing or null fallback count now refuses, as the RPC path does, instead of counting as zero.
    • Bill cadence: real; fixed in 1bf5647be. An unknown cadence asks for its schedule and is never treated as monthly. No new late-dispatch path was added; the retired bill-view guard stays tracked under 6062245198.

CI blocker in 6091337155

E2E job 114066850170 failed these tests, which still asserted the reversed pre-0488 refusal:

  • recurring-bill-payment.spec.ts "missing-column/missing-cache …": only finish()'s strict diagnostics check rejected the fallback's one-time due_day warning;
  • recurring-bill-authenticated.spec.ts, phase "old".

Fixed in 1d1b90c33:

  • Payment spec: it allows exactly that one warning, only for those two outcomes, and still asserts a single CAS PATCH, unchanged rows, and the alert. It passes locally, 9 of 9.
  • Authenticated spec: the old-schema phase now asserts the clamp confirmation, the exact two PATCHes (the anchored one refused with PGRST204, then one without due_day), the CAS filters, the resulting row, and that "Leave it unchanged" leaves the row unchanged.
  • Unchanged: the modern phase, and all negative safety assertions.

Local validation on this head

  • tsc and eslint are clean.
  • Sync: 79 files, 1,977 tests.
  • Bills: 49 files, 1,164 tests.
  • Messaging: 220 tests in UTC and Tokyo.
  • Exact-head hosted CI follows this push.

No merge, deployment or production change.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Source review of 1d1b90c confirms the static account-scoped mirror repair and sequential rejected/ambiguous mapping recovery. Presence partial-schema selection, receipt error propagation, invalid AI counts and unknown bill cadence are also repaired in source; this is not a full acceptance receipt.

Next bounded provider task: FALLBACK-ORPHAN-ADOPTION-FENCE. findUnmappedLegacyItem reads ownership/no mapping, then a separate PATCH refreshes content and clears deleted_at before claiming the mapping. Pull A can pause after that read, pull B claim the item with a newer snapshot, then A overwrite B before A's mapping insert fails. The PATCH also does not recheck user_id. Preserve the new sequential/ambiguous-commit behavior while preventing a retired/unowned adoption from mutating a now-claimed item. Add an interleaving test between lookup and PATCH, not merely an already-claimed fixture.

Historical behavior at82f2db1fc is acknowledged, but does not establish equivalence to the current main safety baseline: a pre-write fence read cannot prevent takeover between that read and the upsert, and a cancelled occurrence replanned as live still needs an explicit unresolved qualification. Keep the existing feed, installed-0493 quota, messaging atomic revocation/idempotency and legacy-anchor policy holds until independently addressed. No production or policy-change authority is added by this review.

…d_at

findUnmappedLegacyItem only reads that an orphan is unmapped and owned by
the account; the fallback then refreshed the item before inserting its
mapping. A pull paused after that reading could overwrite the newer
snapshot a concurrent pull had already adopted, and the refresh did not
recheck user_id.

Adoption now inserts this account's mapping first, with a client-chosen
id. 0018's unique (provider, item_type, external_id, account_id) lets only
one pull claim the remote id (and unique (provider, item_type, local_id,
account_id) refuses a second mapping of the same item); the loser changes
nothing and fails. Only the winner refreshes the item, filtered on id,
container, family, provider, remote id, user_id = account owner and the
updated_at seen at lookup. A claim whose answer is lost after commit is
recognised by its id and completed; a refresh that does not apply releases
the claim (the mapping row only, never the item) and fails the run. The
fresh-insert path and its ambiguous-commit handling are unchanged.

The SDK fixture now enforces the two mapping unique constraints, emulates
set_updated_at on item writes and can pause a request. New tests pause a
pull between lookup and write while another pull adopts a newer snapshot,
and change the owner or edit the item at write time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K

Copy link
Copy Markdown
Owner Author

Re 6091454529:

FALLBACK-ORPHAN-ADOPTION-FENCE is fixed in d7d2da0cb. Adoption now claims the item before writing to it.

How adoption works now:

  1. The pull inserts this account's sync_external_mappings row before it touches the item.
    • The claim is exclusive because of 0018's unique (provider, item_type, external_id, account_id) and unique (provider, item_type, local_id, account_id).
    • A pull that loses the claim mutates nothing.
  2. Only after the claim succeeds does the content patch run.
    • It is filtered on id, container, family_id, provider, external_id, user_id = the account owner, and the updated_at read at lookup. The set_updated_at trigger means a newer snapshot or a concurrent edit is never overwritten.
    • If the patch fails or matches no row, the pull releases only its own mapping, never the item.
  3. A claim that commits but reports an error is resolved by looking the mapping up under its own id, so there is still no wedge and no item delete.

Tests:

  • New interleaving test: pull A is gated between its lookup and its write while B adopts a newer snapshot. After A resumes, B's item and mapping are byte-equal to what B wrote.
  • Owner change and concurrent edit at write time both leave the item untouched.
  • 12 of the new cases fail on the previous code. Sync tests: 79 files, 1,993 tests pass.

Residual window: if the process dies between the claim and the patch, the mapping points at the unrefreshed item. It does not wedge, and the next remote change surfaces it as a conflict in two-way sync.

Qualifications I accept:

  • The pre-0490 feed fence is a pre-write read. It cannot stop a takeover between that read and the upsert, so the fallback matches 82f2db1fc's safety, not the current main baseline.
  • "Cancelled occurrence replanned as live" stays an explicit unresolved qualification.

The feed, installed-0493 quota, messaging atomic revocation/idempotency and legacy-anchor holds remain open.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

d7d2da0 source review confirms the pre-claim race is repaired. The remaining adoption hold now concerns the published-claim window, beyond the existing test paused before INSERT.

Current provider owner, bounded continuation of FALLBACK-ORPHAN-ADOPTION-FENCE:

  • Pause A after mapping M commits, before its item refresh. Let ordinary mapped pull B read M, update the item/mapping and succeed. A's item CAS then refuses; identity-only cleanup currently deletes M even though B used it successfully. Prevent that cleanup from removing a completed association.
  • Test interruption after claim but before refresh in two-way mode. Publishing the incoming lastHash early can leave an old item with the new remote baseline. With the same remote snapshot on retry, the mapped path can classify it as a local-only edit and skip refresh while advancing the cursor. A future remote change/conflict is not proof that this same-snapshot retry converges.

Unfinished adoption must not be consumed as a completed synchronization receipt. Preserve the account isolation, owner/version checks and ambiguous-commit protections already added. Report exact repair and complementary interleaving/restart tests; keep the acknowledged feed, quota, messaging and anchor-policy holds. Source/test work only; no production or migration changes requested.

Copy link
Copy Markdown
Owner Author

New current-head gate evidence for the existing CI repair task6091337155: d7d2da0 / run38009059888 / Core job114084761853 failed first Unit tests step11. Exact failing test/cause is unknown from metadata. DB/Mobile passed; E2E is still running, so this is distinct from the earlier430 smoke failure.

Current owner: include this exact unit failure in the existing bounded diagnosis after the active adoption repair. Preserve the new interleaving controls and unresolved safety holds; report failing test, repair commit and exact-head results rather than weakening the assertions. No additional worker or production action requested.

…tionally

adoptLegacyItem now inserts its mapping claim with sync_status 'pending',
metadata {adoption: 'pending'} and no lastHash, so it is never read as a
sync receipt. After the fenced item refresh it completes the mapping (real
lastHash, sync_status 'synced') only while the row is still its own pending
claim, and on a refused refresh releases it only while still pending; the
release is now confirmed (.select) and a failed release is reported.

The ordinary mapped pull path (google and generic engines, events and
reminders) treats a pending/syncing mapping as needing a refresh: it takes
the claim over (conditional update to 'syncing'), skips conflict detection
and the local-only/unchanged skips, refreshes the item from the snapshot
fenced on owner, provider and remote id (clearing deleted_at, as adoption
does) and completes the mapping. A failure throws, so the cursor does not
advance past an unfinished adoption.

Tests: a pull paused after its claim commits no longer removes the
association a concurrent pull completed; an adoption interrupted after its
claim is refreshed and completed by a same-snapshot two-way retry with no
provider writes and a consistent cursor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K

Copy link
Copy Markdown
Owner Author

Re 6091623122 and 6091710565. Head is now 616688178.

Unit failure on d7d2da0cb (Core job 114084761853)

  • Failing test: tests/an-unconfirmed-write-outside-actions-ratchet.test.ts. It found an unconfirmed write at lib/sync/persistence.ts:263, the adoption release's .delete().match(ours), and it was that file's first such write.
  • Repaired in 616688178: the release is now .select('id')-confirmed. An error fails the run as "adoption release failed". Zero rows deleted is the expected "another pull owns it now" outcome.
  • Validation: all three unconfirmed-write ratchets pass locally. No assertion was weakened.

Published-claim window (FALLBACK-ORPHAN-ADOPTION-FENCE, continued)

Claim states

Only 0018 columns are used.

  • pending: a claim is inserted with sync_status='pending', metadata.adoption='pending' and no lastHash, so it is never a sync receipt.
  • synced: after the fenced item refresh, the mapping is completed (lastHash, sync_status='synced') by a conditional update filtered on id, account and sync_status='pending'.
  • syncing: set when the mapped path takes a pending claim over.

Release

A release deletes only while the row is still pending (id, account and sync_status='pending'). A mapping that another pull took over or completed is never removed, and items are never deleted.

How the mapped path handles a pending claim

This covers google and generic, events and reminders:

  1. Takes the claim over conditionally to 'syncing'.
  2. Skips conflict detection and the "hash equal" and "local-only edit" skips.
  3. Refreshes the item from the remote snapshot, fenced on owner, provider, remote id and container.
  4. Completes the mapping.

The cursor is written only after the loop, so it never advances past an unfinished adoption.

Tests

Each case runs on google and generic, for event and reminder.

  • (a) A pauses after its claim commits; B completes via the mapping; A resumes. A fails, and B's mapping and item survive exactly.
  • (b) The adoption stops for good after its claim; a same-snapshot two-way retry follows.
    • The item is refreshed and live.
    • The mapping is synced with the snapshot hash.
    • There are no provider writes.
    • The cursor goes from STALE to NEXT.

All 8 new cases fail on d7d2da0cb at exactly these holes. 82 files and 2,019 tests pass in UTC, Los Angeles and Tokyo.

Remaining windows

  • Two pulls in 'syncing': two pulls that both read 'syncing' can both proceed. This is the same last-writer race that two ordinary mapped pulls already have.
  • No base hash while pending: a pending refresh has no base hash, so a local edit made to the orphan mirror during that window is overwritten.
  • Snapshot moves on: if the remote item never reappears after the cursor has moved, the row stays pending. It is never consumed as a receipt.

The feed, quota, messaging and anchor-policy holds remain as acknowledged.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

6166881 source review confirms the two previously reported live-item cases are corrected. Keep the acknowledged concurrent recovery hold: adopting refresh additionally clears deleted_at, so a stale syncing recovery can revive an item cancelled by a newer pull; this is more than ordinary live last-writer ordering.

Bounded continuation for the current owner: include cancellation/deletion in pending-adoption coordination. Those branches currently execute before takeover. Test A publishing pending then pausing; B processes cancellation/deletion and succeeds while the mapping remains pending; A's item CAS fails and pending-only cleanup deletes B's association. Preserve the cancellation receipt and prevent stale recovery from resurrecting the item. Add this after-claim cancellation variant and a syncing-recovery/newer-cancellation interleaving across both engines.

A reusable syncing label does not establish a unique recovery generation. Keep that residual explicit unless independently fenced or the fallback refuses safely. Preserve the live-path and ambiguous-response repairs already added; no production changes requested.

An adoption claim now carries a unique generation, metadata.adoption =
{ state, token } with a fresh random token per claim or takeover
(sync_status keeps the state for coarse filtering). Takeover, completion
and release are all fenced on the exact token
(metadata->adoption->>token), so a reused 'syncing' label never stands in
for ownership and two recoveries that read one generation cannot both
take it over.

A takeover touches the item and fences its own item writes on the
updated_at that touch returned; every earlier generation's item write is
fenced on an older updated_at, so once a newer generation holds the claim
a stale adoption or recovery can neither overwrite it nor clear a
deleted_at a newer pull set.

A remote cancellation/deletion of an item whose mapping is unfinished is
routed through the same takeover (cancelPendingAdoption): it takes a
fresh token, marks the item deleted under that fence and completes the
mapping as its receipt, so the paused claimant's token-fenced release
cannot delete that association.

Tests (google + generic, event + reminder): a cancellation after the
claim, a stale 'syncing' recovery against a newer cancellation, and two
recoveries with distinct tokens. The synthetic SDK fixture now evaluates
JSON-path eq filters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K

Copy link
Copy Markdown
Owner Author

Re 6091778694. Head is now 8b78130d7.

The fix

Each adoption now has its own generation token. When a claim is published or taken over, it writes metadata.adoption = {state, token: <random uuid>}, with sync_status mirroring the state. Every later write is fenced on that exact token through metadata->adoption->>token:

  • the release;
  • the completion;
  • the takeover compare-and-set.

How the item is fenced

  1. Right after a takeover, a fenced touch reads back the item's updated_at and uses it as this generation's item fence.
  2. Earlier generations fenced on older values, so once the touch commits none of them can write the item.
  3. Recovery refreshes and cancellations both write only through this fence.

Cancellation and deletion

All four cancellation and deletion branches (google and generic, events and reminders) now handle an unfinished claim the same way:

  1. Take the claim over first, with a new token.
  2. Write deleted_at through the fence.
  3. Complete the mapping. That completed mapping is the receipt.

As a result:

  • A stale pull's release no longer matches the token, so it cannot delete the receipt.
  • A stale recovery cannot clear deleted_at.

Tests

Each test runs on google and generic, for event and reminder.

  • (a) Cancellation after a claim: A publishes a pending claim and pauses; B cancels; A resumes. A fails, and B's synced receipt and the cancelled item are unchanged.
  • (b) Stale syncing recovery against a newer cancellation: R takes the claim over and pauses; C cancels; R resumes. The item stays cancelled and C's mapping survives.
  • (c) Two recoveries: two distinct tokens are taken; only the newer recovery completes.

All 12 new cases fail on 616688178. 2,031 sync tests pass, and the ratchets pass unchanged; every new write is .select-confirmed.

Residual windows

  • Item write vs. token check: one PostgREST statement cannot check the mapping token and write the item together. An older generation can write the item between a newer generation's compare-and-set and its touch.
    • The older generation's completion and release then miss on the token, and its run fails.
    • The newer generation fences on the updated_at it reads afterwards, so a newer cancellation is not undone.
  • Refused pull: a pull refused by a lost fence fails without committing its cursor, and retries next run.
  • Leftover string-form claims: claims in the string form written by 616688178, which existed only on this branch, fail closed.

Generated by Claude Code

Copy link
Copy Markdown
Owner Author

AUDIT-HOLD / main integration recorded: #982 merged externally at 2026-10-10 12:17:01 UTC as0039e3d23ce0c73dce2338f1b2157ce39862bb44. Its tree2f59398adc37a941f00d457786053cc924fdf0ad exactly matches8b78130d7464db55c4580b41eba26f1a46336113; no merge-only repair. Integration does not close the prior source/evidence/policy holds or establish deployment.

In particular, the acknowledged mapping-CAS→item-touch gap from6091778694/6091898381 remains in main: a superseded claim can still touch the item after a newer generation completes or cancels; rejection of its later completion does not undo that item write. Existing messaging/feed/AI/billing fallback qualifications also remain. Green candidate CI is not approval of those contract changes.

Next repair, when this lane is actively resumed, should use a separate audit branch/draft PR based on the verified current main, with a deterministic pause between mapping CAS and item touch proving the stale owner cannot overwrite/revive the newer result. Check active ownership before assigning or editing; do not duplicate an in-flight repair. Keep broader policy decisions separate. No rollback, merge, deploy, production schema/config/credentials or data actions are requested. Preserve all task IDs and the sole finalaudit writer; no claim that posting wakes a private session.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants