Persistence ports

Two ports sit under the draft operations. A host always supplies ISnapshotPrincipal; AddStateMachines provides a default ISnapshotStore, EfSnapshotStore, over the data context your provider registers, so you implement the store only for a custom backend.

ISnapshotPrincipal

The authenticated user behind a request. Every draft is scoped to CurrentUserKey, so the draft id is not a bearer capability: one user cannot load another's draft by guessing the id.

public interface ISnapshotPrincipal
{
    string? CurrentUserKey { get; }   // null when the request is unauthenticated
}

In an HTTP host this is backed by the request principal (a claim); in tests it is a fake. Bind it in DI when you wire the subsystem.

ISnapshotStore

Raw, user-scoped persistence of a snapshot. It moves the four snapshot fields (context as jsonb) and enforces optimistic concurrency, but does not validate: validation lives above it in the draft service. Every write is total, a conflict or a unique-key race returns false rather than throwing (genuine infrastructure failures still propagate).

A draft is keyed by its user, its machine and its id: two machines may each give one user a draft under the same id. The writes take the machine from the snapshot, and the draft service reads and deletes through the overloads that name it. A store that implements only the members without a machine keys a draft by user and id alone; the draft service then reads before every save and creates through Insert, so one machine's save is refused as a conflict rather than replacing another machine's draft under the same id.

MethodReturnsDoes
Get(userKey, machine, id, ct)StoredSnapshot?reads the caller's draft of that machine, or null if there is none. It has a default implementation that calls Get(userKey, id, ct) and treats a draft naming another machine as absent; a store that keys by machine overrides it
Get(userKey, id, ct)StoredSnapshot?reads one of the caller's drafts under that id, whichever machine it belongs to, or null if there is none
Delete(userKey, machine, id, ct)Taskdeletes the caller's draft of that machine, leaving another machine's under the same id; idempotent. Its default implementation deletes through Delete(userKey, id, ct) only when the draft there is this machine's
Delete(userKey, id, ct)Taskdeletes the caller's drafts under that id; idempotent (deleting a gone row is a no-op)
Insert(userKey, id, snapshot, ct)boolcreates the draft and only creates it: false when one already exists. The draft service creates every new draft through this, so a save that read no draft never overwrites one created since. Its default implementation checks Get(userKey, id, ct) and then calls Upsert, so it refuses while the user has any draft under the id, whichever machine it belongs to; the check and the write are two steps, so a store that can make them one statement should override it (EfSnapshotStore does)
Upsert(userKey, id, snapshot, ct)boolthe last-writer-wins autosave of a machine with no committed state and no effect; false on a concurrent-write conflict
Update(userKey, id, snapshot, expectedToken, requestId, ct)boolwrites only if the row still carries expectedToken, and records requestId as the last applied idempotency key with no trigger or from-state; false if the row changed
UpdateWithRequest(userKey, id, snapshot, expectedToken, request, ct)boolthe authoritative path: Update that records the whole AppliedRequest (id, trigger, from-state), or clears it when request is null. It has a default implementation that calls Update with the id alone, so a custom store keeps compiling; override it, or every retry against that store is refused as request-id-reused rather than replayed

StoredSnapshot

A stored draft as read back:

FieldTypeMeaning
Jsonstringthe draft's canonical JSON
TokenGuidthe concurrency token to write against
LastRequestIdstring?the idempotency key of the last applied advance, if any
LastRequestTriggerstring?the trigger that advance fired, or null when none was recorded
LastRequestFromStatestring?the state that advance fired from, or null when none was recorded
LastRequestAppliedRequest?the three above as one value, or null when there is no request id
UpdatedAtDateTimeOffsetwhen the row was last written (the window the draft-TTL expiry checks)

The Token / expectedToken pair is the optimistic-concurrency contract: read a draft, then Update against its Token; if another write landed in between, the token no longer matches and the update returns false instead of clobbering it.

How a request id is matched

The draft records the last advance that carried a request id: the id, the trigger it fired, and the state it fired from. An advance with a request id is then one of three things:

The stored requestThe draft nowOutcome
a different id, or noneanyfires the trigger and records this request
the same id and triggeranywhere but the from-state, or on an edge that loops back to ita retry: the current snapshot is returned and nothing fires
the same id and triggerback in the from-state, on an edge that leaves itthe request's outcome was undone (a reset or a move back), so it fires again
the same id, another trigger or none recordedanyrefused as request-id-reused; nothing is written

Advance and send share one id space. A send with no requestId uses send:{id}, so a client that keys its advances by the draft id does not collide with it.

The recorded trigger and from-state are the last_request_trigger and last_request_from_state columns, added by migration 048_snapshot_draft_request_scope.sql (Postgres) and 013_snapshot_draft_request_scope.sql (SQLite). A row written before that migration has neither, so a retry of its last request is refused once as request-id-reused instead of being replayed.

What each path may write

Autosave stores a snapshot the client computed; advance fires one trigger on the stored draft; send runs the machine's effect and then fires its trigger. Only send puts a draft into a committed state or into the state an effect-bound transition lands in, because only send knows the effect ran:

PathRefusesCode
advancea trigger bound to the machine's effect from the stored stateeffect-bound
autosavecreating a draft in, or moving one into, a committed state or an effect's targetstate-reserved
autosaveany change to a draft already in a committed state or an effect's target, except a reset to the initial state that the machine declares from that statedraft-committed
autosaveoverwriting a stored draft that fails rehydration with anything but the initial statedraft-unreadable
sendrecording the receipt on a draft that was written while the effect ranconflict
sendreplaying a receipt onto a draft whose content is not what the effect ran ondraft-changed

The receipt a draft in a committed state holds is the one the effect produced for that draft's content, so its content is as fixed as its state. A save identical to the stored draft, the snapshot a send returned for instance, is answered as saved without a write. A reset is allowed only where the machine itself has a transition from that state back to the initial state; a machine with none has no soft reset out of it.

A reset releases the draft's effect claims once their outcome is settled on the draft, so the next draft runs its effect afresh. A claim whose effect is still running inside its lease is kept, and so is a completed claim whose receipt the draft never recorded (its send reported a conflict): the next send replays that receipt rather than running the effect a second time, provided the draft again holds the content the effect ran on, and is refused as draft-changed otherwise. To seed a draft in a committed state in a test, write it through the store.

EfSnapshotStore and EfEffectClaimStore

The default stores take the IDataContext their tables are reached through and the provider's ISqlDialect:

public EfSnapshotStore(IDataContext db, ISqlDialect? dialect = null)
public EfEffectClaimStore(IDataContext db, ISqlDialect? dialect = null)

The dialect is how a lost race (a concurrent create of one draft, a claim on a key someone else holds) is told apart from a real failure, on Postgres and SQLite alike. Without one, such a race throws instead of returning false or Lost. Both stores stop tracking what they write, so they can share a request's data context.

IEffectClaimStore.ReleaseForReset(effectKey, receiptRecorded, ct) is the release a reset uses: it keeps an in-flight claim whose lease has not passed, and deletes a completed claim only when receiptRecorded accepts its receipt. Release deletes whatever is there, and is what deleting an expired draft uses. A custom claim store that does not override ReleaseForReset gets a default that releases only a completed claim whose receipt was recorded, and keeps every in-flight claim until the lease lets the next send reclaim it.

TryClaim(effectKey, lease, contentFingerprint, ct) records the fingerprint of the content the effect runs on in effect_claim.content_fingerprint, on a new claim or on an expired one it takes over, and GetCompleted(effectKey, ct) returns a completed claim's receipt with that fingerprint in one read as a CompletedEffect. IdempotentEffect.RunOnce(effectKey, contentFingerprint, effect, lease, ct) passes it through, and a lost claim's EffectOutcome.AlreadyRan carries the recorded ContentFingerprint; the effect runner compares it with the draft's before replaying. Both members have defaults, so a custom claim store keeps compiling: one that does not override them records no fingerprint, and its claims replay without the content check.