Skip to content

feat: commit audited lifecycle changes atomically - #4

Merged
fabbrik merged 1 commit into
mainfrom
feat/2-4-atomic-lifecycle-commits
Sep 18, 2026
Merged

fabbrik merged 1 commit into
mainfrom
feat/2-4-atomic-lifecycle-commits

Conversation

@fabbrik

@fabbrik fabbrik commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Summary

Story 2.4. A record's status can now change, and every change is audited.

  • Core decides. ExperienceLifecycleService allows the minimal table this epic needs — Candidate→Validated, any status except Revoked→Quarantined, any status→Revoked — stamps the LifecycleEvent, and calls the port. A transition outside the table is refused before any database call. The adapter never invents a transition, status, or score.
  • The adapter commits atomically. The event append and the projection update run in one READ COMMITTED transaction on one connection: both or neither.
  • Retries are safe. EventId is the idempotency key. An identical replay returns the original outcome and the revision that commit produced, even after later commits. The same ID with any differing field is Conflict.
  • Concurrent writers are safe. ExpectedRevision must match the stored revision, so two commits racing from one revision produce exactly one winner; the loser gets StaleRevision, whichever guard it trips.
  • The audit log cannot lie. The projection update also matches the record's stored status when the event carries a prior status, so a caller cannot assert a false prior status to land a transition Core forbids. A mismatch returns the new StatusMismatch outcome with the stored status to re-decide against.
  • History. GetHistoryAsync returns the current revision plus every event, oldest first, from one consistent read. Events are never updated or deleted; migration 0002 adds the append-only lifecycle_events table with a unique (experience_id, applied_revision) index.

Review

Three independent reviewers raised 27 findings. Two found the same real hole — the transition table was applied only to the prior status the caller asserted — which was resolved as a deliberate change to the story's intent and is the StatusMismatch guard above. 22 findings fixed, 4 deferred (bounded history with a cursor, exposing each event's stored timestamp, injecting a clock), 1 rejected (re-revoking a revoked record still bumps the revision, which the approved table allows and a test pins).

Test plan

  • dotnet build --configuration Release: 0 warnings, 0 errors
  • dotnet test --configuration Release: 379 of 379 pass (Postgres 89, Core 209). Locally with TESTCONTAINERS_RYUK_DISABLED=true.
  • One test per edge case, including two commits racing from the same revision, a deliberate failure between the two writes proving neither persists, a dishonest prior status, and a replay after later commits.
  • Also lands the mid-flight cancellation test deferred from Story 2.1's review.
  • CI green

🤖 Generated with Claude Code

Add a lifecycle commit and history read to IExperienceRecordStore, and a Core
ExperienceLifecycleService that decides the minimal transitions (validate,
quarantine, revoke) and stamps the event. The PostgreSQL adapter appends the
event and updates the record projection in one READ COMMITTED transaction,
idempotent by event ID, guarded by expected revision and by the record's stored
status, with an append-only lifecycle_events table added by migration 0002.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@fabbrik
fabbrik merged commit 9c1737f into main Sep 18, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant