fix(relay): fire kick live side effects at convergence; persist target; fence re-add race with held lock - #7298
Conversation
76c9646 to
a08a2ce
Compare
🔐 Codex Security Review
|
…t, fence re-add race Three changes closing the live-effects gap on the admin kick path: 1. Convergence placement: move apply_kick_live_side_effects from inside the is_none() mutation block to a shared convergence point after it. The fresh HTTP path (Committed), concurrent-driver path (AlreadyCommitted → reload), and crash-recovery path all pass through the convergence point. 2. Persist enforcement context: add enforcement_target_pubkey / enforcement_channel_id to relay_admin_actions (migration 0045). These columns are written once at claim time and read at recovery, so recovery no longer re-derives the kick target from mutable report/event rows that may have been purged. Missing persisted context at convergence is now an invariant error (not warn+skip). AdminActionRecord gains both fields; claim_report and row_to_action updated. Recovery worker reads persisted fields and falls back to report re-derivation only for pre-migration rows where both columns are NULL. 3. Re-add race fence: add verify_member_still_removed in buzz-db, which acquires the same pg_advisory_xact_lock as add_member and checks removed_at IS NOT NULL. apply_kick_live_side_effects accepts an is_recovery flag; on the recovery path eviction and workflow-disable are gated on this check. Cache invalidation remains unconditional (stale-positive is always safe to drop). Tests: crash-recovery seam test moved from report_resolution.rs generic mod tests to api/admin/mod.rs postgres_tests (discoverable by check-postgres-test-discovery.py), rewritten to use shared e2e helpers, extended to assert workflow disable, and the re-add race test added. Purge-test seeding/assertions extended to cover all three live side effects (not just finalize state). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
a08a2ce to
ba34fb2
Compare
…-context to kick only Finding 1 — Lock not held through effects: Replace verify_member_still_removed (which acquired + released the advisory lock before returning) with membership_removal_fence, a RAII guard that keeps the advisory transaction lock alive until the caller drops it. Update apply_kick_live_side_effects to use the fence on every path (fresh and recovery), holding it through eviction and workflow-disable so no concurrent add_member can commit between the removed-at check and the destructive effects. Remove the is_recovery parameter — the fence is the single correct path. Add membership_removal_fence + MembershipRemovalFence to buzz-db and expose them via Db::membership_removal_fence. Update race test to use real add_member (not direct SQL), falsifying the lock serialization guarantee. Add membership_removal_fence_blocks_concurrent_add_member test demonstrating add_member blocks while the fence is held and completes once it is released. Finding 2 — Persisted-context branch breaks stranded delete recovery: Gate the persisted-context path in admin_action_worker.rs on action == "kick" only. Non-kick actions (ban, timeout, delete) always re-derive from the report so delete recovery still has the required target_event_id. The erroneous branch was forcing target_event_id = None for any action with enforcement_target_pubkey set, causing pre-marker delete recovery to fail with "delete requires target_event_id" and post-marker recovery to skip the tombstone outbox row. Add stranded_delete_pre_marker_recovers_via_worker test pinning the regression: a stranded delete (crash before mutation) must converge via recover_one to succeeded with the event soft-deleted and tombstone + reporter_notice rows. Finding 3 — False compatibility comment: Replace the misleading "pre-migration rows re-derive to save legacy kicks" comment with an accurate description: pre-migration kick rows cannot finalize (convergence requires rec.enforcement_*); the re-derive path is correct only for non-kick actions. CI: Rust Lint and Windows Rust failures are main-inherited (same failures on PR #7291 merged to main as b4cc53a); local clippy is clean. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…s to schema.sql Fix three test-only lint errors: prefix let tenant → let _tenant at lines 6324, 8000, 8258 (added in ba34fb2, not carried forward by 1d0db42). Add enforcement_target_pubkey and enforcement_channel_id to relay_admin_actions in schema/schema.sql. The postgres-test framework builds the per-test DB from schema.sql via pgschema, not from SQLx migrations. Migration 0045 adds these columns, but the desired-state schema was not updated, causing all relay_admin_actions tests to fail with 42703 "column does not exist". Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…n parity test The admin_schema_parity_between_desired_state_and_migrations test was running migrations only to version 39, while schema.sql (the desired-state source) now includes the enforcement_target_pubkey and enforcement_channel_id columns added by migration 0045. Advance run_to(39) to run_to(45) so the migrated probe DB matches the desired-state probe DB column for column. The three kick-recovery tests (crash_recovery_redrive_fires_kick_live_side_effects, crash_recovery_after_readd_preserves_membership_subscriptions_and_workflows, and worker_redrive_of_event_kick_converges_after_event_purged_mid_flight) all call create_workflow for the kicked user. The desired-state DB enforces the workflows FK (community_id, owner_pubkey) → users (community_id, pubkey), but none of the test setup paths creates a users row for the workflow owner. Seed the row with ON CONFLICT DO NOTHING before each create_workflow call. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
… regression The workflow-disable UPDATE (SEC-006) previously called disable_workflows_for_owner_in_channel(&pool, ...) inside apply_kick_live_side_effects, acquiring a second pool connection while the MembershipRemovalFence already held one. With a pool of size N, N concurrent kicks self-deadlock: each kick waits for a connection its own fence is holding. The 3 s sqlx acquire timeout silently skips the durable revocation — the per-fire authority gate remains, but the disable is lost. Fix: add disable_workflows_for_owner_in_channel_on_conn(&mut PgConnection) to buzz-db/store/workflow.rs and MembershipRemovalFence::commit_disabling_workflows to channel_members.rs. The fence's commit path runs the UPDATE on its own transaction connection, then commits — no second pool connection needed. The advisory lock is not released until after the disable is durable. The inline remove paths (kind 9001 / kind 9022 via handle_group_remove_member and handle_group_leave) do not hold a fence, so they continue using disable_departed_member_workflows with a pool connection; this function is restored with a clarifying doc comment distinguishing the two paths. Regression test pool_size_1_fence_commit_disabling_workflows_completes_without_deadlock uses a max_connections=1 pool to make the old two-connection path deterministically deadlock, and asserts the workflow row is durably disabled after the fix. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
* origin/main: refactor(relay): extract NIP-29 membership authorization (#7285) chore(release): release Buzz Desktop version 0.5.22 (#7308) feat(desktop): preserve mentions across copy and paste (#7228) test(desktop): await Bestie drag and profile hover endpoints (#7294) Collapse contiguous join messages (#7262) Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
…riptions The prior docstring for apply_kick_live_side_effects said eviction "closes every open channel subscription," which implied cluster-wide coverage. The helper actually iterates conn_manager on the local pod only; a kicked user connected to a different pod keeps their inert subscription state. Rescope the doc to match the real behavior: - eviction = local-pod connections only - cluster-wide correctness = cross-pod CacheInvalidation::Membership (invalidate_membership publishes to all pods) + filter_fanout_by_access re-checks is_member_cached before every delivery, blocking the removed user on every pod regardless of which pod they are connected to - note that the same pod-local primitive serves remove/leave; cross-pod CLOSED frame delivery is a known future improvement No code changes — doc/behavior consistency fix per review comment. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Changes requested
Reviewed head 3c428b59537a276ddeed4a2f73ab897f637f54b3 against base 5079c770fe30bb3d8204822ce6c2431eacac6d4b. Source-only review on Blox, with existing exact-head CI evidence; no checkout, build, test execution, or CI rerun.
P2: Use valid channel-bearing event reports in both legacy recovery regressions
The new pre/post-marker tests call e2e_report_pubkey (pre-marker fixture, post-marker fixture). That helper inserts a pubkey report without channel_id. Each test then clears both persisted enforcement fields to simulate an old-writer row, discarding the channel supplied directly to claim_report.
The real recovery worker must re-derive the channel from the report, so it receives None. Before the marker, the kick mutation fails its required-channel check; after the marker, the convergence gate rejects the unresolved context and leaves the action enforcing. Production HTTP rejects kicks on pubkey reports, so these fixtures do not model valid legacy kicks.
This is confirmed by the exact-head PostgreSQL job: legacy_kick_row_pre_marker_recovers_via_worker fails at line 9271 with actual failed; legacy_kick_row_post_marker_recovers_via_worker fails at line 9527 with actual enforcing; both expect succeeded. These newly introduced failures break the PostgreSQL lane and leave the upgrade contract without the intended regression coverage.
Required fix: construct both fixtures from valid event reports, with a stored event author and channel_id on the report, then clear the persisted enforcement pair. Preserve the assertions covering pre/post-marker convergence, cache/subscription eviction, workflow disable, report/action state, and outbox effects. Both corrected regressions should pass in the existing PostgreSQL CI lane; do not relax their success assertions or make production accept invalid pubkey kicks to satisfy them.
Prior feedback and scope
The earlier production NULL/NULL-context defect is addressed for derivable legacy rows: the worker re-derives the context and the convergence gate now accepts that fallback. The failures above establish broken fixtures, not that the production fallback is absent. The moved live-effects test also passes in this CI job. The held re-add fence, same-connection workflow disable, and kick-only recovery branch preserving delete targets have no additional blocker in the reviewed paths.
Cross-pod immediate subscription eviction is not established by this change; that retained limitation and associated wording are non-blocking here. The separate replica-fence/desktop failures and DCO status are not attributed to this patch or added to this finding’s exit criteria.
3c428b5 to
2f5276d
Compare
* origin/main: (75 commits) fix(workflows): make deletion persistent and retryable (#7735) fix(mobile): preserve thread replies through refresh failures (#7757) fix(mobile): keep iOS message menu actions responsive after rebuilds (#7758) fix(relay): exclude ephemeral activity from message quota (#7736) release: push gateway chart 0.3.1 (#7749) fix(push): label plaintext push gateway service as HTTP (#7717) Replace personal and internal data in desktop test fixtures (#7748) Add mobile VISION (#7710) fix(mobile): keep relay sessions stable during push lease updates (#7745) fix(desktop): keep managed agent avatars usable across communities (#7732) fix(mobile): fail open when age checks are unavailable (#7714) fix(ci): don't run desktop tests for purely mobile client changes (#7709) fix(mobile): temporarily disable age gating (#7708) feat(db): expose connection setup metrics (#7286) Isolate S3 storage metrics from the relay (#7543) fix(web): route mobile invite downloads to app stores (#7702) feat(mobile): show build number with version in settings (#7697) release(mobile-infra): buzz-push-gateway 0.3.0 (#7685) Add authenticated WebSocket recovery telemetry (#7546) Instrument database pool roles (#7356) ... Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Migration 0047 adds enforcement_target_pubkey/enforcement_channel_id to relay_admin_actions without backfilling existing rows. The old claim writer left those columns NULL, so any pending/enforcing kick action that pre-dates the migration has NULL/NULL context. The convergence gate in drive_enforcement read only the persisted columns and rejected NULL rows as corrupt, causing every recovery retry to fail forever. A cancellation cannot release a post-mutation action, so these rows would remain permanently stuck. Fix: when persisted columns are absent, fall back to the function parameters (which the recovery worker already re-derives from the report row). Error only when both sources are absent (genuinely unresolvable). This preserves the invariant that a new-writer row always uses persisted context, while allowing pre-migration rows to converge through the same recovery path. Also: - Rename migration 0045 → 0047 to avoid collision with two main migrations (0045_retain_push_revocation_tombstones, 0046_storage_accounting_snapshots) that merged during branch drift. - Move kick_live_side_effects_clears_membership_cache_and_evicts_subscription from the infra-free unit test lane to the PG fixture lane; the function calls membership_removal_fence which requires a real Postgres connection. - Add two new PG upgrade-recovery tests: legacy_kick_row_pre_marker and legacy_kick_row_post_marker, seeded through the old-writer shape (NULL enforcement columns), each falsifiable against the pre-fix convergence gate. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ry channel_id Both legacy-kick recovery tests previously seeded via e2e_report_pubkey(), which inserts a pubkey report without channel_id. The recovery worker re-derives channel_id from report.report.channel_id — None for pubkey reports — so kick's 'requires channel_id' guard rejected both fixtures before the convergence gate could be exercised. Fix: seed through e2e_event_report_with_author() instead. The helper inserts a channel, adds the author as a member, inserts the target event, and creates an event report with the channel FK populated. derive_enforcement_target_pub returns Some(author) via the events JOIN. Both fixtures now produce a complete enforcement context (pubkey + channel_id) when the convergence gate falls back to function parameters — exactly what the fix needs to exercise. Also fix schema.sql:1809 comment: 'migration 0045' → 'migration 0047'. Falsifiability re-established: removing the .or(target_pubkey) and .or(channel_id) fallbacks causes both tests to fail with the action remaining in 'enforcing' state (unresolvable-target error path). Confirmed locally: GREEN at fixed head, RED with fallbacks reverted. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The branch introduced this migration as 0045, but main merged two migrations with the same prefix while the branch was open (0045_retain_push_revocation_tombstones, 0046_storage_accounting_snapshots). The merge replayed the collision. Rename to 0047 to restore uniqueness and correct ordering. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
2f5276d to
7bb0305
Compare
The DCO rebase regressed this test from run_to(47) to run_to(45). Migration 0047_relay_admin_action_target is the highest migration in this branch, so the parity test must run through 47 to validate the admin table columns and index shapes added by that migration. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Source re-review clear
Reviewed head 304d5e6e6c28bbc0b97b950a0c0c0af425f3da62 against base a61239f0d8036aff58176f5c0ce7f080c66e21b7. The P2 from review 5269373450 is resolved. No new actionable blocker found. This is a review comment, not approval.
- Fix verified: both legacy-kick regressions now use
e2e_event_report_with_author, which supplies the stored event author, report channel, and target membership needed by real recovery. Both still clear the persisted enforcement pair, exercise their distinct pre/post-marker paths throughrecover_one, and retain the action-success, cache-eviction, subscription-eviction, and workflow-disable assertions. Production was not broadened to accept invalid pubkey-report kicks. Report resolution and outbox insertion were verified in the atomic finalization source; these two tests do not directly assert those rows, and no such assertions were removed. - Delta and compatibility: accounted for the rebased history with tree comparisons. Eight of the ten PR-owned paths are unchanged since the prior review; changes are the two fixture replacements and a migration-number comment correction. Reviewed relevant main drift and the recovery/finalization contracts. The held re-add fence, legacy fallback, and kick-only worker branch retain their previously reviewed behavior. Cross-pod immediate eviction and existing missing-author limitations remain outside this fix’s exit criteria.
- Validation: PostgreSQL CI job 106449952986 ran merge
980db89f411dcd28b07490974e4e77416e96d20bof this exact head/base: both named legacy recovery regressions PASS, with 406/406 tests passing. The overall check snapshot had 52 successful, 27 skipped, and one cancelled Codex security-review check; that cancellation is not a security pass. Review source work stayed on pinned Blox, with applicable exact-base guidance and independent metadata review; no checkout, local tests, PR-code execution, or CI rerun.
…n-surface * origin/main: feat(relay): add admin HTTP routes for member restriction management (#7302) fix(relay): fire kick live side effects at convergence; persist target; fence re-add race with held lock (#7298) feat(relay): add atomic complete read-state snapshots (#7572) fix(desktop): register macOS badges for new and existing installs (#7783) fix(mobile): avoid opening empty threads on message tap (#7756) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
…ecurity * origin/main: (22 commits) feat(relay): add admin HTTP routes for member restriction management (#7302) fix(relay): fire kick live side effects at convergence; persist target; fence re-add race with held lock (#7298) feat(relay): add atomic complete read-state snapshots (#7572) fix(desktop): register macOS badges for new and existing installs (#7783) fix(mobile): avoid opening empty threads on message tap (#7756) fix(workflows): make deletion persistent and retryable (#7735) fix(mobile): preserve thread replies through refresh failures (#7757) fix(mobile): keep iOS message menu actions responsive after rebuilds (#7758) fix(relay): exclude ephemeral activity from message quota (#7736) release: push gateway chart 0.3.1 (#7749) fix(push): label plaintext push gateway service as HTTP (#7717) Replace personal and internal data in desktop test fixtures (#7748) Add mobile VISION (#7710) fix(mobile): keep relay sessions stable during push lease updates (#7745) fix(desktop): keep managed agent avatars usable across communities (#7732) fix(mobile): fail open when age checks are unavailable (#7714) fix(ci): don't run desktop tests for purely mobile client changes (#7709) fix(mobile): temporarily disable age gating (#7708) feat(db): expose connection setup metrics (#7286) Isolate S3 storage metrics from the relay (#7543) ... Signed-off-by: Alex Rosenzweig <arosenzweig@squareup.com>
…ness-overload * origin/main: (74 commits) feat(relay): add admin HTTP routes for member restriction management (#7302) fix(relay): fire kick live side effects at convergence; persist target; fence re-add race with held lock (#7298) feat(relay): add atomic complete read-state snapshots (#7572) fix(desktop): register macOS badges for new and existing installs (#7783) fix(mobile): avoid opening empty threads on message tap (#7756) fix(workflows): make deletion persistent and retryable (#7735) fix(mobile): preserve thread replies through refresh failures (#7757) fix(mobile): keep iOS message menu actions responsive after rebuilds (#7758) fix(relay): exclude ephemeral activity from message quota (#7736) release: push gateway chart 0.3.1 (#7749) fix(push): label plaintext push gateway service as HTTP (#7717) Replace personal and internal data in desktop test fixtures (#7748) Add mobile VISION (#7710) fix(mobile): keep relay sessions stable during push lease updates (#7745) fix(desktop): keep managed agent avatars usable across communities (#7732) fix(mobile): fail open when age checks are unavailable (#7714) fix(ci): don't run desktop tests for purely mobile client changes (#7709) fix(mobile): temporarily disable age gating (#7708) feat(db): expose connection setup metrics (#7286) Isolate S3 storage metrics from the relay (#7543) ... Signed-off-by: tornquist <tornquist@squareup.com>
…rcement * origin/main: (87 commits) feat(relay): add admin HTTP routes for member restriction management (#7302) fix(relay): fire kick live side effects at convergence; persist target; fence re-add race with held lock (#7298) feat(relay): add atomic complete read-state snapshots (#7572) fix(desktop): register macOS badges for new and existing installs (#7783) fix(mobile): avoid opening empty threads on message tap (#7756) fix(workflows): make deletion persistent and retryable (#7735) fix(mobile): preserve thread replies through refresh failures (#7757) fix(mobile): keep iOS message menu actions responsive after rebuilds (#7758) fix(relay): exclude ephemeral activity from message quota (#7736) release: push gateway chart 0.3.1 (#7749) fix(push): label plaintext push gateway service as HTTP (#7717) Replace personal and internal data in desktop test fixtures (#7748) Add mobile VISION (#7710) fix(mobile): keep relay sessions stable during push lease updates (#7745) fix(desktop): keep managed agent avatars usable across communities (#7732) fix(mobile): fail open when age checks are unavailable (#7714) fix(ci): don't run desktop tests for purely mobile client changes (#7709) fix(mobile): temporarily disable age gating (#7708) feat(db): expose connection setup metrics (#7286) Isolate S3 storage metrics from the relay (#7543) ... Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com> # Conflicts: # crates/buzz-auth/src/nip98.rs # crates/buzz-relay/Cargo.toml
…-history * origin/main: feat(relay): add admin HTTP routes for member restriction management (#7302) fix(relay): fire kick live side effects at convergence; persist target; fence re-add race with held lock (#7298) feat(relay): add atomic complete read-state snapshots (#7572) fix(desktop): register macOS badges for new and existing installs (#7783) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
…-dev * origin/main: fix(desktop): bound startup request bursts and recover quota refusals (#7790) fix(audit): frame hash inputs with TLV (#7492) fix(admin): allow cold storage worker DB startup (#7770) feat(relay): add admin HTTP routes for member restriction management (#7302) fix(relay): fire kick live side effects at convergence; persist target; fence re-add race with held lock (#7298) feat(relay): add atomic complete read-state snapshots (#7572) fix(desktop): register macOS badges for new and existing installs (#7783) fix(mobile): avoid opening empty threads on message tap (#7756) fix(workflows): make deletion persistent and retryable (#7735) fix(mobile): preserve thread replies through refresh failures (#7757) fix(mobile): keep iOS message menu actions responsive after rebuilds (#7758) fix(relay): exclude ephemeral activity from message quota (#7736) release: push gateway chart 0.3.1 (#7749) fix(push): label plaintext push gateway service as HTTP (#7717) Replace personal and internal data in desktop test fixtures (#7748) Add mobile VISION (#7710) fix(mobile): keep relay sessions stable during push lease updates (#7745) Signed-off-by: Tom Brow <tomb@block.xyz>
…in-gate * origin/main: (30 commits) feat(agents): humanize uncurated Databricks model ids with a label grammar (#7844) fix: route databricks claude fqns to anthropic messages (#7829) feat(relay): add opt-in newest-first thread windows (#7823) refactor: move agent Git bootstrap into ACP harness (#7819) Use worker snapshots for relay storage metrics (#7845) fix(hooks): strip repo-local git env from pre-push test lanes (#7841) fix(mobile-infra): render push grant lifetimes as decimal in chart 0.3.2 (#7820) fix(agent): preserve Databricks Opus UC reasoning and tool continuation (#7840) feat(canvas): add version history with atomic restore (#6780) chore(release): release Buzz Desktop version 0.5.24 (#7817) test(desktop): stabilize unread and audio release smoke fixtures (#7821) fix(desktop): remember Inbox unread-only choice (#7672) feat(mobile-infra): support development App Attest (#7744) docs(nip-fi): clarify federated identity amendments (#7803) fix(desktop): refresh channels after access-revoked closure (#7784) fix(desktop): bound startup request bursts and recover quota refusals (#7790) fix(audit): frame hash inputs with TLV (#7492) fix(admin): allow cold storage worker DB startup (#7770) feat(relay): add admin HTTP routes for member restriction management (#7302) fix(relay): fire kick live side effects at convergence; persist target; fence re-add race with held lock (#7298) ... Signed-off-by: coder 1 <a93f3b1decd199cec83848f116ff60c20776cdd03861b9bba2610acae7c9eeeb@buzz.block.builderlab.xyz>
What
Ensures admin kick live side effects (subscription eviction + workflow disable) fire correctly on every path — fresh HTTP, crash recovery, and upgrade recovery from pre-migration rows — without a race window where a concurrent legitimate re-add can be revoked.
Finding 1 — Lock held through effects
Replace
verify_member_still_removed(which acquired + released the advisory lock before returning) withmembership_removal_fence, a RAII guard that keeps thepg_advisory_xact_lockalive until the caller drops it.Update
apply_kick_live_side_effectsto use the fence on every path (fresh and recovery), holding it through eviction and workflow-disable. Remove theis_recoveryparameter entirely.The bug: the old code released the lock before returning a bool; a concurrent
add_membercould commit between the check and the effects, silently evicting a re-added member.New guarantee: any
add_membereither serializes before the fence (fence observesremoved_at IS NULL, skips effects) or waits until after effects complete.Finding 2 — Persisted-context path scoped to kick only
Gate the persisted-context branch in
admin_action_worker.rsonaction == "kick". Non-kick actions (ban, timeout, delete) always re-derive from the report.The bug: any action with
enforcement_target_pubkeyset took the persisted-context branch, which hard-codedtarget_event_id = None. A stranded delete either failed pre-marker recovery ("delete requires target_event_id") or finalized post-marker without the tombstone outbox row.Finding 3 — Legacy kick row upgrade recovery (Carl's CHANGES_REQUESTED)
Migration 0047 (renumbered from 0045 to avoid collision with two main merges: 0045_retain_push_revocation_tombstones and 0046_storage_accounting_snapshots) adds
enforcement_target_pubkey/enforcement_channel_idwithout backfilling.The bug: the convergence gate read only the persisted columns and rejected NULL/NULL rows as corrupt, causing every recovery retry to fail forever. A cancellation cannot release a post-mutation action, so these rows remain permanently stuck.
Fix: when persisted columns are absent, fall back to the function parameters (which the recovery worker re-derives from the report row). Error only when both sources are absent (genuinely unresolvable). This preserves new-writer safety while allowing pre-migration rows to converge.
An old writer in a rolling deployment can also create NULL rows after the DDL applies; the same fallback handles these.
Finding 4 — Test moved to PG lane
kick_live_side_effects_clears_membership_cache_and_evicts_subscriptionwas in the infra-free unit lane but calledmembership_removal_fence, which requires a real Postgres connection. Moved to the PG fixture lane (#[ignore = "requires Postgres"]) with a real member row so the fence query sees the expectedremoved_atstate.Tests
crash_recovery_redrive_fires_kick_live_side_effects(existing): re-drive via real recovery worker path, asserts all three side effects.crash_recovery_after_readd_preserves_membership_subscriptions_and_workflows(updated): uses realadd_member(not direct SQL) — re-add completes before fence is acquired.membership_removal_fence_blocks_concurrent_add_member(existing): holds the fence explicitly, spawnsadd_member, asserts it blocks until the fence is dropped.stranded_delete_pre_marker_recovers_via_worker(existing): stranded delete (crash before mutation) converges viarecover_oneto succeeded.legacy_kick_row_pre_marker_recovers_via_worker(new): old-writer shaped row (NULL columns, no step_marker) converges through the full state machine via recovery worker. Falsifiable: reverting the convergence-gate fallback leaves the action stuck in enforcing.legacy_kick_row_post_marker_recovers_via_worker(new): same but with step_marker set (post-marker crash). Falsifiable: same revert.kick_live_side_effects_clears_membership_cache_and_evicts_subscription(moved to PG lane): verifies cache eviction and subscription removal against a real member row.CI
Rust / Rust LintandWindows Rustfailures in the prior run were main-inherited (same lanes fail on main at the prior base). The branch is now at main's current head.