From 490cb0c3aeff8131e1bbd8f11d26203dbad42ba5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 12:44:55 +0000 Subject: [PATCH 1/5] Lock the commune before the task when completing, handing over or undoing Completing, skipping, reopening, handing over and undoing a task locked the task first and needed the commune later, when the foreign key on the occurrence or activity line they add takes a key-share lock on it. A member leaving locks the commune and then their tasks, so the two could wait on each other until PostgreSQL cancelled one as a deadlock. 0037 patches the three functions in place, as 0027 did for edits and removals: the task's commune is read without a lock and locked, then the task (for undo, the task through its occurrence) is locked and read as before. Every existing check and message is unchanged, and each patch asserts that it applied and that the commune is the first row locked. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS --- .../0037_task_command_lock_order.sql | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 infra/neon/migrations/0037_task_command_lock_order.sql diff --git a/infra/neon/migrations/0037_task_command_lock_order.sql b/infra/neon/migrations/0037_task_command_lock_order.sql new file mode 100644 index 00000000..73bb071c --- /dev/null +++ b/infra/neon/migrations/0037_task_command_lock_order.sql @@ -0,0 +1,86 @@ +-- Completing, skipping, reopening, handing over and undoing a task now lock +-- the commune before the task, as editing and removing have since 0027. A +-- member leaving locks the commune and then each of their tasks. These +-- commands locked the task first and needed the commune later, when the +-- occurrence or activity line they add takes a key-share lock on it through +-- its foreign key. So when a member left while someone acted on one of their +-- tasks, each waited on the other until PostgreSQL cancelled one as a +-- deadlock, and the action or the departure failed. Now the command waits +-- for the departure to finish, then finds the task as the departure left it +-- and goes ahead or is refused with its usual message. +-- +-- Only the start of each function changes, so each is patched in place: the +-- task's commune is read without a lock and locked, then the task (for undo, +-- the task through its occurrence, then the occurrence) is locked and read +-- as before. A task never moves to another commune, so that commune is still +-- the task's once the locks are held, and every check runs on the task as it +-- is then. Nothing else locks a task or an occurrence before its commune: +-- adding a task locks the commune first, and the snapshot and search only +-- read. No client change. Apply after 0036. +BEGIN; + +-- Each patch must apply, and leave the commune as the first row its +-- function locks. +DO $$ +DECLARE v_original text; v_source text; +BEGIN + v_original := pg_get_functiondef('public.record_operation_occurrence(uuid,text,date,date,uuid,text)'::regprocedure); + v_source := replace(v_original, ' v_op public.commune_operations%ROWTYPE;', + ' v_commune_id uuid; + v_op public.commune_operations%ROWTYPE;'); + v_source := replace(v_source, + ' -- The row lock serialises two people acting on the same task. + SELECT * INTO v_op FROM public.commune_operations WHERE id = p_operation_id FOR UPDATE;', + ' -- The commune first, then the task, as in update_operation: the order a + -- member leaving takes them in. The other order lets the two wait on each + -- other until one fails as a deadlock. + SELECT commune_id INTO v_commune_id FROM public.commune_operations WHERE id = p_operation_id; + PERFORM 1 FROM public.communes WHERE id = v_commune_id FOR UPDATE; + -- The row lock serialises two people acting on the same task. + SELECT * INTO v_op FROM public.commune_operations WHERE id = p_operation_id FOR UPDATE;'); + IF position(' v_commune_id uuid;' IN v_source) = 0 + OR split_part(v_source, ' FOR UPDATE', 1) !~ 'PERFORM 1 FROM public\.communes WHERE id = v_commune_id$' THEN + RAISE EXCEPTION 'Unsupported occurrence definition'; + END IF; + EXECUTE v_source; + + v_original := pg_get_functiondef('public.hand_over_operation(uuid,text,date,text,date,text,uuid)'::regprocedure); + v_source := replace(v_original, ' v_op public.commune_operations%ROWTYPE;', + ' v_commune_id uuid; + v_op public.commune_operations%ROWTYPE;'); + v_source := replace(v_source, + ' SELECT * INTO v_op FROM public.commune_operations WHERE id = p_operation_id FOR UPDATE;', + ' -- The commune first, then the task, as in update_operation. + SELECT commune_id INTO v_commune_id FROM public.commune_operations WHERE id = p_operation_id; + PERFORM 1 FROM public.communes WHERE id = v_commune_id FOR UPDATE; + SELECT * INTO v_op FROM public.commune_operations WHERE id = p_operation_id FOR UPDATE;'); + IF position(' v_commune_id uuid;' IN v_source) = 0 + OR split_part(v_source, ' FOR UPDATE', 1) !~ 'PERFORM 1 FROM public\.communes WHERE id = v_commune_id$' THEN + RAISE EXCEPTION 'Unsupported handover definition'; + END IF; + EXECUTE v_source; + + v_original := pg_get_functiondef('public.undo_operation_occurrence(uuid)'::regprocedure); + v_source := replace(v_original, ' v_op public.commune_operations%ROWTYPE;', + ' v_commune_id uuid; + v_op public.commune_operations%ROWTYPE;'); + v_source := replace(v_source, + ' SELECT o.* INTO v_op FROM public.commune_operations o + JOIN public.operation_occurrences h ON h.operation_id = o.id WHERE h.id = p_occurrence_id FOR UPDATE OF o;', + ' -- The commune first, then the task, then the occurrence, as in + -- update_operation. + SELECT o.commune_id INTO v_commune_id FROM public.commune_operations o + JOIN public.operation_occurrences h ON h.operation_id = o.id WHERE h.id = p_occurrence_id; + PERFORM 1 FROM public.communes WHERE id = v_commune_id FOR UPDATE; + SELECT o.* INTO v_op FROM public.commune_operations o + JOIN public.operation_occurrences h ON h.operation_id = o.id WHERE h.id = p_occurrence_id FOR UPDATE OF o;'); + IF position(' v_commune_id uuid;' IN v_source) = 0 + OR split_part(v_source, ' FOR UPDATE', 1) !~ 'PERFORM 1 FROM public\.communes WHERE id = v_commune_id$' THEN + RAISE EXCEPTION 'Unsupported undo definition'; + END IF; + EXECUTE v_source; +END; +$$; + +NOTIFY pgrst, 'reload schema'; +COMMIT; From 9a926d05d355f58e0310b17f6a365ed3e9b118a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 12:44:56 +0000 Subject: [PATCH 2/5] Race task completion, skip, handover and undo against a member leaving The lock order test now holds a departure between its commune and member locks while Casey completes, skips, hands over or undoes, as it already did for an edit and a removal. Each race must finish without a deadlock, the departure must succeed, and the command either goes ahead on the task as the departure left it or is refused with its usual message. Each session now ends its transaction as soon as its statement settles, so a deadlock fails the test in about a second rather than after a statement timeout. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS --- infra/neon/tests/task_lock_order.mjs | 86 ++++++++++++++++++++++------ 1 file changed, 69 insertions(+), 17 deletions(-) diff --git a/infra/neon/tests/task_lock_order.mjs b/infra/neon/tests/task_lock_order.mjs index 5c9f0675..4b20ccd3 100644 --- a/infra/neon/tests/task_lock_order.mjs +++ b/infra/neon/tests/task_lock_order.mjs @@ -1,16 +1,21 @@ -// Lock order between a member leaving and a task edit or removal (0027). -// A member leaving locks the commune and then that member's tasks. An edit -// or removal that locked the task first and then needed the commune would -// wait on the leaving member while the leaving member waited on it, until -// PostgreSQL cancelled one of them as a deadlock. This test holds the -// departure at the point between its two locks, starts the edit or removal, -// then lets both run, so that interleaving happens every time. +// Lock order between a member leaving and the task commands (0027, 0037). +// A member leaving locks the commune and then that member's tasks. A command +// that locked the task first and then needed the commune (adding a history +// line or an occurrence takes a key-share lock on it) would wait on the +// leaving member while the leaving member waited on it, until PostgreSQL +// cancelled one of them as a deadlock. Every task command now locks the +// commune first: editing and removing since 0027; completing, skipping, +// handing over and undoing since 0037. For each command, this test holds the +// departure at the point between its two locks, starts the command, then +// lets both run, so that interleaving happens every time. The command then +// finds the task as the departure left it, and goes ahead or is refused with +// its usual message. // // Run against a disposable local database with every migration applied, on // top of infra/local/bootstrap.sql or infra/neon/tests/local_auth_stub.sql: // DATABASE_URL=postgres://commune:commune@127.0.0.1:5432/ node infra/neon/tests/task_lock_order.mjs -// It adds a commune and removes it again. Never point it at a shared or -// hosted database. +// It adds a commune for each command and removes it again. Never point it at +// a shared or hosted database. import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; import { createRequire } from "node:module"; @@ -60,9 +65,18 @@ async function waitingForLock(pid) { throw new Error(`Session ${pid} never waited for a lock`); } const settle = (promise) => promise.then((result) => ({ result }), (error) => ({ error })); +// A session ends its transaction as soon as its statement settles. A session +// cancelled as a deadlock then lets the other one finish at once. +async function finish(client, outcome) { + const settled = await outcome; + await client.query(settled.error ? "ROLLBACK" : "COMMIT"); + return settled; +} // Avery runs the commune. Casey added a weekly task that is Blake's turn, -// then Blake is removed while Casey changes the task. +// then Blake is removed while Casey acts on the task. A command's prepare +// step runs before the race and returns its values; otherwise the only +// value is the task. async function race(command) { const commune = (await as(avery, "SELECT public.create_commune('Lock order', 'household', 'GBP') AS id")).id; const sessions = []; @@ -75,6 +89,7 @@ async function race(command) { await owner.query("COMMIT"); const task = (await as(casey, `SELECT public.create_operation($1, 'task', 'chore', 'Bins', current_date, NULL, true, $2, jsonb_build_array($2::text, $3::text), 'weekly', '', $4) ->> 'id' AS id`, [commune, blake, casey, randomUUID()])).id; + const values = command.prepare ? await command.prepare(task) : [task]; // Hold Blake's membership row, so the departure stops after locking the commune. const holder = await connect(); @@ -87,19 +102,17 @@ async function race(command) { await waitingForLock(leaving.pid); const changing = await begin(casey); sessions.push(changing.client); - const changed = settle(changing.client.query(command.sql, [task])); + const changed = settle(changing.client.query(command.sql, values)); await waitingForLock(changing.pid); await holder.query("COMMIT"); - const departure = await left; - await leaving.client.query(departure.error ? "ROLLBACK" : "COMMIT"); - const change = await changed; - await changing.client.query(change.error ? "ROLLBACK" : "COMMIT"); + const [departure, change] = await Promise.all([finish(leaving.client, left), finish(changing.client, changed)]); for (const outcome of [departure, change]) { assert.notEqual(outcome.error?.code, "40P01", `${command.name} and a member leaving deadlocked: ${outcome.error?.message}`); } assert.equal(departure.error, undefined, departure.error?.message); - const { rows } = await owner.query("SELECT assignee_id, rotation_ids, title, version, deleted_at FROM public.commune_operations WHERE id = $1", [task]); + const { rows } = await owner.query(`SELECT assignee_id, rotation_ids, turn_owner_id, title, date - current_date AS due_in, + status, version, deleted_at FROM public.commune_operations WHERE id = $1`, [task]); return { change, row: rows[0] }; } finally { // Ending a session rolls back anything it left open. @@ -123,7 +136,46 @@ try { assert.equal(removal.change.error, undefined, removal.change.error?.message); assert.notEqual(removal.row.deleted_at, null); assert.deepEqual([removal.row.assignee_id, removal.row.version], [casey, 3]); - console.log("task lock order passed: a member leaving and a task edit or removal wait in turn"); + + // The completion waits for the departure, then completes the turn that has + // passed to Casey, the only person left in the rotation. + const completion = await race({ name: "A completion", + sql: "SELECT public.record_operation_occurrence($1, 'completed', current_date, current_date, $2)", + prepare: (task) => [task, randomUUID()] }); + assert.equal(completion.change.error, undefined, completion.change.error?.message); + assert.deepEqual([completion.row.assignee_id, completion.row.rotation_ids, completion.row.due_in, completion.row.status, + completion.row.version], [casey, [casey], 7, "open", 3]); + + // The skip waits for the departure, then moves Casey's turn on a week. + const skip = await race({ name: "A skip", + sql: "SELECT public.record_operation_occurrence($1, 'skipped', current_date, current_date, $2)", + prepare: (task) => [task, randomUUID()] }); + assert.equal(skip.change.error, undefined, skip.change.error?.message); + assert.deepEqual([skip.row.assignee_id, skip.row.rotation_ids, skip.row.due_in, skip.row.status, skip.row.version], + [casey, [casey], 7, "open", 3]); + + // Casey offers to take Blake's turn. The handover waits for the departure, + // then finds the turn already passed to Casey and is refused as stale. + const handover = await race({ name: "A handover", + sql: "SELECT public.hand_over_operation($1, $2, current_date, $3, current_date, NULL, $4)", + prepare: (task) => [task, casey, blake, randomUUID()] }); + assert.equal(handover.change.error?.code, "P0001", handover.change.error?.message ?? "The stale handover was saved"); + assert.equal(handover.change.error.message, "This task has already moved on. Refresh to see who has it now."); + assert.deepEqual([handover.row.assignee_id, handover.row.turn_owner_id, handover.row.due_in, handover.row.version], + [casey, null, 0, 2]); + + // Casey completed Blake's turn, so the next one is Casey's. Undoing that + // would give the task back to Blake: the undo waits for the departure, then + // is refused because Blake has left, and the task stays with Casey. + const undo = await race({ name: "An undo", sql: "SELECT public.undo_operation_occurrence($1)", + prepare: async (task) => [(await as(casey, `SELECT public.record_operation_occurrence($1, 'completed', current_date, + current_date, $2) ->> 'id' AS id`, [task, randomUUID()])).id] }); + assert.equal(undo.change.error?.code, "P0001", undo.change.error?.message ?? "The undo gave the task back to Blake"); + assert.equal(undo.change.error.message, + "The person who had this task has left, so it can no longer be undone. Edit the task instead."); + assert.deepEqual([undo.row.assignee_id, undo.row.rotation_ids, undo.row.due_in, undo.row.status, undo.row.version], + [casey, [casey], 7, "open", 3]); + console.log("task lock order passed: a member leaving and a task edit, removal, completion, skip, handover or undo wait in turn"); } finally { await owner.end(); } From dc88a6f3f3259148e617fd23e2bde5302da8ed84 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 12:59:06 +0000 Subject: [PATCH 3/5] Lock the commune before the invitation when emailing an invitation The invitation email sender locked the invitation first and needed the commune later, when the foreign key on the delivery row it adds takes a key-share lock on it. An admin leaving, removed or made a member locks the commune and then withdraws their invitations, so the two could wait on each other until PostgreSQL cancelled the sender as a deadlock. The sender now takes a key-share lock on the invitation's commune, found by the token hash, in its own statement first. It then waits for such a change to finish and refuses with the existing "Invitation is unavailable" 404. Accepting an invitation and inviting the same address again already lock the commune before the invitation, so the order now matches them too. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS --- apps/reminder-worker/src/invitations.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/reminder-worker/src/invitations.ts b/apps/reminder-worker/src/invitations.ts index c1745acc..192c5da2 100644 --- a/apps/reminder-worker/src/invitations.ts +++ b/apps/reminder-worker/src/invitations.ts @@ -105,6 +105,11 @@ export async function sendInvitationEmail(pool: Pool, actor: string, token: stri let payload: Payload; try { await client.query("BEGIN"); + // An admin leaving, removed or made a member locks the commune and then + // withdraws their invitations, so the sender takes the commune first and + // the two wait in turn. + await client.query(`SELECT 1 FROM public.communes c JOIN public.commune_invites i ON c.id=i.commune_id + WHERE i.token_hash=$1 FOR KEY SHARE OF c`, [tokenHash]); const { rows } = await client.query(` SELECT i.*, c.name AS commune_name, u.name AS inviter_name FROM public.commune_invites i JOIN public.communes c ON c.id=i.commune_id From 8caf771ab943970447b7ef0f8779385d98657ca9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 12:59:07 +0000 Subject: [PATCH 4/5] Race an invitation email against its admin being removed The invitations test now holds an admin's removal between its commune and member locks while the real sendInvitationEmail runs for that admin's invitation, so the interleaving happens every time. Neither side may fail as a deadlock, the removal must succeed, and the sender must be refused with the existing 404, leaving no delivery row and never calling the mocked provider. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS --- apps/reminder-worker/tests/invitations.mjs | 55 ++++++++++++++++++++-- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/apps/reminder-worker/tests/invitations.mjs b/apps/reminder-worker/tests/invitations.mjs index 032d6e1d..a4748aa4 100644 --- a/apps/reminder-worker/tests/invitations.mjs +++ b/apps/reminder-worker/tests/invitations.mjs @@ -11,7 +11,7 @@ const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const senderPool = new Pool({ connectionString: process.env.DATABASE_URL, options: "-c search_path=pg_catalog" }); const send = (actorId, token, mail = config) => sendInvitationEmail(senderPool, actorId, token, mail); const tokenHash = (token) => createHash("sha256").update(token).digest(); -const actor = randomUUID(), outsider = randomUUID(), bulkSender = randomUUID(); +const actor = randomUUID(), outsider = randomUUID(), bulkSender = randomUUID(), otherAdmin = randomUUID(), removedAdmin = randomUUID(); const config = { apiKey: "test-only", from: "Commune ", appUrl: "https://example.test" }; const calls = []; let providerStatus = 200; @@ -27,12 +27,23 @@ async function asUser(userId, fn) { } const asActor = (fn) => asUser(actor, fn); const refusal = (status, message) => (e) => e.status === status && (!message || e.message === message); +const settle = (promise) => promise.then((result) => ({ result }), (error) => ({ error })); +// Polls until the query's row says ok, so a race can wait for a session to +// be stuck behind a lock. +async function waitUntil(sql, values) { + for (let attempt = 0; attempt < 400; attempt += 1) { + if ((await pool.query(sql, values)).rows[0]?.ok) return; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`Gave up waiting for: ${sql}`); +} +const UNAVAILABLE = "Invitation is unavailable or you are no longer a commune admin."; const NAME_REFUSAL = "This invitation can't be emailed because a name in it looks like a link or phone number. Copy the link and share it yourself."; const SUPPRESSED = "This address can't receive Commune emails. Share the link another way."; const NEW_ACCOUNT = "New accounts can email up to 5 invitations a day. Copy the link and share it yourself."; const PAUSED = "Invitation emails are paused for today. Copy the link and share it yourself."; const deliveries = async () => +(await pool.query("SELECT count(*) FROM public.invitation_email_deliveries WHERE sender_id=$1", [actor])).rows[0].count; -let commune, bulkCommune; +let commune, bulkCommune, removalCommune; try { // The email names its inviter, quotes the commune and offers a report link // that names the invitation by id, never by token. @@ -197,11 +208,45 @@ try { assert.equal(calls.length, before + 1, "simultaneous sends dispatch only once"); await asActor(c => c.query("UPDATE public.commune_members SET role='member' WHERE commune_id=$1 AND user_id=$2", [commune, actor])); await assert.rejects(send(actor, parallelToken), refusal(404), "former admins cannot send"); - console.log("Invitation email: authorization, inviter and commune naming, report link, contact-like names, suppressed addresses, new-account and Commune-wide limits, index use, provider failure/retry, deduplication, rate limit, expiry, token privacy and HTML escaping passed. No real email sent."); + + // An admin removed while their invitation is being emailed. The removal + // locks the commune and then withdraws the admin's invitations, so the + // sender takes the commune first, waits for the removal and then finds the + // invitation gone. Holding the admin's membership row stops the removal + // between its two locks, so this interleaving happens every time. + await pool.query('INSERT INTO neon_auth."user" (id,name,email,"emailVerified") VALUES ($1,\'Other admin\',$2,true), ($3,\'Removed admin\',$4,true)', + [otherAdmin, `${otherAdmin}@example.test`, removedAdmin, `${removedAdmin}@example.test`]); + removalCommune = (await asUser(otherAdmin, c => c.query("SELECT public.create_commune('Removal test','household','GBP') AS id"))).rows[0].id; + await asUser(otherAdmin, c => c.query("INSERT INTO public.commune_members (commune_id,user_id,role) VALUES ($1,$2,'admin')", [removalCommune, removedAdmin])); + const removedToken = (await asUser(removedAdmin, c => c.query("SELECT public.create_commune_invite($1,'removal@example.test','member') AS token", [removalCommune]))).rows[0].token; + const holder = await pool.connect(), remover = await pool.connect(); + try { + await holder.query("BEGIN"); + await holder.query("SELECT 1 FROM public.commune_members WHERE commune_id=$1 AND user_id=$2 FOR UPDATE", [removalCommune, removedAdmin]); + await remover.query("BEGIN"); + await remover.query("SELECT set_config('request.jwt.claims',json_build_object('sub',$1::text)::text,true)", [otherAdmin]); + const { pid } = (await remover.query("SELECT pg_backend_pid() AS pid")).rows[0]; + const removal = settle(remover.query("SELECT public.manage_commune_member($1,$2,'remove')", [removalCommune, removedAdmin])); + await waitUntil("SELECT wait_event_type='Lock' AS ok FROM pg_stat_activity WHERE pid=$1", [pid]); + before = calls.length; + const sending = settle(send(removedAdmin, removedToken)); + await waitUntil("SELECT count(*)>0 AS ok FROM pg_stat_activity WHERE $1=ANY(pg_blocking_pids(pid))", [pid]); + await holder.query("COMMIT"); + const [removed, sent] = await Promise.all([ + removal.then(async (outcome) => { await remover.query(outcome.error ? "ROLLBACK" : "COMMIT"); return outcome; }), sending]); + for (const outcome of [removed, sent]) assert.notEqual(outcome.error?.code, "40P01", `the removal and the sender deadlocked: ${outcome.error?.message}`); + assert.equal(removed.error, undefined, removed.error?.message); + assert.equal(sent.error?.status, 404, sent.error?.message ?? "the invitation was emailed"); + assert.equal(sent.error.message, UNAVAILABLE); + } finally { holder.release(true); remover.release(true); } + assert.equal((await pool.query("SELECT active FROM public.commune_members WHERE commune_id=$1 AND user_id=$2", [removalCommune, removedAdmin])).rows[0].active, false); + assert.equal(+(await pool.query("SELECT count(*) FROM public.invitation_email_deliveries WHERE commune_id=$1", [removalCommune])).rows[0].count, 0, "no delivery is left"); + assert.equal(calls.length, before, "the provider is never called"); + console.log("Invitation email: authorization, inviter and commune naming, report link, contact-like names, suppressed addresses, new-account and Commune-wide limits, index use, provider failure/retry, deduplication, rate limit, expiry, token privacy, HTML escaping and an admin removed while sending passed. No real email sent."); } finally { - await pool.query("DELETE FROM public.communes WHERE id = ANY($1::uuid[])", [[commune, bulkCommune].filter(Boolean)]); + await pool.query("DELETE FROM public.communes WHERE id = ANY($1::uuid[])", [[commune, bulkCommune, removalCommune].filter(Boolean)]); await pool.query("DELETE FROM public.email_suppressions WHERE provider_event_id LIKE 'msg_test_%'"); - await pool.query('DELETE FROM neon_auth."user" WHERE id = ANY($1::text[])', [[actor, bulkSender]]); + await pool.query('DELETE FROM neon_auth."user" WHERE id = ANY($1::text[])', [[actor, bulkSender, otherAdmin, removedAdmin]]); await pool.end(); await senderPool.end(); } From 79cdd9b813ab6c824f907e8a2e58b8ab89fee26b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 14:48:36 +0000 Subject: [PATCH 5/5] Record the lock order migration and the sender change in the release notes Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS --- docs/HANDOFF.md | 6 +++++- infra/local/README.md | 3 ++- infra/neon/README.md | 16 +++++++++++++--- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index ee4daae0..d1db205b 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -65,6 +65,10 @@ lifecycle pack. Work is paused for a handoff, not a production release. (`apps/web/src/data/covers.ts`), on a calm tone for the commune type while they load or if they fail; bundling them needs the three original files from Edem. Production builds preload the data client chunk (`vite.config.ts`). +- Also from 9 October, for review: completing, skipping, handing over and + undoing a task now lock the commune first (migration 0037), and the + invitation email sender does the same, so either meeting a member leaving + waits for it instead of failing as a deadlock. - The website is `web/` (the editorial homepage); the earlier v2 site is in `web-backup/`. Launch names `website` (3004) and `website-backup` (3003). - No push, deployment, hosted migration or provider change has been made. Do @@ -178,7 +182,7 @@ no further copy-cleanup pass is needed for the handoff. Read [the product-depth roadmap](PRODUCT-DEPTH-ROADMAP-2026-10-02.md) and the Remaining section of [money lifecycle](MONEY-LIFECYCLE-2026-10-02.md). -1. **Hosted parity.** Migrations 0013 to 0036, schema-cache refresh, the +1. **Hosted parity.** Migrations 0013 to 0037, schema-cache refresh, the matching web client and sender, auth, invitations and real email delivery are all unverified on Neon. Apply migrations before deploying the client. Measure cold/warm/idle loading with separate accounts. Do not declare diff --git a/infra/local/README.md b/infra/local/README.md index 1589f003..5267158d 100644 --- a/infra/local/README.md +++ b/infra/local/README.md @@ -41,7 +41,8 @@ files in order using a database owner connection: 35. `infra/neon/migrations/0034_database_hardening.sql` 36. `infra/neon/migrations/0035_currency_rule.sql` 37. `infra/neon/migrations/0036_lighter_loading.sql` -38. `infra/local/after-core.sql` +38. `infra/neon/migrations/0037_task_command_lock_order.sql` +39. `infra/local/after-core.sql` `after-core.sql` is idempotent. Re-run it after applying a newer migration to an existing local database so the local-only reminder helpers stay current. diff --git a/infra/neon/README.md b/infra/neon/README.md index d2be9948..15f566bb 100644 --- a/infra/neon/README.md +++ b/infra/neon/README.md @@ -14,8 +14,8 @@ this is not a replacement of Neon Auth or a switch back to Supabase. - [Current design language and preserved layouts](../../docs/DESIGN-LANGUAGE.md) - [Local build-out from 5 October: migrations 0020 onwards](../../docs/LOCAL-BUILDOUT-2026-10-05.md) -Migrations **0015 to 0036 are local only**. They have not been applied to any -Neon branch. Release order when they are: apply 0013 to 0036 in numeric order +Migrations **0015 to 0037 are local only**. They have not been applied to any +Neon branch. Release order when they are: apply 0013 to 0037 in numeric order to the preview branch, refresh that branch's Data API schema cache, then deploy the matching web client. The new client calls `send_reminder_command`, `record_share_receipt`, `reverse_share_receipt`, `correct_expense`, @@ -89,6 +89,16 @@ Six of the 9 October migrations change that rule: `set_recurring_bill_archived`; production has no recurring bills yet, so only the new client uses them. +0037 makes completing, skipping, reopening, handing over and undoing a task +lock the commune before the task, as edits and removals have since 0027, so +one of them meeting a member's departure waits for it instead of failing as a +deadlock. It needs no client or worker change. Separately, the invitation +email sender in `apps/reminder-worker` now locks the invitation's commune +before the invitation, so emailing an invitation while its admin leaves, is +removed or is made a member waits and is then refused as unavailable instead +of failing as a deadlock. That is a worker change only, released with the +worker deploys above. + ## Last recorded production evidence — 27 September 2026 The app is served at `https://app.ourcommune.io` from `main`. The latest @@ -252,7 +262,7 @@ rate limits) are recorded in [`docs/AUTH-SETTINGS.md`](../../docs/AUTH-SETTINGS. Broad grants would let direct writes bypass ledger invariants. Keep preview branches separate from production. 3. Apply `migrations/0001_commune_core.sql` through - `migrations/0036_lighter_loading.sql` in numeric order to the + `migrations/0037_task_command_lock_order.sql` in numeric order to the development branch via an **unpooled** connection. Run `tests/grants.sql`, review RLS, and run two-account signed-JWT tests before applying the same reviewed migrations to production. After a migration adds or changes a