Publish archive/restore lifecycle notifications only after the proposal commits (#2934) - #3016
Conversation
CardService.SetArchivedAsync published its board realtime event right after SaveChangesAsync, which is correct for a standalone API archive/restore but premature on the proposal apply lane: that SaveChanges is still inside AutomationExecutorService's outer transaction, so a later operation could fail and roll the change back after subscribers had already been told it happened. Adds a bounded transaction bridge - DeferredBoardRealtimeNotifier, a buffering IBoardRealtimeNotifier that the executor owns. OperationHandlerRegistry hands it to SetArchivedAsync as the notification sink on the proposal lane only; the executor flushes it immediately after CommitTransactionAsync and discards it in a finally, which drains every non-commit exit (rollback returns, guard refusals, the unexpected-error catch, cancellation). Flush is best-effort and logged: the write is already durable, so a failing channel must not fail an applied proposal. Direct API archive/restore passes no sink and notifies immediately, unchanged. Detached-child "updated" events raised by the same archive ride the same bridge. Create/update/move on the proposal lane share the underlying pattern but would each need their own sink parameter and call site, which is not the one-line reuse the issue scoped in; left as-is and noted. Refs #2934
ProposalLifecycleNotificationTests drives the real CardService and the real AutomationExecutorService over a mocked unit of work whose commit/rollback callbacks flip a flag, and records every published event together with that flag at publish time - so the assertion is the ordering itself, not merely that an event eventually appeared: - archive-lifecycle followed by an operation the registry refuses: transaction rolls back and nothing is published at all; - a successful archive-lifecycle and a successful restore-lifecycle: exactly one event each, carrying board/card ids and the archived/restored operation, with the transaction already committed at publish time; - a direct CardService.SetArchivedAsync in both directions: published immediately, no transaction opened, unchanged from today. DeferredBoardRealtimeNotifierTests covers the bridge on its own: staging holds events back, flush publishes in staging order, discard drops them, and a flush never republishes - neither after a successful flush nor after a downstream channel throws mid-batch. Verified non-vacuous: with the sink wiring reverted to null, the three proposal lane cases fail (two on CommittedAtPublishTime, one on the rolled back event being published) and the two direct-API cases still pass. Refs #2934
…on vocabulary One sentence on the archive-lifecycle / restore-lifecycle bullet: the apply lane stages its realtime event in the executor's deferred buffer and publishes it only after commit, while the direct API lane still notifies immediately. Refs #2934
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 49c9a582f2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Two MEDIUM findings from the fresh-context review of this branch, both in the lines this PR introduced. 1. A committed archive could end up publishing nothing. The flush ran after NotifyAssignmentsCommittedAsync and took the caller's cancellation token, so an aborted request (or a transient throw in that call) after the commit reached the finally and discarded events describing an already-durable write. The flush is now the first statement after CommitTransactionAsync and uses CancellationToken.None: the write happened, so telling subscribers about it is not the caller's to cancel. 2. The 6-argument constructor silently disabled the lane instead of falling back. It delegates with realtimeNotifier: null, which wrapped NoOpBoardRealtimeNotifier and still handed that buffer to the handler as the sink - so lifecycle events were staged into a black hole for every short-ctor caller (McpToolsTests builds the executor that way). The buffer is now null when no notifier is supplied, and a null buffer means the handler passes no sink, so CardService notifies through its own notifier exactly as it did before this bridge existed. Tests added for both, plus the detached-children path the docs already claim and a non-vacuity guard on the rollback case: - archive with one child: two events, both after the commit, in staging order (card.archived then the child's card.updated); - executor built without a notifier: the lifecycle event is still published; - the rollback case now also asserts the archive really applied and was saved before the failure, so an empty publish list cannot pass vacuously. Declined as non-blocking (tracked in the PR thread): per-event isolation inside FlushAsync. A throw on event n abandons n+1..N, but CompositeBoardRealtimeNotifier already swallows per channel, so it is unreachable with the production wiring and changing it would alter the contract the bridge's own tests pin. Refs #2934
Fresh-context adversarial review — findings and triageOne independent read-only review pass over this branch's diff and its seams (DI registration, the Fixed (MEDIUM)1 — a committed archive could publish nothing at all. The flush ran after 2 — the 6-argument constructor silently disabled the lane instead of falling back. It delegates Production DI was not affected either way: Fixed (LOW)3 — vacuous-pass risk in the rollback test. 4 — the detached-children deferral was changed but untested, while the autodoc sentence claims Declined as non-blocking (tracked here, not fixed)5 — Out of scope, restated: create / update / move on the proposal lane share the same premature Verification on the fix head
|
|
Coordinator's fresh-context independent review (round 1 for the merge gate) at exact head Refuted from source (read-only):
Fix round (law 2a judgment: cheap, and each protects the committed-then-marked-failed path):
Tracked, not fixed (law 2c):
|
Round-2 review findings from the coordinator. Buffer lifetime. The buffer was built once in the constructor of a Scoped service and shared for its whole lifetime, so "one sequential execution" held only because BatchProposalExecutionService happens to iterate with a foreach - an invariant this type cannot see and does not own. The executor now keeps the raw IBoardRealtimeNotifier and creates a DeferredBoardRealtimeNotifier as a local inside ExecuteProposalWithReceiptCoreAsync, passing it down through OperationHandlerRegistry.ExecuteOperationAsync per call. Two concurrent executions on one scope can no longer append to the same list. The registry loses its buffer field and ctor parameter; DeferredBoardRealtimeNotifier is unchanged. Flush guard. The catch inside FlushDeferredNotificationsAsync was the only thing stopping a throwing notifier from unwinding into the outer catch, rolling back an already-committed transaction and flipping an Applied proposal to Failed - a committed board change reported as a failure. That guard had no test. Added one: an executor wired to a notifier that always throws still returns success, still commits, never rolls back, and leaves the proposal Applied. Verified non-vacuous by replacing the catch body with a rethrow, which fails the new test on exactly that assertion. The flush log now binds the exception and names the proposal. Unlike the sanitized operation-failure logs in this file, this one comes from a realtime or webhook channel rather than persistence and never reaches the caller's result - CompositeBoardRealtimeNotifier already logs its channel exceptions the same way - and without it a dropped notification is undiagnosable. Webhook durability is documented rather than fixed. A lifecycle event's outbound webhook delivery rows are now written by the post-commit flush, outside any transaction, so a host crash between the commit and the flush loses them with no retry, where before they rolled back with the mutation. One clause added to the autodoc sentence; #3024 tracks the outbox that would close it. Reverting the ordering is not the fix - that is the bug this PR exists to remove. Refs #2934
Round 2 (final fix round) — coordinator reviewFix commit Fixed — buffer lifetime (MEDIUM)
The executor now keeps the raw Fixed — the load-bearing guard now has a test (MEDIUM)The
Verified non-vacuous: replacing the catch body with Fixed — log detail (LOW)The flush log now binds the exception and names the proposal. This file otherwise logs without Documented + tracked, not fixed — webhook durability (MEDIUM)A lifecycle event's outbound webhook delivery rows are now written by the post-commit flush, outside One clause added to the Remaining LOWs — recorded, not fixed
Checks on
|
| Check | Result |
|---|---|
dotnet build backend/Taskdeck.sln -c Release |
succeeded, 0 errors |
Application: ProposalLifecycleNotificationTests + DeferredBoardRealtimeNotifierTests |
13 passed, 0 failed |
Application: the above + AutomationExecutorService + OperationHandlerRegistry + ExecutionAuditRecorder classes |
78 passed, 0 failed |
Api: AutomationProposalsApiTests + CardAssignmentApiTests + McpToolsTests + ArchivedBoardProposalDecisionConcurrencyTests |
144 passed, 0 failed |
node scripts/check-docs-governance.mjs |
passed |
node scripts/check-doc-links.mjs |
passed (695 files, 0 broken links) |
dotnet test backend/Taskdeck.sln -c Release -m:1 |
running — result posted below when it lands |
CardAssignmentApiTests and McpToolsTests are in that Api filter on purpose: they are the two
call sites that construct OperationHandlerRegistry / the short executor constructor and would catch
the signature change or a silently-disabled bridge.
Required backend gate — green on
|
| Project | Result |
|---|---|
Taskdeck.Domain.Tests |
1676 passed, 0 failed, 0 skipped |
Taskdeck.Application.Tests |
4376 passed, 0 failed, 0 skipped |
Taskdeck.Api.Tests |
3174 passed, 0 failed, 4 skipped |
Taskdeck.Cli.Tests |
243 passed, 0 failed, 0 skipped |
Taskdeck.Architecture.Tests |
28 passed, 0 failed, 1 skipped |
Taskdeck.Integration.Tests |
7 passed, 0 failed, 29 skipped (Docker-gated) |
That closes the one "NOT verified" item that mattered in the PR body: every backend project now has
a local green on this exact head, layer purity included. The skips are the pre-existing
Docker-gated integration cases and one roadmap-invariant test, not anything this PR touches.
CI on the same head: 0 failed, remaining checks still running at the time of writing.
Red check on this head:
|
| Started | Result |
|---|---|
| 19:44:34Z | success |
| 19:46:09Z | success |
| 20:08:30Z | failure (planner-error) |
Identical tree, identical SHA, two greens then a red. The 20:08 run was triggered by a
pull_request edited event — I was updating this PR's body with the solution-gate results at
that moment — and the planner fetched the PR merge ref while GitHub was recomputing it, so the merge
SHA and tree SHA it read came from different states of refs/pull/3016/merge. The failure is in the
planner's own input, before it evaluates anything about the diff. The same check was green on both
earlier heads of this branch (49c9a582f2, b652faed7).
Standing of the check. Per CLAUDE.md: "ci-required.yml is the required CI gate; CI Extended
and the Smart CI shadow lane are advisory." The job's own output says shadow — would FAIL in enforce mode, i.e. it is reporting what enforce mode would do, not blocking.
Everything else on this head: 17 success, 11 skipped, 1 failure (the above), 1 still running
(API Integration (windows-latest)). No required backend or frontend check has failed.
Not fixed here. The CI region is R4-class under .claude/rules/ci-control.md and outside this
PR's scope, so I have not touched it. But the underlying fragility is real and reproducible in
shape: editing a PR's body can red a shadow gate that is green on the same commit, because the
planner re-runs on edited and races the merge-ref recomputation. Worth an issue against the Smart
CI planner — retry the merge-ref fetch, or read merge and tree SHA from one atomic ref read — but I
am leaving that call to the coordinator rather than filing into a region I do not own. A re-run of
the job on this head should also simply pass, as it did twice already.
|
Round 2 (fix round) review at head Scoped fresh-context verification of the fix:
Coordinator's merge resolution (commit Triage (law 2c): webhook durability trade tracked as #3024 with the autodoc clause; create/update/move/delete still notifying pre-commit on the proposal lane is follow-up material (no issue yet); the earlier |
What
CardService.SetArchivedAsyncpublished itscard.archived/card.restoredrealtime eventimmediately after its own
SaveChangesAsync. On the proposal apply lane thatSaveChangesAsyncisstill inside
AutomationExecutorService's outer transaction, so a later operation could fail, theexecutor rolled the archive back, and SignalR subscribers had already been told about a transition
that never happened.
This adds a bounded transaction bridge and routes the lifecycle lane through it.
DeferredBoardRealtimeNotifier(new,Taskdeck.Application/Services/) — anIBoardRealtimeNotifierthat buffers instead of publishing.FlushAsyncpublishes the batch instaging order and empties the buffer before the first publish, so a downstream failure can never
republish it;
Discarddrops it.OperationHandlerRegistryreceives that buffer as a per-call parameter onExecuteOperationAsyncand hands it toSetArchivedAsyncasnotificationSink— the proposal laneonly. It sits next to the
recordLifecycleAudit: falseargument from Align card lifecycle proposal side effects and audit receipts #3001, which is untouched.CardService.SetArchivedAsyncgains one optional trailing-ish parameter,IBoardRealtimeNotifier? notificationSink = null. Null (every direct API call) means notifyimmediately, exactly as today. The
card.updatedevents for children detached by an archive ridethe same sink.
AutomationExecutorServicecreates one buffer per execution (a local, never a field — thisservice is Scoped), flushes it as the first statement after
CommitTransactionAsync, and discardsit in a
finally— one drain point covering every non-commit exit (the rollback returns, the twoguard refusals, the unexpected-error catch, cancellation). The flush takes no cancellation token
and is best-effort and logged: the board write is already durable, so neither an abandoned request
nor a dead notification channel may turn an applied proposal into a reported failure.
Why this shape
The issue asked for a bounded bridge, not a notification redesign. The buffer holds one list, knows
nothing about transactions, and belongs to exactly one execution: it is created as a local inside
ExecuteProposalWithReceiptCoreAsyncand passed down per call, so nothing depends on how any callersequences its executions. It mirrors the
NotifyAssignmentsCommittedAsyncpattern already in theexecutor: stage inside the transaction, publish after the commit.
Not fixed here, by design: create / update / move on the proposal lane share the same premature
publish. Routing them through this bridge is not the one-line reuse the issue scoped in — each needs
its own sink parameter and call site on
CreateCardAsync/UpdateCardAsync/MoveCardAsync, plustheir own tests. Left as-is and noted, per the issue's explicit instruction.
Checks run
ProposalLifecycleNotificationTests+DeferredBoardRealtimeNotifierTestsAutomationExecutorService+OperationHandlerRegistry+ExecutionAuditRecorderclassesAutomationProposalsApiTests+CardAssignmentApiTests+McpToolsTests+ArchivedBoardProposalDecisionConcurrencyTestsdotnet build backend/Taskdeck.sln -c Releasedotnet test backend/Taskdeck.sln -c Release -m:1(required backend check)449037027, exit 0, complete run. Domain 1676 · Application 4376 · Api 3174 (4 skipped) · Cli 243 · Architecture 28 (1 skipped) · Integration 7 (29 Docker-gated skips) — 0 failed anywhere. Detail in the gate comment on this PR.node scripts/check-docs-governance.mjsnode scripts/check-doc-links.mjsNon-vacuity checks (both probes reverted and re-run green afterwards):
null→ the three proposal-lane ordering cases fail (two onCommittedAtPublishTime, one on the rolled-back event being published); the two direct-API casesstill pass.
catchbody replaced withthrow;→ the channel-failure case fails on exactly its ownassertion, "Expected result.IsSuccess to be True because the board change committed; a dead
notification channel is not a failure, but found False."
Tests added
ProposalLifecycleNotificationTestsdrives the realCardServiceand the realAutomationExecutorServiceover a mocked unit of work whose commit/rollback callbacks flip a flag,and records each published event together with that flag at publish time — so the assertion is the
ordering, not merely that an event eventually appeared.
(and the archive provably applied first, so the empty list cannot pass vacuously);
correct board/card ids and operation name, and
CommittedAtPublishTime == true;card.archivedthen thechild's
card.updated);service's own notifier;
back, proposal stays
Applied;SetArchivedAsyncboth directions → published immediately, no transaction opened.DeferredBoardRealtimeNotifierTestscovers the bridge alone: staging, flush ordering, discard, norepublish after a flush, and no republish after a downstream channel throws mid-batch.
NOT verified
useBoardRealtimeconsumer were readbut not exercised. The frontend behaviour is unchanged either way — it debounces a refetch and
ignores operation semantics, which is why the issue rated this MEDIUM with no persisted corruption.
outer transaction commits rather than inside it.
OutboundWebhookService.EnqueueBoardMutationAsyncdoes its own
SaveChangesAsync, so they persist — but that new ordering was verified by readingthe code, not by a webhook integration test, and a host crash between the commit and the flush
loses them with no retry. Documented on the autodoc sentence and tracked in Outbox for post-commit webhook deliveries (durability gap opened by #2934) #3024.
IBoardRealtimeNotifiernow resolved intoAutomationExecutorServiceby DI was verified bybuild and tests, not by starting the API host, and not by asserting which of the two public
constructors the container selects. That selection is non-load-bearing by construction: when no
notifier is supplied no buffer is created and the card service notifies directly, as before.
actually drives two concurrent executions on one scope (the shared
DbContextforbids it anyway).Taskdeck.Integration.Testscases (29) stayed skipped locally, as they always do on this box.not verified as safe.
Docs
One sentence added to
autodoc/interfaces/proposal-operation-vocabulary.mdon the archive-lifecyclebullet. No
docs/STATUS.mdor masterplan change: this is an ordering bug fix, not a change inshipped capability or sequencing.
Closes #2934