Fans the frozen #253 optimistic-concurrency recipe (api-conventions §7a, established by PR1/#263 on Block) across the RR + Reconcile aggregates. Design spec: #253#issuecomment-8472 §6 PR2.
What shipped
Four aggregates wired end-to-end (ETag on the items GET, If-Match on the replace PUT → 412 on mismatch, unconditional Version++, SaveChangesWithConcurrencyGuard backstop, SPA editor ETag round-trip + 412 conflict dialog):
#
Aggregate
Mechanism
Sibling config-writers bumped
3
Template
RR delete-and-reinsert
none
4
DecoTemplate
RR (post-commit playout-Reset enqueue preserved, on CancellationToken.None)
Handler recipe per §7a: concurrency check is a standalone Either after validation (ToEither(validation).Bind(x => x.CheckVersion(request.ExpectedVersion))) so a PreconditionFailedError survives to 412 (never flattened to 422 by Apply/Join()); unconditionalroot.Version++ (a no-op save still bumps + rotates ETags); save through the guard (racing writer → 412).
Controllers: GET-items emits the strong ETag; PUT parses If-Match (malformed/weak/list → 400), threads ExpectedVersion, and returns the fresh post-bump ETag on success. Each PUT keeps its own existing 200 body shape.
schedule save-failure propagation: a 412 from the guard returns before the post-save read-side reload + playout-Refresh enqueue — a failed save runs no side effects.
SPA (web/src/api/* + four editor screens): getXItemsWithMeta load reads items-with-meta first (ETag ≤ data, fail-safe), save round-trips the ETag, 412 → blocking "changed elsewhere — reload" ConfirmDialog. SchedulesScreen threads the ETag through the existing #242 dirty-guard (itemSeq stale-guard, loadItems reload on conflict), not layered on top.
Notes
No migration — PR1's AddAggregateVersions already added all 9 Version columns.
Schedule version in body: ScheduleController returns ProgramScheduleViewModel directly (no ProgramScheduleResponseModel exists), so version also surfaces in GET /api/schedules[/{id}] bodies. Intentional/harmless (ETag stays the authority); a ResponseModel purely to hide one field was judged disproportionate. Documented in §7a.
OpenAPI + v1.d.ts regenerated once post-merge; drift is exactly 4×(400+412) + If-Match/412 descriptions + the one schedule version field.
Verification
dotnet test ErsatzTV.sln — green (ErsatzTV.Tests 1334, Core 493, Scanner 1467, Infrastructure 53, Architecture 5, FFmpeg 20; 0 failed). New non-vacuous concurrency suites per aggregate (stale→412 no-mutation, match/absent→success+bump, no-op still bumps, two-context racing save→412).
web: typecheck / build / lint clean; vitest 667/77 (incl. a 412-conflict-dialog test per editor); check:api no drift.
Adversarial review + live E2E pending (in progress).
Fans the frozen ETag/If-Match/412 recipe (Block reference, #253) onto the
Playlist aggregate:
- ReplacePlaylistItems command carries ExpectedVersion; the handler runs
CheckVersion as a standalone Either after validation (so a stale write
survives as 412, not flattened to 422 by Apply/Join), bumps Version
unconditionally before saving, and persists via
SaveChangesWithConcurrencyGuard (EF concurrency-token backstop).
- PlaylistViewModel carries Version; the items GET sets a strong ETag and
the PUT parses If-Match, threads it into the command, and returns the
refreshed ETag on success (400 on a malformed If-Match).
- Sibling item-adding handlers (AddItemsToPlaylist, AddMovie/Episode/
Season/ShowToPlaylist) bump Version too, since they mutate the same
editor-visible item list.
- SPA: playlists.ts exposes getPlaylistItemsWithMeta and an
If-Match-aware updatePlaylist; PlaylistEditor holds the ETag in a ref,
round-trips it on save, and opens a "changed elsewhere" ConfirmDialog on
412 (mirrors BlockEditor).
Tests: new ReplacePlaylistItemsHandlerConcurrencyTests (stale/match/
force-write/no-op-bump/racing-save), new PlaylistController tests
(ETag on GET items, 400/412/thread-version/force-write on PUT), and a
vitest 412-conflict-dialog test for PlaylistsScreen. dotnet test:
1304/1304 green. web: npm run typecheck clean, npm run build clean,
vitest 664/664 green.
Ref #253 PR2.
Wire the frozen ETag/If-Match/412 recipe (Block reference implementation)
onto the Template and DecoTemplate aggregates:
- ReplaceTemplateItems / ReplaceDecoTemplateItems commands gain
Option<int> ExpectedVersion; ToCommand() on the request DTOs threads it
through from If-Match.
- Handlers introduce the version check as a standalone Either after
validation (never via Apply), bump Version unconditionally before
saving, and persist through SaveChangesWithConcurrencyGuard so a losing
writer maps to 412 instead of 500. DecoTemplate's post-commit playout
Reset enqueue now only runs after a successful save.
- TemplateViewModel / DecoTemplateViewModel carry Version (header-only,
not echoed in the response body), populated in Mapper.
- TemplateController / DecoTemplateController: GET items emits a strong
ETag of the root's version; PUT parses If-Match (400 on malformed),
threads the expected version into the command, and returns the new
ETag from the refreshed root on success. Both PUT actions now use the
handler's returned item list directly instead of re-querying items.
- SPA: templates.ts / decoTemplates.ts gain getXItemsWithMeta and an
If-Match-aware replaceX; TemplateEditor / DecoTemplateEditor hold the
ETag in a ref, read items-with-meta first on load, and open a
"changed elsewhere" ConfirmDialog on a 412 instead of navigating away.
Tests: new ReplaceTemplateItemsHandlerConcurrencyTests /
ReplaceDecoTemplateItemsHandlerConcurrencyTests mirror the Block
concurrency contract tests (stale/matching/absent If-Match, no-op bump,
racing-save 412, non-vacuous backstop). TemplateControllerTests /
DecoTemplateControllerTests gain ETag/If-Match/412 coverage.
TemplatesScreen.test.tsx / DecoTemplatesScreen.test.tsx gain a 412
conflict-dialog test mirroring BlocksScreen's.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Independent Codex review of #268 found two Blockers the fork missed + two Mediums:
- Blocker: replace PUTs returned the handler's item snapshot but re-queried the root
for the ETag separately, so a racing writer could pair stale items with a newer ETag
(silent overwrite). All four controllers now reload root-then-items (version-first,
fail-safe) and 404 when the root is gone between commit and reload — matching the
Block reference. Fixes the Blocker + the Medium '200 without ETag' case together.
- Blocker: PlaylistsScreen loaded items+root via Promise.all (concurrent), pairing a
stale name with the current ETag; now sequential (items-with-meta first, then root).
- Medium: SchedulesScreen loadItems now marks not-loaded/loading up front so canEdit is
false through the 412 conflict reload (no stale-draft edits lost).
Controller unit-test mocks updated to stub the new reload query. Full suite green
(ErsatzTV.Tests 1334, web 667, check:api no drift).
The prior fix set setItemsLoading/setItemsLoaded at the top of loadItems, but the
activeId effect calls loadItems synchronously → react-hooks/set-state-in-effect lint
error (Main Lint SPA, the real CI failure). Move the not-loaded/loading gating into
reloadAfterConflict (an event handler, lint-clean), which is exactly the 412 conflict-
reload path Codex's Medium-3 targeted; canEdit stays false through that reload window.
Behavior unchanged; the normal switch/initial-load paths (guarded separately by the
#242 dirty-guard) are left untouched. Local: lint clean, vitest 667, check:api no drift,
full dotnet solution test green.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Fans the frozen #253 optimistic-concurrency recipe (api-conventions §7a, established by PR1/#263 on Block) across the RR + Reconcile aggregates. Design spec: #253#issuecomment-8472 §6 PR2.
What shipped
Four aggregates wired end-to-end (
ETagon the items GET,If-Matchon the replace PUT → 412 on mismatch, unconditionalVersion++,SaveChangesWithConcurrencyGuardbackstop, SPA editor ETag round-trip + 412 conflict dialog):CancellationToken.None)Add{Items,Movie,Episode,Season,Show}ToPlaylist(×5)Add/DeleteProgramScheduleItem,UpdateProgramScheduleEitherafter validation (ToEither(validation).Bind(x => x.CheckVersion(request.ExpectedVersion))) so aPreconditionFailedErrorsurvives to 412 (never flattened to 422 byApply/Join()); unconditionalroot.Version++(a no-op save still bumps + rotates ETags); save through the guard (racing writer → 412).If-Match(malformed/weak/list → 400), threadsExpectedVersion, and returns the fresh post-bump ETag on success. Each PUT keeps its own existing 200 body shape.web/src/api/*+ four editor screens):getXItemsWithMetaload reads items-with-meta first (ETag ≤ data, fail-safe), save round-trips the ETag, 412 → blocking "changed elsewhere — reload"ConfirmDialog. SchedulesScreen threads the ETag through the existing #242 dirty-guard (itemSeqstale-guard,loadItemsreload on conflict), not layered on top.Notes
AddAggregateVersionsalready added all 9Versioncolumns.versionin body:ScheduleControllerreturnsProgramScheduleViewModeldirectly (noProgramScheduleResponseModelexists), soversionalso surfaces inGET /api/schedules[/{id}]bodies. Intentional/harmless (ETag stays the authority); a ResponseModel purely to hide one field was judged disproportionate. Documented in §7a.v1.d.tsregenerated once post-merge; drift is exactly 4×(400+412) + If-Match/412 descriptions + the one scheduleversionfield.Verification
dotnet test ErsatzTV.sln— green (ErsatzTV.Tests 1334, Core 493, Scanner 1467, Infrastructure 53, Architecture 5, FFmpeg 20; 0 failed). New non-vacuous concurrency suites per aggregate (stale→412 no-mutation, match/absent→success+bump, no-op still bumps, two-context racing save→412).typecheck/build/lintclean;vitest667/77 (incl. a 412-conflict-dialog test per editor);check:apino drift.Docs:
docs/api-conventions.md§7a updated (fan-out landed + schedule body-version nuance).Remaining on #253: PR3 (Diff + Scalar: Collection, Playout ×2, MultiCollection, RerunCollection, incl. §9 H1 catch-rethrow) · PR4 (Phase-2 428 flip).