feat: commit audited lifecycle changes atomically - #4
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Story 2.4. A record's status can now change, and every change is audited.
ExperienceLifecycleServiceallows the minimal table this epic needs — Candidate→Validated, any status except Revoked→Quarantined, any status→Revoked — stamps theLifecycleEvent, and calls the port. A transition outside the table is refused before any database call. The adapter never invents a transition, status, or score.EventIdis 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 isConflict.ExpectedRevisionmust match the stored revision, so two commits racing from one revision produce exactly one winner; the loser getsStaleRevision, whichever guard it trips.StatusMismatchoutcome with the stored status to re-decide against.GetHistoryAsyncreturns the current revision plus every event, oldest first, from one consistent read. Events are never updated or deleted; migration0002adds the append-onlylifecycle_eventstable 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
StatusMismatchguard 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 errorsdotnet test --configuration Release: 379 of 379 pass (Postgres 89, Core 209). Locally withTESTCONTAINERS_RYUK_DISABLED=true.🤖 Generated with Claude Code