Skip to content

[Backend][Safety] Route bulk card writers through the archive-race guard - #2134

Merged
Chris0Jeky merged 4 commits into
mainfrom
issue-2114/bulk-writer-archive-guard
Aug 26, 2026
Merged

[Backend][Safety] Route bulk card writers through the archive-race guard#2134
Chris0Jeky merged 4 commits into
mainfrom
issue-2114/bulk-writer-archive-guard

Conversation

@Chris0Jeky

Copy link
Copy Markdown
Owner

Summary

Post-merge follow-up from #2110. External import, starter-pack apply, and archive-item restore each
checked Board.IsArchived and then wrote cards without joining the Board concurrency-token
predicate
, so an archive committing after the check was accepted silently.

Each writer now calls Board.RecordCardMutation() before its SaveChanges. That touches the board
without advancing its token, so EF issues a conditional UPDATE Boards ... WHERE ConcurrencyToken = ?
against the token read before the archived-state check. A racing archive advanced that token, the
update matches zero rows, and UnitOfWork.SaveChangesAsync maps the failure to
DomainException(ErrorCodes.Conflict)409. This is exactly the mechanism #2110 established for
single-card writes, applied verbatim — no new domain API, no migration, no schema change.

Race scenario per writer

Writer Archived check Window the check cannot cover Outcome now
ExternalImportService.ImportToBoardAsync board.IsArchived after GetByIdWithDetailsAsync adapter parse, dedupe matching, conflict/WIP planning, then a transaction conditional board update fails → Conflict, transaction rolled back, no imported card survives
StarterPackApplyService.ApplyToBoardAsync board.IsArchived after GetByIdWithDetailsAsync manifest validation, conflict detection, idempotency check, then a transaction conditional board update fails → Conflict, rollback drops the pack's labels, columns, and seed cards together
ArchiveRecoveryService.RestoreArchiveItemAsync RestorePlanner's "cannot restore to an archived board" snapshot deserialization, column resolution, WIP check, card build conditional board update fails → Conflict; because the card insert, the stale board update, and ArchiveItem.MarkAsRestored share one SaveChanges, a rejected restore also leaves the archive item still restorable rather than consumed

All three services already had a catch (DomainException) that returns Result.Failure(ex.ErrorCode, ...),
and all three controllers already route failures through ToErrorActionResult(), whose
ResultExtensions map sends ErrorCodes.Conflict to 409. Verified end to end — no controller or
mapping change was needed.

Independent writer semantics preserved

The token is deliberately not advanced by any bulk writer, matching #2110's boundary. Two writers
that read the same board still both succeed; the change closes the archive race without serializing
unrelated writes. Each writer has an explicit regression for this, including one pairing a
starter-pack apply with an external import on the same board.

Scope note

RestoreExecutor.RestoreColumnAsync takes the same guard as RestoreCardAsync. RestorePlanner's
archived-target check governs every non-board restore, so guarding only the card half would have
left the identical check-then-act window open on the same predicate, in the same method pair.

Tests added

backend/tests/Taskdeck.Api.Tests/ArchivedBoardBulkWriterConcurrencyTests.cs — 6 tests, one race and
one independence regression per writer. The race is forced deterministically, not by timing: the
archive runs to completion on a second connection from inside a callback the writer itself invokes
after its archived check and before its SaveChanges (the adapter's Parse, manifest validation,
and the destination-column read respectively). Real EF Core, real migrations, real SQLite file; the
only mocked behavior is the one translation the production UnitOfWork performs.

Mutation-checked. With the three RecordCardMutation() calls reverted to main, exactly the
three race tests fail — each reporting IsSuccess=True, the silent acceptance this issue describes —
while the three independence tests still pass, confirming they are not merely mirroring the guard.

Verification

  • dotnet test backend/tests/Taskdeck.Application.Tests/Taskdeck.Application.Tests.csproj -c Release -m:13875 passed, 0 failed
  • dotnet test backend/tests/Taskdeck.Api.Tests/Taskdeck.Api.Tests.csproj -c Release -m:12479 passed, 0 failed, 4 skipped (6 m 47 s)
  • Mutation check: guard reverted → 3 race tests fail, 3 independence tests pass; guard restored → 6/6 pass
  • node scripts/check-docs-governance.mjs — passed
  • git diff --check — clean

Not run: frontend, E2E, and Infrastructure/Domain/Architecture suites — the change is Application-layer
only, adds no dependency, and touches no schema or migration.

Documentation

ADR-0063 described the non-advancing conditional board update as a CardService property. Amended so
the decision record states the bulk writers now share it, rather than leaving a reader to conclude
those paths are still a bare IsArchived check. docs/STATUS.md and the masterplan are unchanged:
shipped reality does not move until this merges.

Tracking

Closes #2114

Refs #2110.

External import, starter-pack apply, and archive-item restore all checked
Board.IsArchived and then wrote cards without joining the Board concurrency-token
predicate introduced in #2110, so an archive committing after the check was
silently accepted.

Each writer now calls Board.RecordCardMutation() before its SaveChanges, which
touches the board without advancing its token. EF issues a conditional UPDATE
against the token read before the archived-state check, so a racing archive
turns the write into DomainException(Conflict) -> ErrorCodes.Conflict -> 409
instead of seeding an archived board. The token is deliberately not advanced, so
independent writers on the same board still do not invalidate each other.
One race test and one independence test per writer. The race is forced without
timing: the archive runs to completion on a second connection from inside a
callback the writer itself invokes after its archived-state check and before its
SaveChanges - the adapter's Parse for import, manifest validation for the starter
pack, and the destination-column read for restore.

Mutation-checked: with the three RecordCardMutation() calls reverted, exactly the
three race tests fail, each reporting IsSuccess=True - the silent acceptance this
issue describes - while the three independence tests still pass, so they are not
merely mirroring the guard.

The restore tests seed a real user because AuditLog.UserId is a genuine foreign
key; an invented actor id fails the insert on a constraint and would mask the
concurrency outcome.
ADR-0063 described the non-advancing conditional board update as a CardService
property. The three bulk card writers now share it, so the decision record says
so rather than leaving a future reader to conclude those paths are still a bare
IsArchived check.
@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Review gate record: one fresh-context adversarial review completed at head f19fb1c — zero CRITICAL, zero HIGH. Verified clean: tracked-read dependency at all four guard sites (GetByIdWithDetailsAsync has no AsNoTracking, no QueryTrackingBehavior override, and the restore planner/executor share one identity-map instance); single-SaveChanges atomicity per writer (rejected restore stays RestoreStatus.Available); an exhaustive card-writer census found no missed writer (BoardJsonExportImportService always constructs a new Board; proposal ops route through the guarded CardService); independence semantics genuinely covered (TouchAndAdvance would fail the 3 independence tests); 409 mapping verified per entry point with the concurrency catch ordered before the transient-retry arm.

Dispositions of the three non-blocking observations:

  1. No-op import still touches the board (perturbs recency ordering, can 409 a nothing-import) — DECLINED as recorded: cleanly rolled back, retryable, and arguably the correct conservative reading of 'an import touched this board'. Revisit only if it surfaces in dogfooding.
  2. [Backend][UX] Single concurrency token conflates board rename with archival: misleading 409s and non-idempotent archive #2126 token-conflation blast radius grows from one card write to a whole batch — recorded on [Backend][UX] Single concurrency token conflates board rename with archival: misleading 409s and non-idempotent archive #2126.
  3. ADR wording now states the RestoreColumnAsync scope call in the amendment itself (dcd4475). The dead 'options' parameter in CreateRestoreServiceAsync is declined as a test-harness nit.

The fix push is docs-only (ADR text), so no fresh review round is owed; CI re-proves at the new head.

@Chris0Jeky
Chris0Jeky merged commit 77252b4 into main Aug 26, 2026
32 checks passed
@github-project-automation github-project-automation Bot moved this from Pending to Done in Taskdeck Execution Aug 26, 2026
Chris0Jeky added a commit that referenced this pull request Aug 26, 2026
… comments and ADR line, add the Activity selector to the recency consequence, right-size a test name
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

[Backend][Safety] Extend archived-board race guard to bulk card writers

1 participant