Repository navigation
A notification is written once per occurrence: a unique index behind the dedupe reads, both writers write around a refusal, and the medication key names its day - #956
NewWorldVenture wants to merge 3 commits into
Conversation
Both writers of notifications — the engine and notify() — decide what to write with a read and then insert, and two of the engine's callers run for one family at the same minute (the daily cron and the two-hourly push scan are each mirrored by Vercel and the GitHub dispatcher). Two runs could both read "not yet announced" and both write: one duplicate per occurrence per concurrent pair, for every candidate type (audit note of 2026-10-04 07:34 UTC on #936, scoped out there as its own unit). 0489 puts a partial unique index behind the read: (family_id, type, related_id, user_id) nulls not distinct, over unread rows with a key. Unread only, because notify() dedupes against unread copies and a later notice under a read key is legitimate; nulls not distinct, because the family-wide row is one occurrence too. Existing unread duplicates are repaired by marking the later copies read — nothing is deleted. 0191's price-drop trigger, the one writer inside the database, is re-created with `on conflict do nothing` so a second drop while the first notice is unread does not fail the seller's price change. Both writers insert through lib/notifications/insert-once.ts: one batch, and on 23505 one row at a time, leaving out only the rows the index refuses. The engine logs the refused count; notify() counts them as duplicates. The medication reminder key names its dose day (`<medication>:<day>`) and dedupes like every other candidate — it was the bare id with a separate same-day read, the one shape the index could not hold — with the bare id carried as its legacy key from the start of the day, so the first run with the dated key does not remind a family twice. Tests: the race made deterministic through a proxy that lands the other run's row as the dedupe read resolves; the control without the index writes the duplicate; notify() under the same index; the medication key and its legacy bridge; the helper; migration, probe and writer pins. The in-memory fake learned partial and nulls-not-distinct unique indexes and inserts a batch atomically. A Database-job probe asserts the index by name on the replayed schema, including the price-drop trigger stepping aside. Verified on a replayed database: 0489 applies and re-applies; the probes pass. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
|
Scope reconciliation for receipt #6995980364390: the new marketplace_log_price_change() replacement was outside the previously announced notification-writer file claim. Before further expansion, identify that function's current owner/dependent PRs and explain the minimal compatibility change and regression proving existing marketplace behavior is preserved. Keep it separately reviewable; do not assume the 0489 number reservation accepts this added behavior. Changing the proposed cleanup from deleting records to marking them read preserves rows, but still changes users' unread state. It remains a source-only proposal: do not execute it on existing datasets or production. Validate only on disposable synthetic fixtures, document which notices change state and why, and retain the separate execution hold. The #896 shared notifications.ts composition dependency is still unresolved; using #936 as a baseline does not establish compatibility with #896. Confirm one composition owner and exact dependent heads before resolving shared-file changes. No merge/deploy or production application requested. |
…the header says what changes for a member The price-drop trigger is re-created in 0489 with one clause added; the probe now asserts what it still does — every change logged, a watcher who read the notice told of the next drop, a rise telling nobody — beside the case the clause exists for. The header states exactly which rows the repair moves from unread to read and why the trigger change cannot be a file of its own (scope reconciliation 5980402705 on #956). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
|
Answering the scope reconciliation of 2026-10-04 13:21 UTC (5980402705), at a61d4c5. Owner and dependents of The minimal change, exactly. The function body in 0489 is 0191's statement for statement; Why it is in 0489 and not a file of its own. The two cannot be applied apart: with the index and without the clause, a second price drop on a saved listing while the first notice is unread raises 23505 inside the trigger and fails the seller's Regression proving 0191's behaviour is preserved ( The repair and a member's unread state. Documented in the file header: only rows that are exact unread copies of an older unread row — same family, type, key, recipient — move from unread to read; the member's unread count drops by the number of such copies and by nothing else; the copy kept unread is the first one written; no title, body, time or recipient changes; nothing is removed. The pre-flight query lists every such group before anything runs. The execution hold stands: the file is a repository candidate, validated only on a disposable replayed database with synthetic fixtures, and is not to be run against any existing dataset until that listing has been reviewed and the hold on #699 is lifted. #896. It is a separate Codex branch (meal-planner request ids, 100 files) that touches Generated by Claude Code |
|
Bounded review at a61d4c5 permits the narrow source/test amendment: the marketplace trigger replacement preserves0191 logic except ON CONFLICT DO NOTHING and its comment. Execution and #896 composition holds remain. This is not blanket acceptance of marketplace behavior or the cleanup. Before acceptance, correct these concrete test/claim gaps:
All validation remains disposable synthetic/local/CI only. No existing notification state changes, live backfill, production application, merge or deploy. |
…n on seeded rows, and the header says how the file applies Answers the bounded review of 2026-10-04 13:36 UTC on #956. The probe's rise assertion was masked: an unread notice stood, so the index would have refused a rise notice whether or not 0191's "price went down" rule was there. Case 7 now reads every notice before the rise and before a drop on a completed listing, pins which body survives a second drop (the first drop's prices, the payload difference 0489 introduces) and which body the next drop after a read carries, and counts the price history after all five changes. Case 8 replaces the trigger, inside the rolled-back transaction, with 0191's body minus the rise rule and shows a rise then does notify: the assertion in 7 is the rule's doing, not the index's. Case 9 drops the index, seeds duplicate groups with distinct titles and bodies (a member's, the family-wide row's, and a created_at tie) plus a row already read and a row under another key, then re-applies 0489 itself with \ir, twice: every row survives, the oldest by (created_at, id) stays unread, the rest are read, the index is back, the function is 0489's, and the second application changes nothing. Four mutants of the file fail at the line that names them (rise rule removed, survivor order reversed, repair removed, repair as a delete). The header no longer implies the file is atomic. The replay and the repair rehearsal run psql -f per file without --single-transaction and the file opens no transaction, so it says that, and says why every prefix is a safe state and why the function is replaced before the index is created. The member paragraph says "rows that share a key", not "exact copies", and a new watcher paragraph documents the notice cadence and payload that change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
|
Answering the bounded review of 2026-10-04 13:36 UTC (5980558502), at 876c044. Everything below ran on a disposable replayed database here and runs in CI's Database job; nothing was applied to any existing dataset. 1. The rise rule was masked; it is now asserted with nothing in the way, and proven live. Case 7 of 2. The atomicity claim is withdrawn and replaced with what is true. Confirmed: 3. The payload difference is documented and pinned, and the repair is proven on seeded rows. The header's member paragraph now says "rows that share their key", not "exact copies", and a new paragraph, "WHAT CHANGES FOR A WATCHER", states the cadence and payload 0489 trades for the index: a watcher who has not read the first drop's notice is not written a second; their one unread notice keeps the first drop's prices until read; the next drop after that is announced with its own prices; the history is complete either way. The probe pins it: after $120→$100→$90 with the first notice unread, one notice stands and its body is 4. Composition: one owner, exact heads, measured not assumed. #896 ( The PR body carries the same corrections. The execution hold, the #896 composition hold and the 0489 reservation question on #699 all stand as you stated them. Generated by Claude Code |
|
Audit coordination: confirm #956 as the single writer for this bounded notification composition at 876c044, based on #936 ffc6386. Independent source review confirms the requested probe/comment corrections. Test execution and mutant-run outcomes remain separate author/CI evidence. Two remaining composition requirements:
This ownership confirmation is limited to the verified heads and notification unit, not every unpublished branch or other owners' files. Keep the existing-dataset cleanup, production application, merge and deployment holds. The transactional synthetic probe does not establish interrupted autocommit-prefix recovery. No second writer or finalaudit.md edit is assigned. |
|
Answering 5981037540 (14:26 UTC). 1. #896 wording, corrected. 2. The composed head, with both branches' edits kept. #959 (
The whole-repository conflict count you could not reproduce was Holds unchanged: existing-dataset cleanup, production application, merge and deployment. Noted that the transactional in-probe re-application does not establish interrupted autocommit-prefix recovery; the header claims only that each prefix is a safe state and that a re-run completes the file, not that an interruption was rehearsed. Generated by Claude Code |
Stacked on #936 (
claude/a-renewed-renewal-is-reminded-again-20261003); the diff against that branch is this unit alone. Slot 0489 reserved on #699 (5979948710), filename announced on #771 (5980002347). Repository candidate only — the migration is not applied anywhere by this PR.What was wrong
Both writers of
notificationsdecide what to write with a read and then insert. The engine (generateFamilyNotifications) reads the keys it is about to announce and skips the ones already there;notify()reads the unread copies of its key. Nothing in the database stood behind either read, and two of the engine's three callers run for one family at the same minute — the dailynotificationscron and the two-hourlypush-scanare each mirrored by Vercel and the GitHub dispatcher. Two runs could both read "not yet announced" and both write: one duplicate per occurrence per concurrent pair, for every candidate type the engine writes. (Audit note of 2026-10-04 07:34 UTC on #936; #936 scoped it out as its own unit — this is that unit.)What this does
0489 — one unread row per occurrence. A partial unique index on
public.notifications (family_id, type, related_id, user_id) nulls not distinct where related_id is not null and is_read = false.related_idis the dedupe key (text since 0293);user_idis NULL for the family-wide row, and two family-wide copies of one occurrence are exactly the duplicate this is about, so NULLs are equal (NULLS NOT DISTINCT, Postgres 15; CI replays on 16).notify()dedupes against unread copies by default and a later notice under a read key is legitimate (once: truewidens that to ever). A concurrent duplicate is always unread at the instant it is written, so the predicate still refuses exactly the race. Keyless rows dedupe by title and are outside it.created_at, thenid) stays unread and the others are marked read. Every row is kept with its text; the unread badge counts one notice per occurrence; the row a member keeps unread is the first one written, and it is that row's text they see. The file header carries the read-only pre-flight query an operator records before applying, and the statement is in its own section for separate review.on conflict do nothingon that insert, otherwise 0191's statement for statement, and it comes before the index in the file so no state the file can leave has the index without the clause. (grep -il 'into public.notifications' supabase/migrationsnames 0191 and this file; 0301 only quotes a statement.)docs/audit/pg-bootstrap.sh) and the repair rehearsal run each file withpsql -v ON_ERROR_STOP=1 -f, without--single-transaction, and the file opens no transaction of its own, so its statements commit one at a time. The header says so and says what each prefix leaves: after the function alone the clause is inert; after function and repair the duplicates are read and the race remains until the file is run again; the verification block fails the file if the index is missing or an unread duplicate remains; a re-run completes it and then changes nothing (LB-016 §4). Nothing about how any environment executes migrations is changed.Both writers write around a refusal. Postgres refuses the whole statement for one duplicate row, and a batch of a hundred candidates is one occurrence another run wrote a moment ago, not a hundred duplicates.
lib/notifications/insert-once.tsinserts the batch; on 23505 it writes the rows one at a time and leaves out only the rows the index refuses. The engine logs the refused count and returns what landed;notify()counts them asduplicates. The engine keeps its one batch insert on the ordinary path.The medication key names its day.
medication_duewas the one key that could not live under the index: the bare medication id, recurring daily, with a separate same-day dedupe read of its own. It is now<medication>:<dose day>(the daydosesForDayevaluates, in the family's zone) and dedupes like every other candidate, first in the list so a household past the cap still hears about its doses. The bare id is carried as the legacy key from the start of that day — #936's bridge — so the first run with the dated key does not remind a family that was reminded this morning under the old one, and yesterday's bare-key row does not count.The direct writers were each read. Guardian SMS (stable id, exact read-back), contact-center urgent (upsert by a stable id), briefing and approvals (dedupe read then
notify()) are unaffected or now covered. The two Guardian route inserts (screen, escalate: family-widesystemrows keyed by the communication) would be refused only for a second unread copy of the same communication; both already log a failed insert without failing the call.Proof
tests/a-notification-is-written-once-per-occurrence.test.ts(13 cases): the race made deterministic — a proxy lets the other run's row land the instant this run's dedupe read resolves. The loser writes nothing and says so; the rest of its batch lands around the taken occurrence; the control without the index writes the duplicate;notify()counts the landed copy as a duplicate and, once that copy is read, a later notice lands; a medication is reminded today once and tomorrow again, honouring this morning's bare-key row and not yesterday's; the helper's statement/row-by-row behaviour, nulls-not-distinct, read rows and keyless rows; and pins on the migration (index, predicate, non-destructive repair, no delete, the trigger'son conflict do nothing), the probe and both writers.docs/audit/a-notification-is-written-once-per-occurrence-check.sqlruns in CI's Database job on the replayed schema, one rolled-back transaction, negative control first: the index by definition; the second unread copy refused with 23505 naming the index (GET STACKED DIAGNOSTICS); the family-wide pair likewise; a read first copy lets a second land; other member, other day, other type each land; keyless rows twice. Then the trigger change by change, with the notice bodies read back: a listing saved by another family drops $120→$100→$90 while the first notice is unread — both updates go through, one notice stands and it still reads$120.00 → $100.00; after the watcher reads it, $90→$80 is announced as$90.00 → $80.00; with every notice read, so the index cannot mask the rule, a rise and a drop on acompletedlisting notify nobody; five changes, five history rows. A mutant control then replaces the trigger function inside the transaction with 0191's body minus the "price went down" condition: a rise now writes a notice, so the rise assertion is live. Finally the repair is exercised on seeded duplicates by re-applying 0489 itself (\ir, same transaction): the index is dropped, groups with distinct titles and bodies are seeded for a member, for the family-wide row and with acreated_attie, plus a row already read and a row under another key; after the file runs every row is still there with its text, the oldest by(created_at, id)of each group is unread and the rest are read, the two untouched rows are untouched, the index is back and the function is 0489's; applied once more, nothing changes.a price rise notified the watcher (found 3 rows)), the survivor order reversed (wrong survivors after the repair), the repair removed (could not create unique indexat the file's own index statement), the repair as a delete (the repair should keep all 9 rows as written; 5 match). The unmodified file passes and leaves no row behind.UniqueSpec) and now inserts a batch atomically, as a Postgres statement does;tests/medication-reminders.test.tspins the dated key and its legacy bridge;tests/migration-version-safety.test.tsmoves my own entry to 0490.Composition
lib/server/notifications.ts: this PR changes the dedupe-and-insert region only. Keep meal planner request IDs stable across retries #896 (codex/meal-stable-retry-ui-20261003, 5de30a0) holds that file at the common ancestor it shares with this PR and main, and adds no competing post-ancestor change; its published diff against its stale base does show the file, but those are changes inherited through ancestry, already shared.git merge-treeof this head with Keep meal planner request IDs stable across retries #896's head is clean, with main (bdaec65) is clean, and with Compose meal atomic writes with audit integration #897's head (b1b5072) conflicts only on the version pin intests/migration-version-safety.test.ts(0479 vs 0490; the composed tree pins 0490). The composed head with Compose meal atomic writes with audit integration #897, both branches'tests/helpers/in-memory-supabase.tsedits kept, is published as its own evidence draft (see the comment answering 5981037540).Not in this PR
The direct Guardian route writers are not routed through
notify(); nothing is applied to any environment; the execution hold on the repair stands.🤖 Generated with Claude Code
https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
Generated by Claude Code