sessionstore

package module
v0.9.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 13, 2026 License: Apache-2.0 Imports: 27 Imported by: 0

README

sessionstore

sessionstore is Looprig's transport-neutral durable session aggregate. It composes the primitives from github.com/looprig/storage with canonical records from github.com/looprig/core/sessionwire/v1 so durable session state can be shared without coupling Factory, Host, or Harness to one another.

The module owns journal fencing and replay, catalog and gate projections, durable command admission, host and placement records, fenced pointers, reconciliation claims, and session-scoped object references. Concrete storage providers are selected by the product composition root.

Production imports are limited to the Go standard library, Core, and Storage, and that limit is a test rather than a convention: TestProductionImportsStayWithinBoundary parses every production file's import block and fails on anything else, and fails as vacuous if it found no files. go.mod names exact released versions and contains no replace directive; nothing here is vendored, so GOWORK=off go test ./... verifies the module against the versions it actually pins.

Composing a Store

A composition root picks one provider, hands its complete Storage composite to Open, and closes the Store when it is done:

func Open(ctx context.Context, backend *storage.Composite, opts ...Option) (*Store, error)
func (s *Store) Close(ctx context.Context) error
store, err := sessionstore.Open(ctx, memstore.New())
if err != nil {
	return err
}
defer store.Close(ctx)

if _, created, err := store.CreateCatalogEntry(ctx, sessionstore.CreateCatalogEntryRequest{
	TenantID:               "tenant-a",
	SessionID:              "session-a",
	AgentID:                "agent-a",
	RuntimeCompatibilityID: "runtime-v1",
	CreatedAt:              now,
	LastActiveAt:           now,
	State:                  sessionwire.SessionStateIdle,
	Residency:              sessionwire.SessionResidencyCold,
	DesiredPlacement:       sessionwire.HostPlacementPooled,
	IdempotencyKey:         "create-1",
}); err != nil {
	return err
}

page, err := store.ListSessions(ctx, sessionstore.ListSessionsRequest{TenantID: "tenant-a", Limit: 10})

ExampleOpen in example_test.go runs this same flow under go test with its output checked, so the API these calls name cannot change without a test failing. The snippet above is prose and is held to the example by review; the example is what the build holds.

memstore is Storage's in-process oracle; it is used above because it satisfies Open's provider requirement and needs no setup, and a product substitutes a durable provider at exactly that one call. Nothing else in the snippet changes with the provider.

The composite must carry all five primitives — Ledger, Leaser, KV, Blobs and OrderedIndex. A nil composite or a missing primitive is refused before any provider I/O with *InvalidBackendError naming the component (TestOpenRejectsMissingPrimitive).

The options are WithControlShards, WithLegacySingleTenant, WithLimits, WithShutdownTimeout, WithClock, WithLogger, WithProviderOwnership and WithIOProviderOwnership. Two of them are not runtime settings: the control shard count and the layout choice are persisted in the backend's layout marker at the first Open and are compared on every later one, so changing either for a populated backend is an offline migration rather than a redeploy.

Provider lifecycle is not assumed. Without WithProviderOwnership or WithIOProviderOwnership, Close never closes caller-supplied storage; with one of them, ownership transfers only after Open has SUCCEEDED, so a provider passed to a failed Open is still the caller's to close.

There is no caching layer here and none is wanted: every read in this package is a direct provider read or a bounded provider query, and a cache in front of it would serve a revision a compare-and-swap has already invalidated, which is the one thing the fences exist to prevent.

Immutable session bindings

CreateCatalogEntryRequest.Binding optionally pins StorageBindingID, BindingVersion, RuntimeSessionID, and ProtocolMode. These are bounded opaque IDs; the configuration version is immutable and contains no credentials. A complete binding is written inside the catalog's single atomic ordered create. Matching retries return the winning record; a different binding or AgentID fails with CatalogErrorConflict. Desired-state updates preserve the binding. There is no online binding or protocol conversion API.

Zero binding retains the literal version-1 legacy catalog format and its legacy retry behavior. Bound records use version 2, require every binding field, and reject unknown fields or modes. ProtocolModeLegacy selects the released single-store protocol. ProtocolModeDisposition selects the independent ownership and settlement protocol, whose command lifecycle is described under The disposition command lifecycle below. Existing Host/gate, journal, registration and pointer writers still cannot execute that protocol, and the legacy inbox is unreachable to a disposition session.

A separate create-only KV witness reserves only the protocol, before any session data is written. It never selects storage configuration or the winning catalog binding. If creation fails after that witness, a same-mode retry can still create the catalog with its proposed binding. Keep the witness for the session's lifetime; deleting it independently is unsupported. Sessions with old collision witnesses but no protocol witness are conservatively legacy, including previously unused scopes. The legacy single-tenant layout refuses disposition sessions. Old binaries must be excluded from stores serving disposition sessions: they do not know this witness and cannot be fenced by it.

The disposition command lifecycle

pending -> claimed -> applying -> applied | rejected, plus one shortcut: pending | claimed -> rejected before any dispatch. Every edge is a revision compare-and-swap of one inbox record, under the session's immutable catalog binding and its protocol-mode fence.

Edge Call Authority it requires
admit -> pending AdmitDispositionCommand a disposition catalog binding
pending -> claimed ClaimDispositionCommand the *ResidencyGrant AcquireResidency returned
claimed -> applying BeginDispositionAttempt the CLAIM's own residency, plus the runtime's journal epoch
applying -> terminal SettleDispositionCommand verified evidence; the residency is context only
pending/claimed -> rejected RejectDispositionCommand none required; a named residency is fenced

Three authorities, not one. The residency epoch guards the claim and the attempt, the runtime's JOURNAL epoch guards application, and the revision compare-and-swap guards each write. Nothing compares a residency with a journal epoch, and neither is derived from the other.

The claim edge takes a GRANT, not an epoch, and it is the only edge that does. ClaimDispositionCommandRequest.Residency is the *ResidencyGrant AcquireResidency returned; the epoch is read off it and a caller cannot name one. The reason is that this edge is the only producer of the record's high-water mark, and that mark only ever rises: a single stored claim at an epoch no provider issued would supersede every real Host for that command permanently — it could never be claimed, attempted or applied again, and its only exit would be a zero-residency reconciler rejection once the bogus claim lapsed. A bare number could not be checked, because storage.Leaser exposes only Acquire, so there is no way to read a session's issued epoch without taking the lease away from whoever holds it. A grant needs no check: it is the store's own object carrying a provider-issued epoch for a named session.

Be exact about what that buys. It is not proof of a live lease — nothing in this module reads one, and a grant whose lease has expired or been taken over still passes. It is proof the epoch came from this store's provider for this session, which is all a ratcheting mark needs: a stale grant names a lower epoch and the fence refuses it on its own terms, and it cannot name a higher one. A nil, released, other-session or other-store grant is refused with inbox invalid (residency).

The other edges keep a bare epoch, which is not an inconsistency — the test is whether the store decides from the value or merely records it. SettlingResidencyEpoch is recorded and never read back. BeginDispositionAttempt's epoch is fenced to equal the claim's own, so it cannot raise the mark. RejectDispositionCommand writes no claim, so it cannot either, and it must accept a zero because a reconciler holds no residency.

ClaimDispositionCommand also holds the expiry to MaxCommandClaimTTL and refuses any record carrying an ATTEMPT — applying is a fortress. Its refusals are ordered so a caller meeting two at once is told the one that stays true: terminal, attempt, superseded residency, exact replay, apply deadline, held claim. A residency STRICTLY ABOVE the record's mark may take over a live claim, which is failover; the claim's own residency naming a different expiry is a RENEWAL and is refused, exactly as legacy ClaimCommand refuses one.

Idempotency is over the reread, not over the lost response. Re-issuing the same request against a record whose claim is still live returns the stored entry with claimed=false and writes nothing — so a replay can never extend an expiry. "Same request" means the same residency and the same instant: the expiry is compared by instant, not by time.Time value, so a caller may pass it in any zone and with or without a monotonic reading. Re-issuing at the revision the caller originally decided on is a conflict carrying the current revision, which is this module's standing answer to a compare-and-swap whose outcome the caller did not learn. Once the claim has lapsed an exact replay names a past expiry and is refused with invalid (claim_expires_at) before the record is read — neither idempotent nor claim_held; choose a new expiry and re-claim.

RejectDispositionCommand is the PRE-DISPATCH refusal and nothing else. It cannot become a post-attempt rejection: it refuses every record carrying an attempt, applying and terminal alike — including a settled not_applied, which is a rejection it must never present as its own idempotent result. Its own prior result, a terminal rejected with no attempt, IS returned idempotently with rejected=false. It performs no journal scan and needs none — but the premise is a consumer obligation, not a store-enforced invariant, and the distinction matters because the omission rests on it. The store cannot observe a dispatch; what it enforces is that BeginDispositionAttempt is the only writer of an attempt, and that a non-terminal record carries one exactly when it is applying. A Host must not dispatch until that call has returned successfully. Given that obligation, a record with no attempt has had no dispatch and there is no effect for the rejection to orphan. It stores no reason — this record has no member for one, and adding a durable member would require a record-version bump.

Its residency is OPTIONAL. Zero is the honest statement "I am not acting under a residency", and what confines such a caller is the CLAIM RULE, a property of the record that applies at every residency: a LIVE claim admits only its own holder, so a reconciler may settle a pending command or one whose claim has lapsed, and a live claim wins the deadline race outright. A nonzero epoch is a consistency check on a view the caller asserts, not an authority boundary — nothing forces a caller to name one — but one below the record's high-water mark is told it is superseded rather than acted on. A rejection of a CLAIMED command keeps the claim, as the durable record of who was working on it.

The two edges answer a SUCCESSOR differently, and the asymmetry is deliberate. A residency above the record's mark may claim a live claim away from its predecessor — that is failover — but may not reject the command under it: it is told claim_held and must take the claim first. Rejecting is a terminal decision about work the holder may be in the middle of, so it belongs to whoever holds the claim; claiming first makes the successor the holder before it decides anything terminal.

One ordering note, because it is the opposite of the claim edge's: the reject edge's idempotent arm runs before both fences, so a superseded residency replaying a pre-dispatch rejection gets the idempotent success rather than epoch. That arm writes nothing — it reads a record that is already terminal — and telling a superseded caller "this was already rejected" is both true and final. The claim edge's replay arm sits after its fence because that edge can go on to write.

Residency-only grants

AcquireResidency(ctx, AcquireResidencyRequest{TenantID, SessionID}) requires an existing, valid disposition-mode catalog binding. A protocol witness alone does not authorize acquisition. It acquires the session's separate /residency/lease namespace without opening, reading or appending its journal. The returned ResidencyGrant exposes Epoch() ResidencyEpoch, Lost() and Release(ctx). Never compare or substitute a residency epoch for a journal epoch. ClaimDispositionCommand takes the grant itself rather than its epoch, and BeginDispositionAttempt accepts only the epoch of the grant the claim was taken under. This module still implements no disposition journal writer and no evidence reader; legacy OpenJournal continues to refuse disposition sessions.

Lost() is the actual Storage provider signal. The acquire context bounds only acquisition; Store shutdown attempts release of outstanding grants. Liveness depends on the provider: memstore has no TTL or crash takeover, and residency loss alone never fences the independent journal grant.

Release is idempotent after success. Failed release retains the Store admission and can be retried; Store.Close may reach its caller's deadline while cleanup remains unresolved. Each release attempt, including its wait for a concurrent attempt, is bounded by its caller context and the Store shutdown timeout, assuming the provider honors cancellation. No background retry loop runs. Acquisition errors always return a nil grant. If acquisition is canceled after the provider grants ownership and rollback fails, use errors.As to retain the *ResidencyAcquireCleanupError and retry its Release(ctx) method. That cleanup handle exposes no usable ownership epoch. Its cause includes both the original refusal and cleanup failure.

Provider compatibility

Open requires the backend's Blobs primitive to implement Storage's optional BlobReaderLifecycle capability, be a concrete non-nil implementation, and advertise a positive close bound. This lets Store shutdown stop an outstanding object read before an explicitly owned provider is closed. The reader close bound and WithShutdownTimeout cover separate shutdown phases and are not compared.

Storage v0.6.0's memory backend and natsstore v0.5.1 satisfy the requirement. fsstore v0.5.1 intentionally does not claim bounded reader shutdown and is rejected with *InvalidBackendError naming BlobReaderLifecycle. Any other provider is compatible only after it implements the capability and its provider-specific blocked-I/O proof. Capability rejection happens before layout marker or other provider I/O; a provider passed to a failed Open remains caller-owned. TestOpenRejectsInvalidBlobReaderLifecycleBeforeProviderIO drives all four refusal shapes — absent capability, dynamically nil capability, zero bound and negative bound — and asserts on each that no provider call was made, that no layout marker was written, and that a transferred closer was not closed. It names no provider: production code here cannot import one, so the capability is what is tested and the named versions above are stated from those modules' own sources.

Layout compatibility

An unmarked backend is atomically initialized as the tenant-scoped tenant-v1 layout. The historical sessions/<uuid> layout is available only through WithLegacySingleTenant, which persists and enforces the exact configured tenant. SessionStore never probes for old data, auto-migrates, or dual-writes layouts.

Migration must be performed offline with SessionStore stopped, into a new backend already initialized for tenant-v1. Validate the migrated data before switching the composition root; do not rewrite a live backend's immutable layout marker.

Provider ownership options take effect only after Open has successfully validated and bound the backend layout. If Open fails, the provider remains caller-owned and SessionStore does not close it.

Logical keys: every name is derived, and no derived name is trusted alone

Under tenant-v1 the TENANT never appears in a provider name. A tenant's physical namespace is tenants/<token>, where the token is a domain-separated digest of the TenantID; a session's is that namespace plus /sessions/<token> over (tenant, session). Every other name a session has is derived from those two in one pure function that touches no provider — its journal ledger, its lease, its catalog key and its blob prefix.

Be precise about what that does and does not hide, because the difference matters to anyone reading a provider's keys. What is digested is what selects a NAMESPACE. Inside a namespace that is already tenant- or session-scoped, a record's StableKey is the raw identity: catalogID files a catalog record under the SessionID, gateIntentID under the GateID, inboxID under the CommandID. That is deliberate — those keys are named reads within a scope the derivation has already established — but it means a session, gate or command id IS legible at the provider, and only the tenant id is not.

The catalog record's ORDERING and RANKING scope is the tenant namespace, which is what makes a recent-first tenant page one provider query rather than a filter over a wider one.

Outstanding records — inbox commands and gate deadline intents — are additionally FILED in a control shard namespace while keeping the session namespace as their ordering scope; see the control shard section for why those are two different things.

Because a derived name is a digest, two identities could in principle collide. CreateCatalogEntry therefore binds create-only collision WITNESSES — one under the tenant token, one under the session token, each holding the identity it was derived from — and every session-scoped read and write verifies them before touching the session's data: those paths funnel through helpers that call verifySessionScope first, and TestBindingFailurePrecedesEverySessionDataPrimitive asserts that a binding failure touches the witness KV and none of the four session-data primitives. A witness holding a different identity is a collision and fails closed.

That produces a distinction a caller has to handle: a session whose witnesses were never bound is refused by the keyspace with *KeyspaceError and binding_not_found before any record of the kind asked for is consulted, while a bound session with no such record is that record's own not_found. TestPointerWritesBindTheSessionsWitness pins the CLASS of the refusal rather than merely that one occurred, because the two make different claims about the world.

A listing binds and verifies no witness, and the asymmetry is deliberate: it names no session, so there is nothing to prove, and requiring a binding would answer "this tenant is empty" with a failure. Separation there comes from holding every returned record to the identity its own bytes claim.

The legacy layout derives nothing. Its names are sessions/<uuid> verbatim, it has no witnesses at all, and a non-canonical session id or any tenant other than the one in the marker is refused before a provider is touched (TestInvalidAndForeignLegacyIdentitiesTouchNoProviderPrimitive, TestLegacyLayoutRejectsForeignTenantAndNoncanonicalSession).

What a page promises: records are strong, ranked and due views are weak

Two different guarantees run through this package, and mixing them up is how a sweep silently loses work.

Strong — anything read by name. GetCatalogEntry, ReadGates, GetCommand, GetHostRegistration, the pointer reads and the reconciliation claim read all fetch one authoritative record at its current revision, and the writes that UPDATE one of those records close their read-compare-write with a revision compare-and-swap. (AdmitCommand is the one write that is not an update: it is a single atomically idempotent Create, which is why a duplicate needs no read of its own.) There is no cache in front of any of them. ReadGates is in this group rather than the next one precisely because it has no cursor: a session holds at most MaxCatalogOpenGates gates, so the whole answer is one bounded record read.

Weak — anything paged. ListSessions and ListCompatibleHosts are ListRanked queries; ListDueGates, ListDueCommands and ReconcileHostTargets are ListDue queries. Storage specifies both as keyset pagination over a LIVE view: a continuation resumes from the frozen (rank | due_at, stable_key, ordering_scope) tuple the cursor names, not from a snapshot, so a record whose rank or due time moves across that position between two pages is skipped or returned twice. TestListSessionsSurvivesARankMoveBetweenPages drives exactly that. A sweep that must see every record once therefore reconciles BY IDENTITY, not by page — and ReconcileHostTargets does: it revalidates each row's own stored expiry and compare-and-swaps onto the revision the page reported, so a Host that heartbeated since the page was read is left alone.

Journal pages are the exception, and they are strong. ReadPublicJournal and ReadRuntimeJournal capture the ledger tip on the first page and pin it into the cursor, so a walk covers exactly the snapshot that first page named; the ledger is append-only, so nothing inside that range can move. A cursor carrying an inflated captured tip cannot widen the snapshot.

The per-session acceptance stream is a third case, and it is strong. ListSessionDispositionCommands pages one session's disposition inbox by ListOrdered, which walks the IMMUTABLE acceptance order rather than a live ranked or due view. A row's order never moves, so nothing can be skipped or returned twice across a continuation, and the bound is a row's own order rather than an opaque token. It also fails closed, like the journal readers and unlike the due views: see the consumption section below for why a consumption stream cannot step over a row.

No single row fails a COUNTED page. In the weak views above, and only there, every per-row refusal is counted and stepped over — SessionPage.UnreadableSkipped, DueGatePage.Unreadable, HostTargetPage.LapsedSkipped and UnreadableSkipped. A failure returned from one of THOSE four queries is about the query itself: a bad limit, a foreign cursor, a provider that could not answer. The reasoning, and why a page budget is not a substitute for a continuation, is in the Host target section.

The journal readers are the exception here too, and they fail closed deliberately. ReadPublicJournal and ReadRuntimeJournal STOP the walk on any record they cannot decode and return *JournalError with code integrity. There is nowhere for them to do anything else: neither sessionwire.JournalPage nor RuntimePage carries a skip counter, so a skipped record could not be reported, and a page that silently omitted one would be indistinguishable from a complete one. TestJournalReadsFailClosedOnACorruptStoredFrame and TestJournalReadsFailClosedOnATruncatedStream hold it, and the first states the rule outright: a ledger record this package did not write, or one damaged in place, must stop the walk rather than be skipped or zero-valued. That is the opposite trade from a capacity row on purpose — omitting one advertisement costs a candidate, while omitting one journal record corrupts the state every reader reconstructs from the sequence.

Two consequences of that are sharp enough to plan for:

  • The decode happens BEFORE the public/runtime selection, so one corrupt runtime record fails a public page that would never have published it.
  • ReadPublicJournal resolves an object-backed public body through the ordinary verified object path, so a missing or reclaimed journal blob is likewise an error and not an omission.

In both cases a failed page issues no NextCursor, so a walk cannot continue through the bad record, and this package has no repair API for it. That is the same head-of-line shape ListSessions and ListDueGates were changed to avoid, kept here because the alternative is handing a caller a history with a hole in it that the caller cannot see.

A caller must therefore handle JournalErrorIntegrity as its own case, distinct from a cursor or limit failure. It means a stored record is unreadable, and no retry, cursor reset or different limit will move past it. One thing does: ReadPublicJournalRequest.FromSeq positions a walk directly, so a caller that has identified the bad sequence can resume above it. That is deliberately the only route past, and it is not a repair — it skips a record that is really there, and it is the CALLER that decides to, having been told. Skipping silently inside the reader is what would have been unsafe.

Objects first, references second

Every path in this package that stores bytes larger than a record persists and VERIFIES the object before writing anything that names it, and never the other way round. PutObject mints the identity, writes the blob, re-reads the persisted bytes and checks them against the declared length and digest, commits its immutable metadata index, and only then returns a reference. A journal append whose body is over threshold uploads and verifies the object first and appends the reference second. OpenGate writes the deadline intent before it commits the open-gate projection, which is the same rule one level up.

The rule is chosen for its crash polarity, and the crash tests in crash_prefix_test.go are what hold it. Interrupt any of these and the only state reachable is the first write with the second missing — a verified object nothing references, or a deadline intent with no matching open gate — never the inverse. TestCrashPrefixObjectUploadWithoutReferenceReadsAsDeletableOrphan drives the object case and TestCrashPrefixesGateIntentAndOpenProjectionLicenseDifferentSweepActions the gate case. Both leftovers are inert: every object key is content- and generation-addressed, so an orphan can never be served as another object, and a remnant intent is reported rather than acted on. A reference to an object that is not there would instead be a session that cannot be read, and a public gate with no deadline in any due view would be one nothing expires.

The cost of that choice is stated rather than hidden: orphans accumulate, and reclaiming them is the operator's job. See the next section.

Object orphans

PutObject mints an object's identity before writing it and returns a reference only after re-reading the persisted bytes, verifying their declared length and digest, and committing the immutable metadata index. An unresolved provider failure after the blob commits returns an error and no reference while leaving an orphan. The blob's readback verification may itself have failed.

That is deliberate. Deleting on a post-commit failure would issue a delete against a provider that has just proved unreliable, and every object key is content- and generation-addressed, so an orphan can never be confused with, or served as, another object. Reclaiming orphans is the store operator's responsibility, over the tenant- and session-scoped blob prefix; SessionStore's only enumeration path is internal and unexported, so no caller-facing garbage collector exists yet.

Resolving object metadata

GetObjectMetadata accepts authenticated tenant/session scope, a logical Reference, and an explicit ExpectedKind. It returns the exact metadata from PutObject, including the immutable generation, digest, exact size and media type. It performs at most two collision-witness reads and one exact KV read, with no journal scan, key enumeration or blob read. Factory must authorize the request; Harness must authorize retained-result access against its committed capture records. The index itself grants neither authority.

Each successful PutObject creates a versioned immutable record under the session's object-metadata/v1/<kind>/<digest>/<generation> KV namespace after blob readback verification. LROM v1 stores the tenant, session, reference, digest, media type and exact uint64 size in a canonical binary record bounded to 1,303 bytes; CreatedAt remains the zero value returned by PutObject. Decoding rejects malformed, oversized, noncanonical and mismatched records. KV returns a complete byte slice, so this limit bounds accepted records and decoder work, not a faulty provider's allocation before returning the slice. An ambiguous create resolves through one exact read and requires the exact canonical winner. Failure never triggers deletion or overwrite of the winner.

ObjectErrorMetadataUnavailable with Field: "metadata" means no index row exists. It does not prove that bytes are absent: objects written by older binaries, and writes interrupted before indexing, may have no row. There is no automatic migration or backfill. Existing GetObject calls with explicit full metadata remain compatible and do not consult the index.

Conversely, an index row does not prove that bytes still exist or are intact. Administrative or external deletion can leave the immutable metadata behind. GetObject preserves storage.BlobNotFoundError in its typed backend error when the body is missing; full integrity is established only by consuming its verified stream through terminal EOF. Premature close remains an error.

Journal ownership: no rebasing after a fence conflict

OpenJournal acquires the session lease, reads the ledger tip exactly once, and appends an opening fence at precisely that tip stamped with the grant's epoch. If that CAS conflicts, the grant is spent: the lease is released and a typed *JournalError with code fenced is returned. The caller may acquire a fresh, strictly higher epoch and reopen.

It deliberately does not refresh the tip and retry. A retry loop lets a writer reorder itself behind records it never observed, under an epoch that a predecessor may still believe it holds; failing the grant instead makes epoch order and ledger order agree.

After a successful open the writer tracks only its own committed sequence and CASes every later append on it. It never re-reads the tip, so a successor's opening fence permanently fails it. An append whose outcome could not be resolved — an unresolved ambiguous ack, or a contested record that could not be read back — is equally terminal (code unknown): the writer latches the failure rather than rebasing onto whatever is now durable. A definite backend failure is not terminal, because it left the tracked tip untouched and the same record can simply be offered again.

An over-threshold body is uploaded and verified as an immutable object before its reference is appended. If the append then fails, the verified object is left behind as an orphan for the same reason PutObject leaves one.

Public journal reads

ReadPublicJournal returns only a public event's stored canonical public body and its Core metadata. Runtime control records, ownership fences, and application prefixes are withheld entirely: they contribute nothing to a page except an advance of covered_through, the authenticated watermark that lets a client close a sequence gap without learning the kind or bytes of what filled it. A public body held in an object is resolved through the ordinary verified object path; a private runtime object is never fetched by a public read.

Page cursors are opaque and bound to the projection they were issued for, to the exact tenant and session, and to the tip captured by the first page — a runtime cursor cannot be replayed into a public read, a cursor cannot be moved between sessions, and an inflated captured tip cannot widen the snapshot a walk covers. ReadRuntimeJournal is the privileged counterpart and returns every record as stored, leaving object-backed bodies unresolved for the caller to fetch.

Catalog ownership: a Host epoch and a Factory revision

The session catalog is one authoritative OrderedIndex record per session, ordered and ranked by the tenant's namespace and ranked by LastActiveAt.UnixNano(), so a recent-first tenant page stays a bounded provider query and a status read stays a direct get.

Its fields have two owners and two different guards, and the difference is structural rather than advisory. UpdateCatalogHostState carries the writing lease epoch and is refused if that epoch is below the record's committed high-water mark; an equal epoch is admitted, because one grant legitimately writes many times. UpdateCatalogDesiredState carries an expected revision and a retry-stable idempotency key and has no lease-epoch member at all, so a Factory cannot spell a claim on a lease it does not hold. The idempotency key is compared before the revision: a retry of an already-applied desired-state write carries an expected revision that its own success invalidated, so comparing the revision first would reject exactly the requests idempotency exists to absorb.

Both paths close their read-compare-write with the same revision compare-and-swap, which is what makes the epoch a fence rather than advice: a writer that observed a stale high-water mark loses the swap and, on re-reading, meets the successor's epoch.

Desired placement lives on the catalog record, not beside it

Everything a placement controller needs is Factory-authored state on that same record: the desired placement mode, the runtime compatibility requirement, an opaque versioned platform workload payload, and a generation. placement.go adds the rules and the PlacementIntent projection; it declares no record and no error vocabulary of its own, because a second desired-placement record would need a consistency protocol between two rows with no cross-primitive transaction available to run it — the same argument that keeps the gate deadline index from carrying a second copy of a gate's content.

The workload payload is opaque and versioned. DesiredWorkload is a byte string plus a caller-owned version label, bounded by MaxDesiredWorkloadPayloadBytes so an oversized one is reported against the member the caller actually wrote rather than as "the record is too large". Nothing here parses it: a Kubernetes PodSpec, a Nomad job and a future platform's manifest are the same value to this package, which is what keeps platform types out of the module and keeps a stored payload from becoming undecodable when a platform release changes. The version and the payload are present together or absent together, so "no workload desired" — the ordinary case for a pooled session — has exactly one spelling.

The generation is what tells a controller its work is stale. The revision cannot: every Host heartbeat moves it, so a controller comparing revisions would re-reconcile on every projection write and never learn whether the DESIRE had changed. DesiredGeneration counts ACCEPTED desired-state writes. It starts at one, because creating a session names its desired placement; it does not move on a Host write, on an idempotent replay, or on a key reused for a different intent, all of which apply nothing. It is refused at the uint64 ceiling rather than wrapped, because a wrap lands on a lower value that reads to every controller as a desired state it has already reconciled.

A desired-state write REPLACES the desired members wholesale, as the Host-owned projection write replaces its own: moving a session back to pooled by naming no workload clears the workload. The one identity it cannot touch is AgentID — the request type has no member for it — because every journal record, workspace and runtime-compatibility decision the session has is downstream of it.

PlacementIntent carries no lease epoch, no HostID, no endpoint, no residency and no journal position, and a test reads the source of placement.go and fails if any type there grows one. Observed placement is the registry's tuple, fenced by an epoch this projection structurally cannot name.

Recent-first pages are one ranked query

ListSessions returns a Core SessionPage from a single ListRanked call. The tenant is the ranking scope, so the tenant restriction and the recency order are both inside the provider query and the limit applies to a result that is already restricted and already ordered. Nothing enumerates a key prefix, sorts a catalog, or narrows a wider page afterwards; those cost work proportional to a tenant's history rather than to the page, and a picker renders on every visit.

Unlike a direct get, a listing does not verify the tenant's collision witness: it names no session, and a tenant that has never created one has no binding to prove, so requiring one would answer "this tenant is empty" with a failure. Separation instead comes from holding every returned record to the tenant it itself claims, so a scope two tenants somehow shared fails the page closed rather than disclosing a row.

A page cursor is a versioned SessionStore envelope wrapping the provider's own opaque token. The envelope binds the token to the tenant it was issued for and tags it as a catalog page, so it can be moved neither between tenants nor into a journal read even when the provider underneath does not bind its own cursors, and a caller retains a SessionStore token rather than a provider one. Pagination resumes from the provider's frozen (rank, stable_key, ordering_scope) position rather than from a snapshot, so a session whose recency changes mid-walk may repeat or be skipped; a sweep that must see every session once reconciles by identity, not by page.

Open gates: one projection, one deadline index

The catalog record is the only store of open gates. ReadGates returns Core's GatePage from that one record, in the record's canonical (opened_seq, gate_id) order, with no cursor and no limit: a session's open gates are bounded by MaxCatalogOpenGates, so the whole answer is one bounded read rather than a walk.

OpenGate and ResolveGate add and remove one gate at a time and also maintain a deadline INTENT: one ordered record per open gate whose due state is the gate's absolute deadline, carrying its identity and opening event and nothing else. It is an index, not a second copy of the projection — the catalog cannot answer "what is due" without reading every session, and the ordered index's due view can, deployment-wide, in pages proportional to what is due.

The two writes are ordered, and the order is the durability argument. An open makes the intent durable before it commits the projection; a resolve clears the projection before it retires the intent. So the only state an interrupted operation can leave is an intent with no matching open gate, never a gate open with no durable deadline. ListDueGates is the reader that closes that: it validates every due intent against the session's durable open projection and drops the ones that match nothing. It is a bounded read that takes no action — what a Host does about an expired gate is gate continuation, which this package deliberately does not implement; see "Gate continuation is deferred" below.

ListDueGates reads ONE control shard and takes a continuation; see the shard section below. A remnant intent — one whose gate the session's durable record no longer projects — is REPORTED rather than dropped, in DueGatePage.Remnants, with the revision a retirement names. It is not retired by the reader, and it cannot be: OpenGate writes the intent before the projection, so an intent with no matching open gate is indistinguishable, in its bytes, from a gate being opened right now.

Without a resume position that would be permanent head-of-line blocking. The due view is deadline-ordered, a remnant's deadline is in the past and never changes, and a Host that re-projects wholesale produces one remnant per gate it drops — so Limit remnants at the head of the order would mask every live gate behind them indefinitely, and a few hundred ordinary re-projections could silently switch expiry off. NextCursor is the fix, and a page budget would not have been: bounding a pass's cost does nothing about the row that is blocking it. The continuation steps PAST a row that reported nothing, so a caller that pages a shard to exhaustion sees every due row in it.

A gate that is genuinely open and past its deadline is different: it stays in the view and is reported on every fresh pass, because it is current due work that nothing has dealt with. It does not block, because the continuation moves past it within a pass.

DueGatePage still reports Examined, Unreadable and the effective Limit. They answer a different question from the continuation: whether a full page reported nothing, and whether rows were skipped because they could not be read at all. An unreadable row is SKIPPED and counted rather than failing the page — failing on one would switch gate expiry off for every tenant in the shard until someone repaired the row by hand.

Retiring an intent is a tombstone rather than an erasure: the record stays readable for audit, its identity can never be reused to reopen the same gate, and a tombstone is not due by the provider's own contract, so it leaves the due pages without this package maintaining a flag.

UpdateCatalogHostState still replaces the whole open-gate projection and deliberately leaves intents alone: it is the Host's re-projection path, not an incremental gate edit. A gate projected only that way is readable but has no deadline index, and a gate dropped that way leaves a remnant intent the due reader discards.

Gate continuation is deferred, and that is a decision rather than an omission

Say it plainly, because an omission here would read as a guarantee. Multiple gate projections per session are durable and readable, and nothing in this package claims a continuation for any of them.

What IS implemented and tested: a session may hold up to MaxCatalogOpenGates open gates at once; OpenGate refuses the one past that ceiling (TestOpenGateRefusesMoreThanTheProjectionHolds); ReadGates returns all of them from the one catalog record in the record's canonical (opened_seq, gate_id) order, with no cursor and no limit (TestReadGatesOrdersManySimultaneousGates); every gate opened through OpenGate has a durable deadline intent, with the one exception stated immediately above — a gate projected only through UpdateCatalogHostState is readable but has no deadline index; and ListDueGates reports the intents whose deadline has passed, validated against the durable open projection.

What is NOT implemented: anything that decides what happens next. ListDueGates is a READ — it takes no action, cancels nothing, suspends nothing and schedules nothing, and its doc comment says so at the function. ResolveGateRequest records only that a gate is no longer open and awaiting an answer: it carries no response, decides nothing about what the session does next, and starts no continuation. There is no "resume the session from gate G" call anywhere in this package, and no durable record of one.

The one piece of scaffolding that exists is a NAME: pointers.go defines an active-continuation pointer role alongside the workspace and runtime checkpoint roles — the same Set/Get/Clear triple over an object reference, with the same two fences and nothing else. Nothing in this package reads it to decide anything; a caller that writes one gets durable storage and no behavior.

So a Host that finds an expired gate has a durable, correctly ordered account of what is open and what has lapsed, and must supply the policy itself. Deciding what that policy needs is a later task, and the reason a record for it was not added early is that a half-specified continuation record is exactly the kind of second copy of the projection that the deadline index is deliberately not.

Command admission: one create, one immutable acceptance order

AdmitCommand makes one client command durable and reports whether this call is the one that accepted it. It is exactly one OrderedIndex.Create, filed in the inbox namespace under (session ordering scope, raw CommandID), with the apply deadline as the record's due state and no rank.

Identity is (TenantID, SessionID, CommandID). The session is part of it, so the same client command id in another session — or another tenant — is a different command, and a duplicate within one session is a retry rather than a new acceptance. Because Create is atomically idempotent by identity, the duplicate case needs no read of its own: a loser receives the winner's canonical stored record.

The runtime mapping is allocated once. A caller PROPOSES a RuntimeCommandID and must then use the one the returned record carries: racing replicas legitimately propose different values, and only the winner's is stored, returned and used.

A duplicate whose command CONTENT differs — kind, inline payload, or referenced payload object — fails closed with InboxErrorCommandMismatch, because silently returning the first command's record would tell a caller its command was accepted when nothing of the kind happened. Everything else is deliberately excluded from that comparison. The proposed runtime id is excluded because disagreeing about it is the expected outcome of a race. The accepted instant and apply deadline are excluded because a retry carries a fresh clock reading, so comparing them would turn every real retry into a mismatch. The state, claim, result and rejection are excluded because by the time a retry arrives the command may already be applied or rejected, and that progress is not evidence that this retry differs.

For the same reason there is no "the deadline must be in the future" check: a retry of an unknown outcome may arrive after the original deadline has passed and must still be able to learn the mapping that was durably accepted. The deadline is validated as an instant and nothing more.

InboxErrorCommandMismatch is deliberately not called InboxErrorConflict. This package spells "conflict" two ways already — CatalogErrorConflict is a lost revision CAS (recoverable, retry after a re-read) and ObjectErrorConflict is a key holding different content — so the spelling is kept for the revision-CAS meaning the command transition machine uses when it compare-and-swaps this record, and the caller-caused case takes a name that cannot be mistaken for either.

InboxEntry.AcceptedOrder is the provider's immutable acceptance order, and it is exposed here where CatalogEntry's deliberately is not: consumers sort a session's bounded ordered page by it, and a retry must receive it unchanged as evidence that it is the same acceptance. It is an OPAQUE COMPARISON KEY. It is strictly increasing within one session's order scope, but it is not contiguous, not one-based, and not comparable across sessions: a provider may allocate it from a JetStream stream sequence or a shared SQL sequence, so a session's first command can be order 5000 and its second 9000. Nothing may derive a count, a position, or "the next" order from it.

created == true additionally holds the provider's reply to the bytes THIS call sent, which is the one claim the identity, scope, due and order checks cannot make: they hold a reply to the record's own bytes, and a substituted record satisfies them exactly as well as the real one. On a duplicate the stored bytes are the winner's and only the content is comparable, so the exact comparison is deliberately made on the created path alone.

Deleting a command is not reclamation: its identity and acceptance order can never be reused, so a tombstone is a permanent answer to any caller still retrying it. Whoever adds retention or compaction must bound terminal-command retention below by the client retry window.

Command transitions: one read, one revision CAS

ClaimCommand, BeginApplyingCommand, CompleteCommand and RejectCommand drive pending -> claimed(epoch, expiry) -> applying(epoch, expiry) -> applied | rejected, and GetCommand reads the record a caller re-decides against. Each transition is one Get and one Update of the same authoritative record at the revision the caller named, and it touches no other aggregate.

The epoch on a claim is the SESSION LEASE epoch the claimer acts under, not a number this aggregate allocates, which is what makes it meaningful to a Host and to a Factory replica reconciling the same command. An epoch BELOW the record's claim epoch is refused as InboxErrorEpoch carrying the high-water mark, which is hostEpochFence's rule deliberately. Where the two fences differ is the equal case: the catalog fences a record one lease owns, so one grant writing many times is normal, while a claim fences a work item two writers under one epoch may both reach for and the record carries no claimant identity to tell them apart. So an equal epoch may not take a LIVE claim, only a lapsed one, and a strictly greater epoch may take either — its predecessor is provably fenced out of the journal, and stalling every claimed command for a claim TTL on every failover buys nothing.

A claim cannot be RENEWED. A claimer that needs more time must enter applying before its claim lapses; re-claiming under the same epoch is refused while the claim is live and, once it lapses, is open to every writer at that epoch or above. MaxCommandClaimTTL bounds how far ahead of the store's clock a claim may expire — a ceiling on caller error and clock skew, not a policy TTL. It exists because a claim may legitimately outlive the apply deadline while inboxDue caps the due horizon AT the deadline, so an over-long claim leaves the row due and settleable by nobody for the claim's whole life; unbounded, that is centuries.

applying may be entered only by the holder of a live claim, and it has no deadline check: an unexpired claim wins the deadline race, which is what stops a reconciler's clock from cancelling work about to commit. CompleteCommand requires the claim's epoch but NOT a live claim, because it records an effect that is already in the journal and refusing it would strand a committed application behind a lapsed TTL. RejectCommand keeps the live-claim requirement, because it decides something that has not happened; its lease epoch is optional, and a caller that names none is the deadline reconciler, which may settle a pending or lapsed-claimed command and nothing else.

Consuming a session's commands: an immutable stream and a durable cursor

These operate on the DISPOSITION command family, and that is not an arbitrary choice. A session's protocol mode is a create-only immutable pin, and AcquireResidency refuses any session whose catalog binding is not ProtocolModeDisposition. So every session anything can hold a residency over — and therefore every session anything can consume commands for — is disposition-bound, and the legacy inbox (sessionstore/inbox) is unreachable from a consumer. A per-session listing and cursor built over the legacy family would be correct code that no consumer could call: the cursor's first write would try to pin legacy on a scope already pinned disposition and be refused with a catalog conflict, permanently, with no retry that can help. That is not hypothetical — it is what the first version of this pair did.

ListSessionDispositionCommands and ListDueDispositionCommands read the SAME inbox rows through two different provider views, and neither is derivable from the other.

ListDueDispositionCommands answers "what in this SHARD needs attention by this instant". It is cross-session by its request type, ordered by a deadline, and a settled command leaves it altogether because dispositionInboxDue files one NOT DUE. Every one of those is right for a reconciler and wrong for a consumer.

ListSessionDispositionCommands(tenant, session, afterOrder, limit) answers "what has this SESSION accepted, in the order it accepted it, after here". The bound is the caller's and the ordering is the store's: a consumer that sorted a page for itself would be inferring an order rather than reading one. Settled commands stay in the stream, which is what makes a cursor meaningful at all — a stream that dropped them would make a cursor name a row it can no longer produce.

Its cost is the page plus one catalog read, not one per row, because every row belongs to the one session the caller named; the due sweep is handed rows from many sessions and has to ask the binding question per row. That catalog read is also the authority check and the witness verification.

It fails closed on a row it cannot vouch for, which is the deliberate divergence from the due sweep. The due view counts an unreadable row and steps over it, because failing would switch reconciliation off for every tenant in the shard. Neither half of that argument holds here: a consumer handed a page with a row quietly missing would act on what it received and then advance its durable cursor PAST the row, so the command would never be applied and nothing would look at it again — and the blast radius of failing is one session rather than one shard. The cost is real and is not hidden: one unreadable row stops that session's consumer at that row, there is no skip, no quarantine and no reporting channel, and this package offers no repair operation for a command row — the remedy, if there is one, is a provider-level act outside this module.

LoadDispositionCommandCursor and SaveDispositionCommandCursor are the durable consumption cursor — one permanent, epoch-fenced row per session, in its own unsharded namespace, whose name carries the disposition family because an acceptance order from one inbox means nothing in the other. LoadDispositionCommandCursor answers the ZERO ENTRY for a session that has recorded none, because "nothing has been consumed" is exactly the starting position of a fresh consumer. That answer is narrow in two directions: an undecodable stored row is NOT an absent cursor, it is a fence that cannot be evaluated; and a session with no disposition catalog is REFUSED rather than answered zero, because both operations take the disposition family's authority check exactly as GetDispositionCommand does.

A save is fenced twice, epoch first and position second. The case that decides the ordering is a caller below both marks, and it is worth naming precisely because the obvious candidate is the wrong one: a caller with a low epoch and a high position passes the position fence and is refused by the epoch fence, so it is told the same thing under either ordering. The caller whose answer changes is the superseded lease that also holds a stale position. Epoch-first tells it "you have lost the session", which is terminal and correct; position-first would tell it "your position is stale" and invite it to fetch newer data and retry forever against a session it no longer owns.

An equal epoch and an equal position are both admitted, so one grant may save many times and a save retried after an ambiguous outcome succeeds. The write itself is a revision compare-and-swap, so concurrent savers under one epoch are ordered by the provider and the loser is told to retry rather than overwriting the winner; a lost create race is the same answer, for the same reason.

The two fences and the compare-and-swap have exactly three answers between them: you have lost the session (epoch), your position is stale (order), or you raced (conflict). That is a claim about the fences, not about the call — reaching a fence at all requires a well-formed request, a session this store will vouch for, and a stored row this store will vouch for, and each of those has refusals of its own: invalid for the request, a *CatalogError for a session that is absent or bound to another protocol, malformed / version / too_large for a row that does not decode, deleted for a provider tombstone, backend / unknown for the provider, and identity for a row that decodes and is still refused — another session's bytes, a wrong stable key, ordering scope, rank or due state, or a write whose reply is not the bytes it sent. That last one is reachable from a perfectly well-formed request against a perfectly readable row, which is why "well-formed request against a readable row" is not the right scope either. A consumer's classification must cover all of them and must have a default arm; this list is a residue, not a closure.

The cursor is consumption context, not authority, in the same sense SettlingResidencyEpoch is settlement context. It does not prove any command was applied — a consumer that rejected three of ten writes the same row as one that applied all ten, and the authoritative state of a command is its own record's. It does not prove the saver held a LIVE residency at the swap; no path in this package reads a live lease on this record, so read the stored epoch as "who asked, at or above the mark". It authorizes nothing. It says nothing about commands above it. It is not comparable across sessions or protocols. And zero does not prove nothing was consumed — it proves nothing was RECORDED, so a consumer that crashed before its first save leaves zero, and a successor re-presents work it is relying on being idempotent by identity.

Recovering an application from the journal

FindCommandApplication correlates one command's record with its session's journal and reports what the journal PROVES about its application, which is what lets an applying record whose claim has lapsed be settled at all. Correlation is against the identities in the durable record, never against identities a caller supplies, and all three of them must agree: the public CommandID, the RuntimeCommandID the inbox mapped it to (compared as a decoded UUID value, not as text), and the command kind. A prefix that names the command under a different runtime identity or kind is conflicted, not absent — treating a broken mapping as absence is what would let a command whose effect committed be rejected.

Two things make a negative answer safe, and both are durable state rather than an assertion by the caller asking for the settlement. ADJACENCY: a prefix belongs immediately before its effect, so the record at prefix+1 is the whole question — a public event means committed, an opening fence above the prefix's epoch means abandoned, and anything else, including nothing yet, means unresolved and refuses both settlements. FENCING: journal grants are strictly increasing and an opening fence is committed by CAS at the tip, so a fence above an epoch proves that lease can never append again.

CompleteCommand therefore admits a strictly greater epoch on an applying record when the correlation is committed and the result it records is that correlated effect. The apply deadline takes no part in it: finishing a durable application is continuation, not a new claim, and ClaimCommand still refuses at or after the deadline. RejectCommand admits only a correlation that proves no effect committed, for EVERY caller and state — the claim holder standing on its own committed effect is refused exactly as a late reconciler is — and an expired applying record additionally needs a lease epoch above the claim's AND a journal fence above that same epoch. The correlation walks the session's stream from its first record on every rejection; that cost is deliberate and unconditional, because a check the slow path performs and the fast path skips is how a committed effect gets overwritten.

That narrows the head-of-line hazard the previous section used to leave open: an expired applying record was settleable by nobody, forever, and a conforming one is now settled by the next lease holder — whose own OpenJournal writes the fence that makes its evidence conclusive, so the row clears when the session is next attached. The permanent hazard was RELOCATED rather than eliminated. A due-command reader should size its examined-versus-returned signal for three sources: a live claim outliving the deadline, bounded by MaxCommandClaimTTL; an unresolved correlation in the crash window between a prefix and its effect, bounded by re-attachment; and an unresolved correlation caused by a writer-contract violation, bounded by nothing at all, because the record at prefix+1 is already durable and will never become the effect or the fence.

Three obligations fall on a Host and none of them can be checked here, so inbox_recovery.go states them where an author will look. A prefix is committed immediately before its effect, one application at a time — so a prefix is a COMMITMENT to append the effect next. If that append fails, the prefix is already durable and the command reads unresolved, so it can no longer be rejected under the claim that wrote it; the only route out is to drop the journal grant, reopen (which fences the prefix into abandoned), wait out the applying claim's own expiry, and reject at the higher epoch. Do NOT complete over it: nothing checks a same-epoch result against the journal, so a fabricated event id is accepted and becomes the command's durable outcome. Every runtime-visible effect gets a prefix — an effect without one reads as absent and can be rejected over, which is the one part of the writer contract adjacency cannot enforce. And a Host must not append a prefix for a command whose claim epoch is below its current grant: losing the lease abandons in-flight applications, and re-attaching does not resume one. The fence proves a GRANT is dead, not that a Host stopped writing, so a Host that re-attaches and carries on applying commits the very fence that makes its own unfinished work look settleable.

A command's due state is derived from the record rather than from the operation writing it. A non-terminal command is due at the earliest instant something must look at it again — its apply deadline, or a claim expiry that lapses first, so a crashed writer costs the command a claim TTL rather than a rejection at its deadline — bounded above by the deadline, so the reconciler's page can never miss a command whose deadline has passed. A terminal command is not due at all and stays directly readable by its stable key. validateInboxState states, on the record, which members each state must and must not carry, which is what makes applied and rejected structurally exclusive rather than merely sequenced.

InboxEntry.CommandStatus projects the durable record onto core's public CommandStatus. The five durable states map onto four public ones by treating an UNCLAIMED pending command as accepted — core's own definition, "the inbox commit succeeded", is exactly what this store knows about a command nobody has picked up — and claimed/applying as pending. Claim liveness deliberately does not enter into it: a public caller cannot act on it, and it changes with a clock rather than with the command.

The Host registry: an expiring route over a permanent fence

PutHostRegistration publishes where one session is currently running: (host_id, host_generation, agent_id, runtime_compatibility_id, placement, internal_endpoint, residency, accepting) together with the writing lease epoch, the Host's observation instant, and the instant the observation lapses. It is one OrderedIndex record per session, filed in the session's own namespace, unranked and never due, and it is read and written directly rather than listed. HostRegistration.Observation projects it into Core's HostLinkRegistryObservation, which is also the record's validator: Core owns what a Host route means, so this package does not restate the endpoint, placement and residency rules and cannot drift from the peer that applies them.

The record has two halves with opposite lifetimes. The ROUTE expires, and a reader past ExpiresAt is refused it: the Host that published it may have died at any instant since, and nothing will tell the record. The LEASE EPOCH never expires — it is the high-water mark that refuses a superseded Host's write — and that is why an expired or released registration is retained rather than deleted. Dropping the row would drop the fence.

These rows are permanent. One per session that has ever been registered, kept forever, with no expiry sweep and no deletion path anywhere in this package. That is affordable rather than a debt: a registration is never listed, never ranked, never due, and never read except by name, so a session that ran once and stopped costs a few hundred stored bytes and nothing at all to every reader. It is also not optional — the row IS the session's fence, so the retention is what makes the epoch mean anything.

The consequence for anyone adding retention later, stated on HostRegistration as a carry-forward contract and repeated here because this is the document a sweep's author reads first: the only safe reaper is one that removes a session's whole scope at once — this record, its catalog record, its journal, its commands, and its collision witnesses. Deleting this row alone destroys the fence while leaving the session registrable, which is the exact state the retention exists to prevent, and a sweep that walks record kinds independently and reclaims the cheapest first will reach this one first. There is no partial version of this that is safe.

So the two reads are deliberately different functions. GetHostRegistration reports not_found, expired, or released and carries no tuple in any of the three: a router cannot bind to a Host this store will not vouch for. expired and released do carry the retained lease epoch on the error, because those two codes are the whole public account of a session with no route — they are what a retention decision is made from, and the fence is the one durable fact left to check that decision against. Every write instead reads the RAW record, expired and released ones included, because a writer that believed the public reader would create a fresh record over a row it could not see — and creating a fresh record is exactly how a fencing high-water mark gets reset to whatever a superseded lease named. A stored record that cannot be decoded is likewise a failure and never an absence, for the same reason.

ClearHostRegistration releases a session by writing a tombstone under the same fence, never by deleting the row. A tombstone is a registration with no route at all, which is one nil rather than an enumeration of cleared members, so a released record cannot be routed to however its timestamps read or whatever clock the reader is configured with. Cleanup is idempotent under one grant and returns the stored tombstone without writing; a LATER grant releasing the same session is not a repeat and rewrites the tombstone, because otherwise the fence would stay at the older epoch and every lease granted in between could still write. A session that was never registered is not_found: cleanup is idempotent with respect to its own tombstone, not with respect to nothing.

Three members of this record also appear on the catalog record, and in each case the catalog holds Factory-authored DESIRED state or a durable status projection while the registry holds the Host's OBSERVED answer: desired placement against the admission model the session is actually running under, the last known residency against the routable residency that disappears with the route, and the desired runtime against the runtime the running Host actually loaded. The lease epoch appears on both because each record carries the epoch its OWN writes are fenced at; neither is derived from the other and they advance independently.

The Host target directory: derived capacity that rows leave

PublishHostTarget advertises what one Host can currently take for one target: (agent_id, runtime_compatibility_id, placement) names the target, host_id completes the row's identity, and the offer itself is (internal_endpoint, isolation_class, accepting, available_capacity) plus the Host's observation instant and the instant it promises to heartbeat by. One Host serving several targets publishes one row per target; that is derived capacity, not a competing catalogue of what agents or runtimes exist. HostTarget.Report projects a row into Core's HostLinkCapacityReport, which is also the record's validator, exactly as the registry delegates to Core.

It is the opposite of the registry in almost every way, and deliberately so. The registry is one permanent, unranked, never-due row per SESSION whose lease epoch is that session's ownership fence. This is one row per (target, host), ranked by free capacity, due at its own heartbeat expiry, and REMOVABLE — rows here must actually leave, or a crashed Host permanently occupies the front of every placement page.

A row never proves session ownership. There is nowhere in it to say so: the record names no tenant, no session and no lease epoch, and neither does the projection a placement page publishes. HostGeneration is a write-ordering high-water mark over one row — host_id is part of the identity, so the only writers it can ever compare are incarnations of ONE Host — and what it prevents is a dead incarnation overwriting live capacity, which is a liveness fault. The error code for it is generation rather than epoch for exactly that reason. TestHostTargetsCannotSpellSessionOwnership reads the source and fails if any type in this record's family grows a tenant, session, or lease-epoch member.

Both views are functions of the RECORD, in one function each. A row is ranked by available_capacity when its Host is accepting, and is UNRANKED when the Host has stopped accepting or when the row is withdrawn; it is due at its expiry when it is advertised, and NOT DUE when it is withdrawn. Withdrawal is one nil pointer rather than a set of cleared members, so "withdrawn" leaving both views is a property of the record's shape rather than a rule a writer has to remember.

Three things remove a row from the placement page, and nothing else does:

  • A graceful drain. DrainHostTarget writes the withdrawn record, which leaves both views in one compare-and-swap, at the instant a Host decides to stop rather than when its promise runs out.
  • The due reconciler. ReconcileHostTargets is a SERVICE operation — it names no tenant and no target and sweeps the whole directory's deadline view — and it is the only thing that removes a CRASHED Host's row. A deployment that never calls it accumulates ranked capacity that no longer exists.
  • Nothing else. In particular ListCompatibleHosts does not: it declines to publish a lapsed row and counts it in LapsedSkipped, so a caller is never handed an endpoint this store will not vouch for, but the row stays ranked. A nonzero count means the directory is owed a sweep.

No single row can fail a bounded page, anywhere in this package. Every per-row refusal is counted and stepped over — lapsed ones in LapsedSkipped, undecodable and misfiled ones in UnreadableSkipped — and that is the strongest rule in this record rather than leniency. Nothing here ever rewrites a row it cannot read, because a newer writer may have produced it; so a reader that failed the whole page on one would take every Host serving that target out of service for as long as the row existed, which is forever, with no recovery path anywhere in the system. Skipping leaves the newer writer's row untouched and starts publishing it the instant a reader that understands it asks. A failure returned from ListCompatibleHosts is therefore always about the query — a bad limit, a foreign cursor, a provider that could not answer — and never about one row.

ListDueGates and ListSessions obey the same rule, and they were changed to. Both used to fail the whole page on one row, and both were reachable states with no way out: a single undecodable gate intent sits at the head of an ascending deadline view whose head could not be skipped past within a pass, so it disabled gate expiry for every tenant permanently; a single undecodable catalog row made a tenant unlistable and, because a failed page issues no continuation, took every session ranked behind it too. They report DueGatePage.Unreadable and SessionPage.UnreadableSkipped. SessionPage is this package's own type embedding Core's, added for exactly that count.

Nothing on this record's paths calls the provider's Delete, and it cannot: the ordered index promises an identity is never reusable after a tombstone, while a Host that drains at shutdown and advertises again at startup reuses this identity as a matter of course. A withdrawn row is therefore retained and REUSED, which is what makes a restart work.

The reconciler revalidates each row's own stored expiry before writing anything and compare-and-swaps onto the revision the due page reported. The due view is weakly consistent, so a Host may have heartbeated since the page was read; the two checks together mean such a Host either presents an unlapsed expiry, in which case the sweep leaves it alone, or has already advanced the revision, in which case the write loses. A sweep that trusted the page alone would withdraw the capacity of a Host that is alive, and StillLive counts the rows the revalidation saved.

A sweep queries at one due bound for its whole walk, because a due cursor binds to the exact bound that issued it; the bound therefore travels in the sweep's own continuation, while the instant each row is revalidated against is read fresh. The two are deliberately allowed to differ: the bound decides only which rows a page contains, the revalidation decides whether any of them may be withdrawn, and a fresh reading is monotonically at or after the bound, which is the safe direction. Carrying the bound therefore costs nothing in safety — a bound this store never issued can at worst make the sweep look at rows it then declines to touch.

A row the sweep cannot decode stays due, so it heads every later ascending due page. It is counted in Unreadable and deliberately NOT rewritten: it may be a newer writer's row, and un-ranking that during a rolling upgrade would take live capacity out of service on every pass. The page budget alone does not make that survivable, and believing it did is how this record nearly shipped with the head-of-line failure its deadline view exists not to have. A budget bounds the work one pass does; it says nothing about progress, because every pass restarts at the head of the same view and nothing removes an unreadable row, so that population only grows — once it reaches MaxPages × Limit, every later pass spends its whole budget on those rows and withdraws nothing, forever. What supplies progress is the CONTINUATION on the result: a sweep that runs out of budget reports where it stopped, and a caller that pages until Exhausted reaches every row however many unreadable ones precede them.

There is no in-band repair for a genuinely corrupt row. A row that is not a newer writer's — one whose bytes are damaged rather than merely unfamiliar — is permanent: it cannot be withdrawn, because a withdrawal is built from the record's own decoded identity; it cannot be republished, because a Host names a target and a host rather than a row; it cannot be swept, for the reason above; and Delete is correctly unusable here, because it would retire that Host's identity for the target forever. So a persistently nonzero Unreadable or UnreadableSkipped is not something a retry, a sweep, or an operator command resolves — it needs a build that understands the row, or direct provider-level intervention outside this package. The counts exist so that state is visible rather than silent; they are not a queue that drains.

ListCompatibleHosts is one ranked provider query per page: the target is the ranking scope, so the restriction and the capacity order are both inside the query. It deliberately does not verify the target's collision witness, which the writes bind — a listing names no row, and a target nothing has ever advertised has no witness to prove, so requiring one would answer "no capacity" with a failure. Cross-target safety comes from below instead: every row a page returns is held to the target its own bytes claim.

Reconciliation claims: duplicate suppression that is never a fence

Any Factory replica may reconcile any session. AcquireReconciliationClaim takes a short-lived claim on one session first, so the other replicas that noticed the same due work do something else instead of scaling the same session several times over. ReleaseReconciliationClaim gives it back early and GetReconciliationClaim reports it, and only while it is live.

What makes concurrent reconcilers safe is not this record. Deterministic command IDs, idempotent desired state, and the Host lease already do that with no claim in sight; a replica that ignored this record entirely would produce correct results and merely duplicate effort. The claim makes those mechanisms cheaper to rely on and nothing else.

That is enforced structurally rather than documented, in three ways:

  • The record cannot name ownership. There is no lease epoch, no HostID, no endpoint, no residency and no journal position on it, and no epoch member on its error type. HolderID is a plain string rather than a sessionwire identity, so a HostID cannot be passed for it by accident. TestReconciliationClaimCannotSpellSessionOwnership reads the source and fails if any type in the record's family grows one.
  • Nothing else in this package reads a claim. No other operation takes one, checks one, or refuses without one, so there is nothing for a claim to license. TestNothingInThisPackageReadsAClaimToDecideAWrite derives the set of names reconcile.go declares, parses every other production file, and fails if one USES any of them. It reads the syntax rather than the text, so a doc comment naming an operation is not mistaken for a call to it — which is why the derived set has to come from the declarations rather than from a list of prefixes: none of the three operations begins with ReconciliationClaim, so a prefix list missed the only names another file would actually call.
  • Acquiring is not required to do the work. It is advice with a deadline.

The row's shape follows the Host registry's — one per session, filed in the session namespace, unranked, never due, read and written only by name — and it is never deleted. The only ordered-record tombstones this package writes anywhere are the ones that retire a gate deadline intent — ResolveGate's and RetireGateDeadlineIntent's — and this is not one of them. There is no sweep, and therefore none of the head-of-line hazards a due view brings: a claim stops being a claim at its expiry, from its own bytes, at the instant a reader asks, and the next acquisition overwrites it.

ClaimedAt is the store's clock and ExpiresAt is the holder's promise, bounded by MaxReconciliationClaimTTL. The bound matters even though a stuck claim causes delay rather than incorrectness: the failure is invisible, so nothing would ever report it, and unbounded it would take a session out of reconciliation for the life of the deployment. A release writes a claim whose expiry equals its claim instant, which has lapsed on arrival — "released" and "expired" are one state, so no reader has to know which it is looking at — and a repeated release is a success that writes nothing, because a caller cannot tell a lost reply from a failure. A claim that is not the caller's is refused with held while it is live and lapsed once it is not: the first says wait, the second says nobody is working and there is nothing of yours to release.

Fixed control shards: bounded, cross-tenant reconciliation

Reconciliation asks "what work is due anywhere?", which is a question about wall-clock time rather than about a tenant. OrderedIndex.ListDue answers exactly that and is NAMESPACE-WIDE — it takes no scope — so the unit a sweep can address is a namespace, and a control shard is therefore a namespace suffix. Outstanding records — inbox commands and gate deadline intents — are filed in <base>/<four hex digits>, chosen by a stable domain-separated hash of (TenantID, SessionID). Their ORDERING SCOPE is unchanged: still the session's physical namespace, so two sessions that hash into one shard cannot collide, and every named read and write still works from the session's own scope with no lookup.

The count is FIXED AND PERSISTED, in the backend's layout marker beside the layout and the key algorithm. WithControlShards names it at Open, the marker is compared for byte equality on every later Open, and a mismatch is refused with KeyspaceLayoutMismatch before any session I/O. That refusal IS the migration constraint: the count is an input to the placement hash, so a deployment that reopened a populated backend with a different one would file new records in shards no sweep of the old count visits and look for existing ones where they are not — a silent, unbounded loss of reconciliation with nothing to report it. Changing the count for a populated backend is an offline migration that moves the records.

ListDueCommands(shard, before, limit, cursor) and the sharded ListDueGates are the queries. Their cost is the page: rows come from one namespace's due view, so a terminal command (inboxDue files it not_due) is not in the view at all, a historical session contributes nothing, and the tenant count does not appear. Nothing on either path reads the catalog to FIND work or enumerates a session's inbox. A sweeper visits every shard round-robin and pages each to exhaustion; the store deliberately does not loop for it, because one call sweeping every shard would put the whole deployment's reconciliation behind one request's latency.

Both are cross-tenant and must never be reachable by a tenant principal. THERE IS NO CAPABILITY GATE IN THIS PACKAGE — SessionStore takes identities as data and authorizes nothing — so what "service-only" buys here is a prose guarantee plus a structural hint: a sweep request names no tenant and no session, so there is no tenant identity for a handler to forward and nothing to build one from. That is weaker than an enforcement and is written plainly rather than implied.

RetireGateDeadlineIntent removes a remnant, and it cannot carry even that hint — it must name the session whose intent it retires. What protects it is revalidation. It re-reads the session's durable record at its own clock reading and refuses any gate still projected open; it CASes onto the revision the page reported; and it refuses an intent younger than MinGateIntentRemnantAge.

That last rule is the one worth reading twice. Inside the window between OpenGate's two writes, "crashed open" and "in-flight open" are the same stored bytes, and nothing derivable from them distinguishes the two: the deadline is caller-supplied and may already be past, and the opening sequence is at or below the tip in both cases. Elapsed time is the only discriminator, which is why the intent carries RecordedAt, stamped by the store. Retiring inside that window would tombstone a live gate's deadline under an identity that can never be reused. The comparison spans two processes' clocks and is skew-relative; five minutes is chosen far above any plausible span of the interval it covers — between the clock reading OpenGate takes and its projection commit: a mutex acquisition, a session-scope verification, a catalog read, the intent write and the projection write — and shrinking it without a real shared clock is how it becomes unsafe.

Every attempt re-stamps forward-only, and that is a safety requirement rather than bookkeeping. The first version stamped once, at creation, so a retry inherited the first attempt's instant — and the ordinary restart path (crash between the two writes, supervisor restarts the Host, retry arrives minutes later) began with the window already elapsed. A sweep landing in the retry's own gap could then tombstone the deadline of a gate about to become public, with no clock skew and no stalled process involved. commitGateIntent therefore re-stamps under a compare-and-swap when it finds a matching live row. It does make the window a rate limit on retries, and that is the right trade: opens arriving for a gate mean the gate is being opened, which is exactly when its deadline must not be removed. The other interleaving — a retirement landing before the retry's intent write — is covered by a different mechanism: the retry meets a tombstone and fails closed.

The ordering that was not chosen. The alternative is unarmed intent, then projection, then arm. It is genuinely safer in one respect: a due intent with no matching open gate is then provably a remnant, so retirement needs no clock at all, and a retirement racing an open that is about to arm makes that open fail loudly rather than silently succeed. It was rejected on cost, not on safety: it adds a second compare-and-swap to every open rather than only to a retry, and its repair sweep — "arm the unarmed intent whose gate is projected open" — cannot be driven from the due view, because an unarmed intent is by construction not due. That repair would have to walk sessions, which is the per-tenant scan the shard design exists to remove. The accepted ordering also has the safer crash polarity: its unrepaired window leaves a gate that is not yet public, where the inverted one leaves a public gate with no deadline in any due view.

What each absent answer licenses on that path is enumerated in the operation's doc comment, because this is a path where absence removes work. In short: an absent intent row is refused (not_found) because "already retired" has a durable spelling and it is a tombstone; a tombstone succeeds and writes nothing; and a session with no durable existence — absent record, tombstoned record, or unbound witness, exactly noSuchSession's set — PERMITS the retirement, because a session that does not durably exist cannot durably project an open gate. Every other failure reading the session stops the operation, and a witness bound to a DIFFERENT identity is a hash collision and is refused.

Object pointers: two high-water marks that a clear retains

A session accumulates immutable objects, and for each ROLE exactly one of them is current. pointers.go stores that choice: one OrderedIndex record per (session, role), filed in the session's own namespace, unranked, never due, read and written only by name, and never deleted. The roles are a closed set — workspace checkpoint, runtime checkpoint, and the active continuation the gate suspension plan will use — and each has its own Set/Get/Clear triple. The kind is spelled by the METHOD rather than carried in the request, so a caller cannot name a role this package has not defined, and because an ObjectID carries its own kind, SetWorkspaceCheckpointPointer refuses a runtime checkpoint reference before it touches a provider.

A pointer is a NAME. Nothing on these paths reads, writes, copies or deletes a blob — TestMovingAPointerNeverTouchesAnObject counts the blob traffic of a replace and a clear and requires it to be zero — so every object a session has ever had stays exactly where it was, byte for byte, however the pointer moves. There is still no caller-facing deletion path for objects anywhere in this package.

Two fences, in an order that is part of the contract. LeaseEpoch answers may you write: an equal epoch is admitted, because one lease grant checkpoints many times, and only a strictly lower one has provably lost the session. Sequence — the journal position the target was captured at — answers is this newer: an equal sequence is admitted, because one position can legitimately be captured twice, and only a strictly lower one is stale. The epoch is checked FIRST, and the two refusals ask for opposite responses:

code what it means what the caller should do
epoch your lease has been superseded stop; no retry under this epoch can succeed
sequence your lease is fine, your data is old re-read the newer capture, then write

Reversing that order would hand a dead lease a sequence refusal, which it would satisfy and retry forever. The sequence fence is the one the epoch cannot supply: two writes under ONE grant are ordered only by their revision compare-and-swap, so without it a losing writer that retried would reinstate its older checkpoint over the newer one and every restore afterwards would silently lose the work in between. Both refusals carry both marks, because a caller that has to raise its epoch will have to satisfy the sequence too.

Clearing writes a tombstone and retains both marks. ClearWorkspaceCheckpointPointer and its siblings never delete: they store a record whose target is nil — one nil rather than an enumeration of cleared members, so a live tombstone is unrepresentable — carrying the epoch that cleared it and the sequence it inherited. A clear is idempotent under one grant and returns the stored tombstone without writing; a LATER grant clearing an already-cleared pointer is not a repeat and rewrites it, or the fence would stay at the older epoch and every lease granted in between could still write. A role that was never set is not_found: cleanup is idempotent with respect to its own tombstone, not with respect to nothing, because writing one for a pointer that never existed would mint a fencing high-water mark out of an unverified caller-supplied epoch.

cleared and not_found license different next moves, and that is the whole reason they are two codes. not_found says no role record exists, so a first write may name any epoch and any sequence. cleared says the record exists and names nothing: the next write must still beat BOTH retained marks, and the error carries them so a caller need not discover them by rejected write. Neither returns the record. Retention blocks a strictly LOWER sequence and nothing more, so a writer holding the exact capture that was abandoned may set it again at its own position — a clear means "there is no current one, and nothing older than this may become it", not "that object is retracted".

The catalog's checkpoint summary is a projection of this record, not a second opinion. UpdateCatalogHostState replaces CatalogRecord.Checkpoint wholesale and zeroes it when a write omits it; that is deliberate and costs nothing precisely because the authoritative retained pointer lives here. SessionPointer.CheckpointSummary() is the intended path: it is the only way to build a summary from durable state, it refuses any role but the workspace checkpoint — the catalog validates a summary's reference as an opaque ObjectID and could not tell a runtime checkpoint from a workspace one — and a cleared pointer projects to the zero summary, which is how a clear reaches the catalog on the next projection write. Nothing on a pointer path reads the summary, so a stale or absent one changes nothing a pointer decides. The two records can therefore diverge, and the divergence is bounded by naming which is authoritative rather than by pretending it cannot happen: there is no cross-record transaction here, and CheckpointSummary is an exported struct, so a Host in another module can compose one from memory. Within this package a source guard holds the composition to the catalog's decoder and this projection; past the module boundary it is a convention.

One wrinkle a caller must know, and it is package-wide rather than the pointer's. "There is no pointer" reaches a caller as two different error TYPES. A session whose collision witnesses were never bound is refused by the keyspace — *KeyspaceError with binding_not_found — before any pointer record is consulted, because a derived record name is never trusted on its own; a bound session with no record of that role is *PointerError with not_found. TestPointerWritesBindTheSessionsWitness pins the distinction and asserts the CLASS of the refusal rather than merely that one occurred, since the two make different claims about the world. readHostRegistration behaves identically, so a caller handling both records needs the same two arms in both places.

These rows are permanent, one per role per session that has ever had one, and the registry's carry-forward contract applies here word for word: the only safe reaper is one that removes a session's whole scope at once, because deleting a pointer row alone destroys a fence while leaving the role writable at any epoch.

Documentation

Overview

Package sessionstore provides transport-neutral durable session persistence over Looprig's Core wire records and Storage primitives.

Index

Examples

Constants

View Source
const (
	// CatalogRecordVersion is the stored legacy catalog version. Bound records
	// use CatalogBindingRecordVersion; readers refuse all other versions.
	CatalogRecordVersion uint8 = 1

	// MaxCatalogOpenGates bounds the open-gate projections one catalog record
	// carries. The catalog is a replay-free status projection, not a gate
	// store: a session with more simultaneously open gates than this is read
	// through the gate API instead.
	MaxCatalogOpenGates = 16

	// MaxCatalogRecordBytes bounds an encoded catalog record. It is well below
	// storage.MaxOrderedValueBytes so a record that this package accepts always
	// fits in the provider, leaving no state that can be written but not
	// rewritten.
	MaxCatalogRecordBytes = 256 << 10
)
View Source
const (

	// DispositionCommandCursorRecordVersion is the independent version of the
	// stored cursor. A reader fails closed on any other version rather than
	// guessing which members a future encoder meant.
	DispositionCommandCursorRecordVersion uint8 = 1

	// MaxDispositionCommandCursorRecordBytes bounds an encoded cursor. The
	// record is two identities, two integers and an instant, so this is
	// generous by more than an order of magnitude; what it is for is that an
	// oversized record is refused HERE rather than by the provider, so a record
	// this package accepted can always be rewritten.
	MaxDispositionCommandCursorRecordBytes = 4 << 10
)
View Source
const (
	// EnvelopeVersion is the independent version of the raw journal frame.
	EnvelopeVersion uint8 = 1
	// MaxEnvelopeBytes is the maximum encoded frame size accepted or produced.
	MaxEnvelopeBytes = 1 << 20
	// MaxInlineBodyBytes is the maximum size of either independent inline body.
	MaxInlineBodyBytes = 512 << 10
)
View Source
const (
	// GateIntentRecordVersion is the independent version of the stored gate
	// deadline intent. A reader fails closed on any other version rather than
	// guessing which members a future encoder meant.
	GateIntentRecordVersion uint8 = 1

	// MaxGateIntentBytes bounds an encoded deadline intent. An intent holds
	// identities and one timestamp, never a prompt, so this is far above what a
	// legitimate record needs and far below the provider's own value bound.
	MaxGateIntentBytes = 8 << 10
)
View Source
const (
	// HostTargetRecordVersion is the independent version of the stored
	// advertisement. A reader fails closed on any other version rather than
	// guessing which members a future encoder meant.
	HostTargetRecordVersion uint8 = 1

	// MaxHostTargetRecordBytes bounds an encoded advertisement. Like the
	// registration's bound it is far tighter than the catalog's, because this
	// record has no open-ended member: it is a fixed tuple of bounded
	// identities, two instants, and three scalars.
	//
	// It allows for JSON ESCAPING, which is what sizes it rather than the sum
	// of the identity lengths. An identity is any valid UTF-8 of at most
	// MaxIDBytes bytes, control characters included, and Go escapes each of
	// those as \u00XX — six bytes for one — so the worst acceptable record is
	// about six times what an ASCII fixture measures. See
	// TestLargestAcceptableHostTargetFitsTheBound, which builds that record
	// rather than an ASCII one.
	//
	// It sits below storage.MaxOrderedValueBytes, so a record this package
	// accepts always fits in the provider and there is no state that can be
	// written but not rewritten. A heartbeat rewrites this row on a fixed
	// cadence forever, so a row that could be created but not updated would be
	// a row frozen at whatever capacity it last reported.
	//
	// On the ENCODE path the refusal is unreachable, and that is the point
	// rather than a gap: every member is bounded by Core's identity ceiling, so
	// the largest record the validators accept is a small multiple of it. What
	// holds the relationship is therefore not a test that reaches the branch —
	// none can — but the unsigned constant below, which fails to compile if the
	// bound ever exceeds the provider's, and
	// TestLargestAcceptableHostTargetFitsTheBound, which fails if the members
	// ever grow into it. On the DECODE path it is live: those bytes are not
	// this package's to bound.
	MaxHostTargetRecordBytes = 16 << 10
)
View Source
const (
	DefaultHostTargetReconcilePages = 16
	MaxHostTargetReconcilePages     = 1024
)

DefaultHostTargetReconcilePages is the number of due pages one sweep walks when a caller names no budget, and MaxHostTargetReconcilePages is the most it may name.

A sweep is bounded by pages as well as by page size because it must be able to STEP OVER a row it cannot handle. A row this sweep cannot decode stays due, so it sits at the head of every later ascending due page; a single-page sweep would spend every pass on that row and never reach the rows behind it.

THE BUDGET ALONE IS NOT THE ANSWER, and believing it was is how this record nearly shipped with the head-of-line failure its own deadline view exists not to have. A budget bounds the work ONE PASS does; it does nothing about progress, because every pass restarts at the head of the same ascending view and nothing ever removes an unreadable row, so that population is monotonically non-decreasing. Once it reaches MaxPages x Limit rows, every later pass spends its whole budget on them and withdraws nothing, forever.

What actually supplies progress is the CONTINUATION on the result: a sweep that runs out of budget hands back where it stopped, and a caller that pages until Exhausted reaches every row however many unreadable ones precede them. The budget then means what it says — a bound on one call — and HostTargetReconcileResult.Unreadable is what makes the cost of those rows visible rather than merely survivable.

View Source
const (
	// InboxRecordVersion is the independent version of the stored command
	// record. A reader fails closed on any other version rather than guessing
	// which members a future encoder meant.
	InboxRecordVersion uint8 = 1

	// MaxInboxPayloadBytes bounds an INLINE private command payload. A body
	// larger than this is stored as an object and referenced, which is what
	// InboxRecord.PayloadRef is for: the inbox is a control record that a
	// reconciler pages through, not a blob store.
	MaxInboxPayloadBytes = 64 << 10

	// MaxInboxRecordBytes bounds an encoded command record. Like the catalog's
	// bound it is well below storage.MaxOrderedValueBytes, so a record this
	// package accepts always fits in the provider and there is no state that
	// can be written but not rewritten.
	MaxInboxRecordBytes = 256 << 10
)
View Source
const (
	// SessionPointerRecordVersion is the independent version of the stored
	// pointer. A reader fails closed on any other version rather than guessing
	// which members a future encoder meant.
	SessionPointerRecordVersion uint8 = 1

	// MaxSessionPointerRecordBytes bounds an encoded pointer.
	//
	// The record has no open-ended member. Two of its members are identities
	// bounded by sessionwire.MaxIDBytes, and the ceiling has to allow for JSON
	// ESCAPING of those, which is what sizes it: an identity is any valid UTF-8
	// of at most that many bytes — control characters included, which both
	// TenantID.Validate and SessionID.Validate accept — and Go escapes each of
	// those as \u00XX, six bytes for one.
	//
	// The target is NOT escaping-sensitive and is the reason this bound is
	// half the registry's rather than equal to it: an ObjectID that
	// parseObjectReference accepts is canonical lowercase ASCII of a fixed
	// shape, so it encodes one byte per byte.
	// TestLargestAcceptableSessionPointerFitsTheBound builds the worst case
	// over every declared kind and reports what it measures.
	//
	// Like the other bounds it sits below storage.MaxOrderedValueBytes, so a
	// record this package accepts always fits in the provider and there is no
	// state that can be written but not rewritten.
	MaxSessionPointerRecordBytes = 8 << 10
)
View Source
const (
	// ReconciliationClaimRecordVersion is the independent version of the stored
	// claim. A reader fails closed on any other version rather than guessing
	// which members a future encoder meant.
	ReconciliationClaimRecordVersion uint8 = 1

	// MaxReconciliationClaimRecordBytes bounds an encoded claim. The record has
	// no open-ended member — it is three identities and two instants — but the
	// ceiling still has to allow for JSON ESCAPING, which is what sizes it: an
	// identity is any valid UTF-8 of at most sessionwire.MaxIDBytes bytes,
	// control characters included, and Go escapes each of those as \u00XX, six
	// bytes for one. TestLargestAcceptableReconciliationClaimFitsTheBound
	// builds that worst case and reports what it measures, because a bound
	// measured with ASCII is a sixth of what it claims to be.
	//
	// Like the other bounds it sits below storage.MaxOrderedValueBytes, so a
	// record this package accepts always fits in the provider and there is no
	// state that can be written but not rewritten.
	MaxReconciliationClaimRecordBytes = 16 << 10
)
View Source
const (
	// HostRegistrationRecordVersion is the independent version of the stored
	// registration. A reader fails closed on any other version rather than
	// guessing which members a future encoder meant.
	HostRegistrationRecordVersion uint8 = 1

	// MaxHostRegistrationRecordBytes bounds an encoded registration. It is far
	// tighter than the catalog's and the inbox's bounds because this record has
	// no open-ended member: it is a fixed tuple of identities, each of them
	// bounded by sessionwire.MaxIDBytes.
	//
	// The ceiling has to allow for JSON ESCAPING, and that is what sizes it. An
	// identity is any valid UTF-8 of at most MaxIDBytes bytes — control
	// characters included, which both TenantID.Validate and validateOpaque
	// accept — and Go escapes each of those as \u00XX, six bytes for one. The
	// worst acceptable registration is therefore about six times the sum of its
	// identity lengths, which at 8 KiB was 95 bytes OVER the bound: records the
	// validators accept were refused here, and the comment that used to sit
	// here said a spelling near the ceiling was one nothing could produce. It
	// was measuring ASCII. TestLargestAcceptableRegistrationFitsTheBound now
	// builds the real worst case and reports the measured size.
	//
	// Like the other bounds it sits below storage.MaxOrderedValueBytes, so a
	// record this package accepts always fits in the provider and there is no
	// state that can be written but not rewritten.
	MaxHostRegistrationRecordBytes = 16 << 10
)
View Source
const (
	// DefaultControlShards is the shard count a store adopts when its backend
	// is first initialized and no count was named.
	DefaultControlShards = 16

	// MaxControlShards bounds the count. It is a ceiling on a REPLICA'S SWEEP
	// COST rather than on the provider: a sweep visits every shard round-robin,
	// so the count is a per-pass floor on the number of provider queries even
	// when nothing at all is due. It is also bounded below by the marker's
	// two-byte field and by controlShardToken's fixed width.
	MaxControlShards = 4096

	// MinControlShards is one — an unsharded deployment, which is a legitimate
	// configuration and the one a single-replica local Factory wants.
	MinControlShards = 1
)
View Source
const CatalogBindingRecordVersion uint8 = 2

CatalogBindingRecordVersion adds a required complete immutable binding to the catalog. Legacy records retain CatalogRecordVersion and their bytes.

View Source
const CatalogPublicCreateRecordVersion uint8 = 3

CatalogPublicCreateRecordVersion retains immutable create identity separately from mutable desired state. Older catalog decoders reject this version.

View Source
const DefaultJournalOverflowThresholdBytes = 64 << 10

DefaultJournalOverflowThresholdBytes is the encoded body size above which a journal body is uploaded as an immutable object and replaced in the record by a fixed-integrity reference. It is well below MaxInlineBodyBytes so an ordinary record stays small enough that a whole page of them fits inside one ledger read.

View Source
const DefaultJournalPageBytes = 1 << 20

DefaultJournalPageBytes bounds the resolved bytes one journal page may return. It exceeds MaxInlineBodyBytes, so a page always makes progress: the largest single public body a writer can commit still fits in one page.

View Source
const DefaultShutdownTimeout = 30 * time.Second

DefaultShutdownTimeout bounds provider cleanup after Store-owned work drains. It matches the remote provider drain bound used by the released NATS backend.

View Source
const DispositionInboxRecordVersion uint8 = 2

DispositionInboxRecordVersion identifies the disposition inbox codec. Legacy v1 records retain their original codec and PayloadRef equality. This version carries the claim, attempt and outcome of the settlement protocol as members absent until the state that requires them, so a pending record's bytes are exactly what they were before those states existed. It is a record format and not an authority: it supplies no claim, dispatch or settlement permission, and a further durable member still requires a version bump because decoding demands exact canonical re-encoding.

View Source
const MaxCommandClaimTTL = time.Hour

MaxCommandClaimTTL bounds how far ahead of the store's clock a claim may lapse. It is a ceiling on caller error and clock skew, not a policy TTL: a caller chooses its own TTL well below this, and nothing here is a recommendation of an hour.

It exists because an over-long claim is a durable liveness fault that one caller can commit alone. A claim may legitimately outlive the apply deadline — that is what lets an unexpired claim win the deadline race — and inboxDue caps the due horizon at the deadline, so from the deadline onward the command is DUE, is paged by every reconciler pass, and can be settled by nobody until the claim lapses. Unbounded, "until the claim lapses" is bounded only by rankableTime, which is centuries: one caller with a skewed clock parks a row in the deadline view for the life of the deployment. The bound turns that into at most one TTL, which is the same shape of exposure a crashed claimer already has.

It is stated as a package constant with no deployment knob for the reason MaxInboxPayloadBytes is: it is a bound on what this record may mean, not a tuning parameter, and a deployment that needed a longer one would be saying something about the machine rather than about its own capacity.

View Source
const MaxDesiredWorkloadPayloadBytes = 16 << 10

MaxDesiredWorkloadPayloadBytes bounds the opaque platform payload.

It is far below MaxCatalogRecordBytes, and the gap is the point. The payload is the only open-ended member a Factory controls on this record, so without a bound of its own an oversized one would be refused as "the record is too large" — a limit naming a member the caller did not write and cannot shrink. Bounding it here reports the member that was actually too big.

The value is sized for a workload SPEC rather than for workload data: a pod template with resources, a workspace policy, and labels is a few kilobytes. A payload wanting more than this is carrying data that belongs in an object, referenced by the spec.

A byte payload has no escaping worst case to allow for, unlike the identity tuples the registry and the target directory bound: Go's JSON encoder writes a []byte as base64, so the encoded cost is a fixed 4/3 of the payload plus the version label, and it cannot be inflated six-fold by a caller choosing control characters.

View Source
const MaxHostRegistrationTTL = time.Hour

MaxHostRegistrationTTL bounds how far ahead of the store's clock a caller may place a registration's expiry.

It exists because an over-long expiry is a durable ROUTING fault one caller can commit alone, and the damage is the opposite shape from a claim's. A claim that outlives its usefulness parks a row nobody may settle; a registration that outlives its Host is worse, because every reader treats it as a live route and keeps sending sessions to a process that is gone. The registration is refreshed by heartbeat, so a legitimate one is short — this is a ceiling on how long a single skewed clock reading can misroute a session, not a tuning parameter, which is why it is a package constant with no deployment knob for the reason MaxCommandClaimTTL is.

View Source
const MaxHostTargetAvailableCapacity uint64 = 1 << 20

MaxHostTargetAvailableCapacity bounds the free capacity one Host may advertise for one target.

It exists so that hostTargetRank is TOTAL. The rank a provider orders on is a signed int64 and the reported capacity is an unsigned uint64, so without a ceiling the conversion has an undefined region: a capacity above MaxInt64 converts to a NEGATIVE rank, which would sort a Host claiming absurd capacity BELOW every real one — a wrong answer that looks like a working directory rather than like a refusal. Bounding the input instead makes that region unreachable, and the ceiling is checked where the value enters the record, not where the rank is computed, so no future rank expression can reintroduce it.

The value is orders of magnitude above any real Host and is not a tuning parameter; a Host near it is already reporting something no process could serve.

View Source
const MaxHostTargetTTL = 15 * time.Minute

MaxHostTargetTTL bounds how far ahead of the store's clock a Host may place its next heartbeat.

It is tighter than MaxHostRegistrationTTL, and the asymmetry is the whole difference between the two records. A registration is consulted BY NAME, for one session a caller already knows about; a stale one misroutes that session. An advertisement is offered to EVERY placement decision for its target, and it is offered PREFERENTIALLY when its capacity ranks high — so a crashed Host that advertised generous capacity is the first row every placement page returns. This ceiling bounds how long a single skewed clock reading can hold that position before the row is even eligible to be reconciled away.

THE COUNTER-ARGUMENT, recorded so the next reader sees both. The ceiling is measured against THIS STORE's clock, so it binds two populations rather than one: a Host whose clock runs fast, and a perfectly-clocked Host whose heartbeat interval is simply longer than the ceiling. Whichever it is, that Host's row can be offered to placement for up to the whole window after the process behind it has died, and five minutes was argued for on exactly that basis. Fifteen is kept because the two populations pull in opposite directions: tightening the ceiling shortens the dead-endpoint window for the first, and REFUSES the second outright — which removes capacity rather than merely mis-offering it — and this package cannot see a deployment's heartbeat interval to tell them apart. The choice is a judgement call within a factor of three, it is pinned at both boundaries by TestPublishHostTargetBoundsTheHeartbeatPromise, and it is cheap to change: nothing derives from it and no stored record embeds it.

View Source
const MaxReconciliationClaimTTL = 5 * time.Minute

MaxReconciliationClaimTTL bounds how far ahead of the store's clock a caller may place a claim's expiry.

It exists because an over-long claim is a durable liveness fault one caller can commit alone, and the shape of the damage is the one MaxCommandClaimTTL describes: nothing removes a claim, and every other replica declines to reconcile the session for as long as it lasts. Unbounded, "as long as it lasts" is bounded only by rankableTime, which is centuries — one replica with a skewed clock takes a session out of reconciliation for the life of the deployment.

The consequence is delay rather than incorrectness, because the claim is not a fence and a replica that decided to reconcile anyway would still be safe. That is exactly why the bound has to be here rather than in a caller's policy: the failure is invisible, so nothing would ever report it.

It is a ceiling on caller error and clock skew, not a policy TTL. A reconciler chooses its own TTL far below this, and five minutes is not a recommendation.

View Source
const MinGateIntentRemnantAge = 5 * time.Minute

MinGateIntentRemnantAge is how long a gate deadline intent must have been durable before this store will retire it as a remnant.

IT IS A CEILING ON HOW LONG ONE OpenGate CALL CAN TAKE, not a policy delay. OpenGate writes the intent, then commits the open projection, and between those two writes the intent looks exactly like a remnant. Nothing in the two records distinguishes "the open crashed" from "the open is in flight", so a retirement inside that window can tombstone the deadline of a gate that is about to become publicly open — leaving it waiting with nothing to expire it, under an identity that can never be reused because this package's tombstones are permanent.

Five minutes is chosen against the cost of being wrong in each direction, and the two costs are not symmetric. Waiting too long leaves a remnant row in a due page for longer; the continuation steps past it, so the cost is a row per page, not a stalled sweep. Waiting too little destroys a live gate's deadline. So the window is set far above any plausible span of the interval it actually covers, rather than close to it.

THE INTERVAL IS THE CLOCK READING TO THE PROJECTION COMMIT, not "between two writes". OpenGate reads the clock at the top, before it is admitted and before it reads the catalog, because this package reads the clock once ahead of any provider work. So the exposed span is a mutex acquisition, the session-scope verification and catalog read, the intent write and the projection write — several round trips rather than the gap between two of them. Leaving the reading where it is remains right: moving it after the catalog read would buy a shorter interval by breaking the rule that keeps every operation's decisions evaluated at one instant.

WHICH CLOCK, AND WHAT THAT DOES NOT BUY. RecordedAt is stamped by the store that opened the gate and the age is evaluated by the store that sweeps — different processes, each with its own injected clock, with no shared time available (see WithClock). The comparison is therefore skew-relative: a fast sweeper reaches the window early by the skew, a slow one late. That is affordable only because the window is minutes and the skew a deployment tolerates is seconds; it would not be affordable for a window of seconds, and shrinking this constant without a real clock is the way to make it unsafe.

View Source
const PublicCreateReservationVersion uint8 = 1

PublicCreateReservationVersion is the immutable tenant-wide reservation codec.

Variables

This section is empty.

Functions

func EncodeEnvelope

func EncodeEnvelope(env Envelope) ([]byte, error)

EncodeEnvelope validates and deterministically encodes an envelope. The returned frame does not alias any caller-owned body.

Types

type AcquireReconciliationClaimRequest

type AcquireReconciliationClaimRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID

	HolderID  string
	ExpiresAt time.Time
}

AcquireReconciliationClaimRequest takes or extends the claim on one session.

HolderID is the calling replica's own identity. ExpiresAt is that replica's promise about when it will be done, and it must lie in the store's future and within MaxReconciliationClaimTTL of it.

There is no claim instant and no expected revision. The claim instant is the store's, for the reason ReconciliationClaim states; the revision is not the caller's business because a claim is not a decision about a record the caller has read — it is "am I the one doing this?", and the answer is decided by the holder and the clock, closed by a compare-and-swap this store reads for itself.

There is deliberately no lease epoch. Requiring one would say that holding a session's lease is relevant to doing scaling work for it, which is exactly backwards: reconciliation runs when NO Host owns the session.

type AcquireResidencyRequest added in v0.4.0

type AcquireResidencyRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
}

AcquireResidencyRequest names an existing disposition-mode catalog session.

type AdmitCommandRequest

type AdmitCommandRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
	CommandID sessionwire.CommandID

	ProposedRuntimeCommandID RuntimeCommandID
	Kind                     CommandKind

	Payload    []byte
	PayloadRef sessionwire.ObjectReference

	AcceptedAt    time.Time
	ApplyDeadline time.Time
}

AdmitCommandRequest accepts one client command into a session's inbox. It is idempotent by (TenantID, SessionID, CommandID): a repeat returns the stored record unchanged with created false.

ProposedRuntimeCommandID is a PROPOSAL. Racing replicas may propose different runtime identities for one public CommandID; only the one in the winning record is stored, returned, and used, and a losing replica receives the winner's rather than its own. A caller must therefore use the returned mapping and never the value it sent.

AcceptedAt and ApplyDeadline are the caller's clock readings, as every other timestamp this package stores is. They belong to the WINNER: a duplicate returns the accepted instant and deadline that were durably committed, not the ones it just sent, and they take no part in deciding whether a duplicate mismatches — see AdmitCommand.

type AdmitDispositionCommandRequest added in v0.5.0

type AdmitDispositionCommandRequest struct {
	TenantID                 sessionwire.TenantID
	SessionID                sessionwire.SessionID
	CommandID                sessionwire.CommandID
	Binding                  SessionBinding
	ProposedRuntimeCommandID RuntimeCommandID
	Kind                     CommandKind
	Payload                  []byte
	PayloadObject            *sessionwire.ObjectMetadata
	AcceptedAt               time.Time
	ApplyDeadline            time.Time
}

AdmitDispositionCommandRequest proposes a pending command for an existing disposition catalog session. Binding must equal its actual immutable pin. A retry compares binding/kind/content and returns the winning runtime ID, metadata or inline representation, timestamps, deadline and acceptance order. This is not public-create reservation: uniqueness is scoped to one session.

type AdmitPublicCreateRequest added in v0.5.0

type AdmitPublicCreateRequest struct {
	Identity      PublicCreateIdentity
	Payload       []byte
	PayloadObject *sessionwire.ObjectMetadata
}

AdmitPublicCreateRequest completes prepared admission with verified content. Inline and independently uploaded object representations compare by content; retries return the exact winning inbox representation and acceptance order.

type BeginApplyingCommandRequest

type BeginApplyingCommandRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
	CommandID sessionwire.CommandID

	ExpectedRevision uint64
	LeaseEpoch       uint64
	ClaimExpiresAt   time.Time
}

BeginApplyingCommandRequest moves a claimed command into applying. Its members mean what ClaimCommandRequest's mean; ClaimExpiresAt replaces the claim's expiry, because the bound that mattered while the claimer was preparing is not the bound that matters while it is applying.

It is the machine's ONE IRREVERSIBLE expiry choice, and a caller should size it for the whole application rather than for the next step. Applying is a fortress: it cannot be re-claimed at any epoch and it cannot be renewed, so once this expiry lapses the command can be completed only by this same lease epoch and settled by nobody else until a later task's recovery reads the journal correlation. A value chosen too small does not fail the application — it parks the command.

type BeginDispositionAttemptRequest added in v0.6.0

type BeginDispositionAttemptRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
	CommandID sessionwire.CommandID

	ExpectedRevision uint64
	AttemptID        DispositionAttemptID
	JournalEpoch     JournalEpoch
	ResidencyEpoch   ResidencyEpoch
	StartedAt        time.Time
}

BeginDispositionAttemptRequest authorizes exactly one dispatch of one claimed command.

ExpectedRevision is the revision of the record the caller read and decided on, as every compare-and-swap in this package requires. JournalEpoch is the grant the runtime's own construction returned; ResidencyEpoch is the Host grant the caller holds, and it must be the claim's own — a lower one has been superseded permanently, and a higher one has not claimed this command.

StartedAt is the caller's own clock reading of when the attempt began. It is recorded, not used as a guard. Within THIS PROTOCOL the settlement path is the only exception: a settlement caller supplies no instant at all, and DispositionOutcome.SettledAt is the store's own reading — see there for why. The scope is not decoration. Package-wide the store's clock is stored in several places (gates, pointers and host targets all record one), so a sentence that claimed settlement was the only such instant anywhere would be false.

type BodyReference

type BodyReference struct {
	Reference sessionwire.ObjectReference
	SizeBytes uint64
	SHA256    [32]byte
}

BodyReference is the fixed-integrity representation stored in an envelope. Reference is a logical Core identity, never a provider key or signed URL.

func BodyReferenceFromObjectMetadata

func BodyReferenceFromObjectMetadata(metadata sessionwire.ObjectMetadata) (BodyReference, error)

BodyReferenceFromObjectMetadata converts canonical SHA-256 Core metadata to the journal's fixed binary reference.

func (BodyReference) ObjectMetadata

func (r BodyReference) ObjectMetadata() (sessionwire.ObjectMetadata, error)

ObjectMetadata converts a valid fixed reference to Core's public metadata shape with a canonical lowercase SHA-256 digest.

type BodySlot

type BodySlot struct {
	Inline    []byte
	Reference *BodyReference
}

BodySlot is one independent inline or object-backed body. A nil Inline is absent; a non-nil, zero-length Inline is present. Inline and Reference are mutually exclusive.

type CatalogEntry

type CatalogEntry struct {
	Record   CatalogRecord
	Revision uint64
}

CatalogEntry is a catalog record together with the revision a caller passes to a subsequent Factory-owned compare-and-swap. The provider's immutable order is deliberately not exposed: it is sparse and scope-relative, so no caller can correctly infer a position or a count from it.

type CatalogError

type CatalogError struct {
	Code     CatalogErrorCode
	Field    string
	Epoch    uint64
	Revision uint64
	Cause    error
}

CatalogError is a typed, redacted catalog failure. Field names the offending input or stage and never carries a provider name, key, or record payload. Epoch is populated only for CatalogErrorEpoch, where the committed high-water epoch is itself the answer, and Revision only for CatalogErrorConflict, where a backend that can safely disclose the current revision did so.

func (*CatalogError) Error

func (e *CatalogError) Error() string

func (*CatalogError) Unwrap

func (e *CatalogError) Unwrap() error

type CatalogErrorCode

type CatalogErrorCode string

CatalogErrorCode classifies a session catalog record failure.

Cursor is separate from Invalid because the two name different owners. An invalid limit is a caller mistake in the request this package validates; Cursor means a continuation token was not one this store issued for this query, whether the envelope or the provider token inside it failed, and a caller's only recovery is to restart the walk from the first page.

Epoch and Conflict are deliberately distinct, and the distinction is the whole point of the catalog's two ownership mechanisms. Epoch means a Host-owned write named a lease epoch below the record's committed high-water mark: that writer has provably been superseded and must not retry with the same epoch. Conflict means a compare-and-swap lost a race on the record's revision without any statement about ownership; the caller may re-read and retry. Unknown means the mutation's outcome could not be resolved at all.

const (
	CatalogErrorInvalid   CatalogErrorCode = "invalid"
	CatalogErrorCursor    CatalogErrorCode = "cursor"
	CatalogErrorNotFound  CatalogErrorCode = "not_found"
	CatalogErrorDeleted   CatalogErrorCode = "deleted"
	CatalogErrorIdentity  CatalogErrorCode = "identity"
	CatalogErrorEpoch     CatalogErrorCode = "epoch"
	CatalogErrorSequence  CatalogErrorCode = "sequence"
	CatalogErrorTooSoon   CatalogErrorCode = "too_soon"
	CatalogErrorConflict  CatalogErrorCode = "conflict"
	CatalogErrorUnknown   CatalogErrorCode = "unknown"
	CatalogErrorBackend   CatalogErrorCode = "backend"
	CatalogErrorMalformed CatalogErrorCode = "malformed"
	CatalogErrorVersion   CatalogErrorCode = "version"
	CatalogErrorTooLarge  CatalogErrorCode = "too_large"
)

type CatalogRecord

type CatalogRecord struct {
	// PublicCreate is immutable provenance, present only in version 3 catalogs.
	// Desired placement and idempotency changes never rewrite this identity.
	PublicCreate *PublicCreateReservation
	// Binding is immutable after creation. Zero preserves the legacy v1 record.
	Binding                SessionBinding
	TenantID               sessionwire.TenantID
	SessionID              sessionwire.SessionID
	AgentID                sessionwire.AgentID
	RuntimeCompatibilityID string

	CreatedAt    time.Time
	LastActiveAt time.Time

	State            sessionwire.SessionState
	Residency        sessionwire.SessionResidency
	DesiredPlacement sessionwire.HostPlacement

	LastJournalSeq uint64
	LastEventID    sessionwire.EventID
	Checkpoint     CheckpointSummary
	OpenGates      []sessionwire.GateProjection

	LeaseEpoch            uint64
	DesiredIdempotencyKey string
	DesiredGeneration     uint64
	DesiredWorkload       DesiredWorkload
}

CatalogRecord is the neutral, replay-free durable projection of one session.

Its fields have two different owners, and the difference is enforced rather than documented. LeaseEpoch, State, Residency, LastActiveAt, the journal summary, the checkpoint summary, and the open-gate projections are written by the Host that holds the session's lease, and a write naming an epoch below the committed high-water mark is refused. DesiredPlacement, RuntimeCompatibilityID, and DesiredIdempotencyKey are Factory-authored desired state, guarded by revision compare-and-swap and an idempotency key; Factory never names a lease epoch, so it cannot claim ownership it does not have.

func (CatalogRecord) PlacementIntent

func (r CatalogRecord) PlacementIntent() (PlacementIntent, error)

PlacementIntent projects the record's desired state.

It canonicalizes first, as Summary and Status do, so an intent can never be produced from a record this package would refuse to store — and so the payload it hands back is this package's copy rather than the record's own.

func (CatalogRecord) Status

Status projects the record into Core's replay-free status shape. WaitingGateID is the first open gate in the record's canonical (opened_seq, gate_id) order, so two readers of the same record always name the same gate. Canonicalizing here rather than assuming a canonical caller keeps the gate comparator stated exactly once; a second defensive sort in this method is precisely the kind of restatement that later drifts.

func (CatalogRecord) Summary

Summary projects the record into Core's recent-first list shape.

type CheckpointSummary

type CheckpointSummary struct {
	JournalSeq uint64
	Reference  sessionwire.ObjectReference
	CapturedAt time.Time
}

CheckpointSummary is the bounded durable description of the active workspace checkpoint. Its zero value means no checkpoint has been committed. It names a logical Core object reference, never a provider key or signed URL.

type ClaimCommandRequest

type ClaimCommandRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
	CommandID sessionwire.CommandID

	ExpectedRevision uint64
	LeaseEpoch       uint64
	ClaimExpiresAt   time.Time
}

ClaimCommandRequest takes a short-lived claim on one accepted command.

ExpectedRevision is the revision the caller decided on, which is the revision the compare-and-swap names. It is required: a transition is a decision about a record the caller has read, and a claim that named no revision would be a blind write dressed as a compare-and-swap.

LeaseEpoch is the session lease epoch the claimer is acting under, and ClaimExpiresAt is the caller's own reading of when the claim lapses. The claim's expiry may fall after the command's apply deadline — that is what lets an unexpired claim win the deadline race — but it may not fall in the past.

type ClaimDispositionCommandRequest added in v0.8.0

type ClaimDispositionCommandRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
	CommandID sessionwire.CommandID

	ExpectedRevision uint64
	Residency        *ResidencyGrant
	ClaimExpiresAt   time.Time
}

ClaimDispositionCommandRequest takes a short-lived claim on one admitted disposition command, which is the state BeginDispositionAttempt starts from.

ExpectedRevision is the revision the caller read and decided on, as every compare-and-swap in this package requires. ClaimExpiresAt is the caller's own reading of when the claim lapses, evaluated against the STORE's clock, and it is held to MaxCommandClaimTTL for that constant's stated reason. The claim's expiry MAY fall after the command's apply deadline — that is what lets an unexpired claim win the deadline race — but it may not fall in the past.

Residency is a GRANT and not a number, and that is the load-bearing choice

Every other residency-taking API in this package takes a bare ResidencyEpoch. This one takes the *ResidencyGrant that AcquireResidency returned, and the epoch is read off it. The caller cannot name an epoch at all.

The reason is that THIS EDGE IS THE ONLY PRODUCER OF THE RECORD'S HIGH-WATER MARK, and that mark only ever rises. Three call sites fence against it — this edge, BeginDispositionAttempt and RejectDispositionCommand — so a single stored claim at an epoch no provider ever issued permanently supersedes every real Host for that command: it can never be claimed, attempted or applied again, and its only remaining exit is a zero-residency reconciler rejection once the bogus claim lapses. The command is durably lost, silently, from one well-formed call.

A bare number could not be checked. `storage.Leaser` exposes only `Acquire(ctx, name) (Lease, error)`, so there is NO way to read a session's issued epoch without taking the lease away from whoever holds it — the store cannot validate a number a caller hands it, at any price short of a Storage contract change. A grant needs no validation: it is the store's own object, carrying a provider-issued epoch for a named session, so an unissued number is not expressible rather than merely refused.

Be exact about what that buys, because the module's own warnings apply here too. It is NOT proof of a live lease — nothing in this package reads one, and a grant whose lease has expired or been taken over still passes. It IS proof that the epoch came from this store's provider for this session, which is the whole of what a ratcheting mark needs: a stale grant names a LOWER epoch and the fence refuses it on its own terms, and it cannot name a higher one. See (*ResidencyGrant).residencyFor for each conjunct.

Why the other edges keep a bare epoch, which is not an inconsistency

The rule, stated so that it actually sorts this package rather than sounding like it does:

A caller-asserted value is safe when it is BOUNDED, SELF-LIMITING, or
COMPARED AND DISCARDED. It must be store-issued when it becomes a MONOTONIC
BOUND ON FUTURE CALLERS that is UNBOUNDED ABOVE and OUTLIVES THE STATE THAT
CARRIED IT.

"The store decides from it" is NOT the rule, and the distinction matters because the shorter sentence is the one a reader will remember. Three caller-asserted values in this same file are decided from, two of them are written, and none is store-issued — and all three are fine:

  • ExpectedRevision is compared and discarded. Every compare-and-swap here decides from it, and it never becomes state.
  • ClaimExpiresAt is written AND decided from — liveness, the deadline race, replay identity — but it is BOUNDED (MaxCommandClaimTTL, and it must be in the future) and SELF-LIMITING: it lapses, and the record recovers.
  • ApplyDeadline is written at admission and decides the deadline refusal, and it is bounded by the same rankable-time rule every stored instant is.

Claim.ResidencyEpoch is none of those. It is written, it is UNBOUNDED ABOVE, and — the property that actually does the work — it OUTLIVES THE CLAIM: dispositionRecordHighWater reads it with no liveness test, and the fence runs BEFORE the live check at both edges, so a lapsed claim still supersedes. That is what makes a bad value permanent rather than merely temporary, and it is the whole discriminator. TestClaimDispositionCommandRefusalOrder's "superseded residency before the deadline and a lapsed claim" is the case that proves it.

Applying that rule to this protocol's four residency-bearing APIs: SettlingResidencyEpoch is recorded and never read back. BeginDispositionAttempt's epoch is fenced to equal the claim's own, so it cannot raise the mark. RejectDispositionCommand writes no claim at all, so it cannot either — and it must accept a zero, because a reconciler holds no residency. Only this edge writes the mark, so only this edge is gated.

type ClearHostRegistrationRequest

type ClearHostRegistrationRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID

	LeaseEpoch uint64
}

ClearHostRegistrationRequest releases one session's route, leaving the epoch-fenced tombstone behind.

It carries no timestamp, and that is deliberate rather than an omission. A tombstone's instants are not an observation of anything — they record that THIS STORE wrote the tombstone — and a caller-supplied instant could place a tombstone's expiry in the future, producing a record that reads as released by structure and as live by time. The store's own clock cannot.

type ClearSessionPointerRequest

type ClearSessionPointerRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID

	LeaseEpoch uint64
}

ClearSessionPointerRequest gives up one role's object, leaving the epoch-fenced tombstone behind.

It names no sequence, and that is the shape of the operation rather than an omission: clearing does not publish a capture, so there is nothing for a sequence to describe, and the stored one is RETAINED rather than replaced. A request that could name one could lower it.

type Clock

type Clock interface {
	Now() time.Time
}

Clock supplies wall time to storage decisions and permits deterministic tests.

type CommandApplication

type CommandApplication struct {
	CommandID        sessionwire.CommandID
	RuntimeCommandID RuntimeCommandID

	Outcome CommandApplicationOutcome

	// PrefixSeq and PrefixEpoch locate the prefix the outcome is about, and are
	// zero when the outcome is Absent.
	PrefixSeq   uint64
	PrefixEpoch uint64

	// EffectSeq and EffectEventID name the public event that carried the
	// effect, and are zero unless the outcome is Committed.
	EffectSeq     uint64
	EffectEventID sessionwire.EventID

	// SupersedingEpoch is the highest opening-fence epoch THE WALK OBSERVED,
	// and a writer at or below it is provably fenced out of the stream.
	//
	// It is deliberately not described as the highest lease that ever owned the
	// session, which is what a walk from sequence one happens to find today.
	// The two come apart the moment the walk is bounded — the admission-tip
	// bound above is the obvious way, and it would leave every fence written
	// before the command was accepted unobserved — and the predicate this
	// member exists for stays correct under that, because a fence observed
	// LATER than some other fence is still a fence. Promising the maximum over
	// the whole journal would make a bound that is otherwise fine look like a
	// breaking change.
	SupersedingEpoch uint64

	CapturedTip uint64
}

CommandApplication is what one session's journal proves about one command.

Every member describes DURABLE STATE this store read, never a conclusion about a caller. The sequences are journal sequences in the session's own stream; the epochs are session lease epochs.

CapturedTip is the tip the correlation was taken at, and it exists to say how long the answer is good for. The answer is: FOUR OF THE FIVE OUTCOMES ARE NOT STABLE ACROSS TIPS, and the transitions are ordinary rather than exotic — absent becomes committed when the applier commits its prefix and effect a moment later, abandoned becomes committed when a later lease retries the application to completion, unresolved becomes either as soon as one more record lands, and committed becomes conflicted when a prefix breaking the mapping appears anywhere later in the stream. Two of those are what this package's own tests do on purpose.

Only CONFLICTED cannot be superseded, because nothing outranks a broken mapping in precedence, which is also why it is the one finding an operator can act on without re-reading.

What IS monotone is not an outcome but a pair of NEGATIVE facts, and they are exactly the two a settlement rests on:

  • no effect for this command had committed by CapturedTip, and
  • the writer at a given epoch was already fenced out by CapturedTip.

Both are properties of a PREFIX OF THE STREAM, and the journal only appends, so no later record undoes either. That is the whole of why the settlements are safe, and it is also why they RE-SCAN rather than accept a correlation a caller took earlier: a caller's older answer still carries true negative facts, but the store cannot tell from the value alone which tip they were true of relative to the record it is about to write.

A caller may therefore hold a correlation to decide WHAT TO DO — finish or settle — and must not hold one as a licence. Caching an ABSENT answer and rejecting on it later is precisely the overwrite this file exists to prevent: absence is the least stable finding there is, because every application starts from it.

type CommandApplicationOutcome

type CommandApplicationOutcome string

CommandApplicationOutcome is what a session's journal proves about one command's application. It is a closed vocabulary because each value unlocks a different settlement, and a caller that met an unlisted one would have no safe default.

const (
	// CommandApplicationAbsent means no record in the journal names this
	// command. Nothing has been applied under it.
	CommandApplicationAbsent CommandApplicationOutcome = "absent"

	// CommandApplicationCommitted means a correlated prefix is immediately
	// followed by the public event that carried its effect. The command has
	// been applied, whichever lease did it.
	CommandApplicationCommitted CommandApplicationOutcome = "committed"

	// CommandApplicationAbandoned means a correlated prefix is immediately
	// followed by an opening fence above its own epoch: the application started
	// and its writer lost the stream before committing anything more.
	CommandApplicationAbandoned CommandApplicationOutcome = "abandoned"

	// CommandApplicationUnresolved means a correlated prefix exists whose
	// outcome cannot be read: it is still at the tip with its writer possibly
	// alive, or the record after it is neither its effect nor its writer's
	// fence. Both settlements refuse; the answer may become readable later.
	CommandApplicationUnresolved CommandApplicationOutcome = "unresolved"

	// CommandApplicationConflicted means a prefix names this command's public
	// identity with a different runtime identity or kind. The durable mapping
	// is broken, so no settlement is safe and an operator has to look.
	CommandApplicationConflicted CommandApplicationOutcome = "conflicted"
)

type CommandClaim

type CommandClaim struct {
	LeaseEpoch uint64
	ExpiresAt  time.Time
}

CommandClaim is the current short-lived claim on a command. Its zero value means the command is unclaimed.

LeaseEpoch is the claiming Host's grant epoch. It is recorded here rather than fencing anything in this file: admission never writes a claim.

type CommandKind

type CommandKind string

CommandKind names what the command asks the session to do.

The set is deliberately not enumerated here. Core accepts any non-empty state, residency, and gate kind so a later wire version can add one, and a closed set in this package would refuse a command Core itself considers valid. It is validated as a bounded opaque UTF-8 value.

type CommandResult

type CommandResult struct {
	CompletedAt time.Time
	EventID     sessionwire.EventID
	JournalSeq  uint64
}

CommandResult is the terminal application result: the durable journal event that carried the command's effect. Its zero value means the command has not been applied.

type CompleteCommandRequest

type CompleteCommandRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
	CommandID sessionwire.CommandID

	ExpectedRevision uint64
	LeaseEpoch       uint64
	Result           CommandResult
}

CompleteCommandRequest records the terminal application of a command.

Result names the durable journal event that carried the command's effect. It is required, because "applied" with no event is a claim that something happened with nothing to point at.

type CreateCatalogEntryRequest

type CreateCatalogEntryRequest struct {
	Binding                SessionBinding
	TenantID               sessionwire.TenantID
	SessionID              sessionwire.SessionID
	AgentID                sessionwire.AgentID
	RuntimeCompatibilityID string
	CreatedAt              time.Time
	LastActiveAt           time.Time
	State                  sessionwire.SessionState
	Residency              sessionwire.SessionResidency
	DesiredPlacement       sessionwire.HostPlacement
	DesiredWorkload        DesiredWorkload
	IdempotencyKey         string
}

CreateCatalogEntryRequest creates the authoritative record for one session. Legacy creation is idempotent by (TenantID, SessionID). A bound retry must also match the immutable Binding and AgentID; a mismatch is CatalogErrorConflict. A legacy request cannot adopt a bound row. Matching retries return the stored record unchanged with created false, even when mutable desired fields differ.

type DesiredWorkload

type DesiredWorkload struct {
	PayloadVersion string
	Payload        []byte
}

DesiredWorkload is the platform workload a Factory wants reconciled for a dedicated session, carried opaquely.

Its zero value means no workload is desired, which is the ordinary case for a pooled session: pooled capacity is reconciled by scaling a Department's Hosts, not by creating anything per session.

The two members are present together or absent together, and canonicalization enforces that rather than documenting it. A payload with no version is a document no reconciler can interpret — it would have to guess a schema — and a version with no payload is a claim about nothing. Requiring both also gives "absent" exactly one spelling, which is what keeps a record's stored bytes independent of which writer produced them.

PayloadVersion is caller-owned text and is never interpreted here. It exists so that a reconciler reading a payload it does not understand can say so instead of misreading it.

A NOTE OWED TO WHOEVER ADDS A BYTES-IDENTITY CHECK TO THE CATALOG. This member makes the catalog record's stored bytes a normalizer's output rather than a fixed point of the caller's input: encoding/json decodes a []byte with non-strict base64, so a stored "AR==" decodes to one byte and re-encodes as "AQ==". The record still round-trips CANONICALLY — decode, encode, decode again is stable, which is what the codec fuzzer asserts — but a check comparing a provider's reply against the exact bytes handed to it, as verifyReconciliationClaimBytes and verifyRegistrationBytes do for records with no []byte member, would refuse a faithful reply to a value some other writer had stored non-canonically. Compare re-encoded forms there, not raw bytes.

type DispositionAttempt added in v0.6.0

type DispositionAttempt struct {
	AttemptID      DispositionAttemptID `json:"attempt_id"`
	JournalEpoch   JournalEpoch         `json:"journal_epoch"`
	ResidencyEpoch ResidencyEpoch       `json:"residency_epoch"`
	StartedAt      time.Time            `json:"started_at"`
}

DispositionAttempt is the complete record of one authorized dispatch, written before the dispatch and immutable afterwards.

JournalEpoch is the grant the runtime returned for the bound journal and ResidencyEpoch is the Host grant the dispatch was authorized under. They are stored as two members of two types on purpose: an attempt that kept one number could not tell a successor which authority had actually been held.

These struct tags are NOT the durable spelling; see dispositionAttemptWire.

type DispositionAttemptID added in v0.6.0

type DispositionAttemptID string

DispositionAttemptID is the immutable identity of one authorized dispatch. It is an opaque caller-chosen name, bounded like every other identity here, and it is what the runtime's durable disposition must name for the evidence to be about this attempt.

type DispositionClaim added in v0.6.0

type DispositionClaim struct {
	ResidencyEpoch ResidencyEpoch `json:"residency_epoch"`
	ExpiresAt      time.Time      `json:"expires_at"`
}

DispositionClaim is the residency-guarded claim a command is worked on under.

Nothing in THIS file moves a pending command into claimed: the claim edge is ClaimDispositionCommand in disposition_claim.go, and this type is declared here because the attempt below consumes such a record and must say precisely what it requires of one. ExpiresAt is the claimant's own reading of when the claim lapses, evaluated against the STORE's clock exactly as every other liveness guard in this package is.

type DispositionCommandCursor added in v0.7.0

type DispositionCommandCursor struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID

	LeaseEpoch    uint64
	ConsumedOrder uint64

	UpdatedAt time.Time
}

DispositionCommandCursor is the durable record of how far one session's disposition command consumer has got, and of the residency epoch that last said so.

WHAT IT IS. ConsumedOrder is an immutable acceptance order from THIS session's DISPOSITION inbox, and the record's meaning is exactly: SOME CONSUMER ASSERTED IT WAS DONE WITH EVERY COMMAND AT OR BELOW THIS POSITION. LeaseEpoch is the epoch of the grant that last wrote the record and is its fencing high-water mark; it never falls, which is why this record is never deleted.

WHAT IT IS NOT, and this half matters more, because the obvious reading of a durable cursor is far stronger than what this row can support. It follows the rule SettlingResidencyEpoch states for the settlement record: the stored epoch is CONTEXT, NOT AUTHORITY.

  1. IT IS NOT PROOF THAT ANY COMMAND WAS APPLIED. It is not a summary of the inbox and it is not derived from one. A consumer that read ten commands, rejected three, failed at the eleventh and saved ten writes exactly the same row as one that applied all ten. The authoritative state of a command is its own record's State, reachable by name, and nothing here substitutes for reading it.

  2. IT IS NOT PROOF THAT THE SAVER HELD A LIVE RESIDENCY. The fence establishes that LeaseEpoch is at or above the greatest epoch previously committed to this row; it does not establish that the grant was still held at the compare-and-swap, and NO PATH IN THIS PACKAGE READS A LIVE LEASE on this record. Read the field as "who asked, at or above the mark", never as "who validly consumed".

  3. IT AUTHORIZES NOTHING. It licenses no claim, no dispatch, no settlement and no read. Every one of those goes through its own operation, with its own preconditions, and none of them consults this row.

  4. IT SAYS NOTHING ABOUT COMMANDS ABOVE IT. In particular a command above the cursor may be applied, settled, or claimed by someone else; the cursor is a consumer's position, not a watermark the inbox respects.

  5. ZERO IS NOT PROOF THAT NOTHING WAS CONSUMED. It is proof that nothing was RECORDED. A consumer that processed a hundred commands and crashed before its first save leaves this row absent, and a successor that trusted zero as history rather than as a starting position would re-present all hundred. Re-presentation is safe — command application is idempotent by identity — but a caller must know that is what it is relying on.

  6. IT IS NOT COMPARABLE ACROSS SESSIONS OR PROTOCOLS, for the reason DispositionInboxEntry states about AcceptedOrder itself: the number is a position in one session's disposition stream and means nothing in any other.

UpdatedAt is the STORE's clock, read when the request is validated and before any provider call, exactly as SessionPointer's is and for the same reason: nothing decides on it, so it is an audit line rather than an input, and paying a round trip to make it truer would buy nothing.

The accumulation is one small permanent row per session that has ever had a consumer: never listed, never ranked, never due, never read except by name. The registry's carry-forward contract about retention applies word for word — the only safe reaper is one that removes the session's whole scope at once, because deleting this row alone destroys a fence while leaving the session writable.

type DispositionCommandCursorEntry added in v0.7.0

type DispositionCommandCursorEntry struct {
	Cursor   DispositionCommandCursor
	Revision uint64
}

DispositionCommandCursorEntry is a cursor together with the revision a later compare-and-swap names.

The ZERO ENTRY is the answer for a session that has never recorded one, and it is a whole-value answer rather than a found flag: ConsumedOrder is zero exactly when nothing is recorded, because a zero order is refused on the way in and no provider allocates one. A caller may therefore test the entry, the cursor, or the order and get the same answer from all three.

type DispositionCommandDescriptor added in v0.5.0

type DispositionCommandDescriptor struct {
	// PublicCreate is emitted only by AdmitPublicCreate. Its explicit presence
	// fails older strict canonical v2 decoders; Kind itself remains opaque.
	// Generic reads check catalog binding, not reservation proof. The marker
	// alone cannot authorize future public-create dispatch; only successful
	// AdmitPublicCreate verifies reservation, catalog and inbox for an ACK.
	PublicCreate     bool                        `json:"public_create,omitempty"`
	TenantID         sessionwire.TenantID        `json:"tenant_id"`
	SessionID        sessionwire.SessionID       `json:"session_id"`
	CommandID        sessionwire.CommandID       `json:"command_id"`
	Binding          SessionBinding              `json:"binding"`
	RuntimeCommandID RuntimeCommandID            `json:"runtime_command_id"`
	Kind             CommandKind                 `json:"kind"`
	PayloadDigest    string                      `json:"payload_digest"`
	PayloadSize      uint64                      `json:"payload_size"`
	Payload          []byte                      `json:"payload,omitempty"`
	PayloadObject    *sessionwire.ObjectMetadata `json:"payload_object,omitempty"`
}

DispositionCommandDescriptor is immutable command identity and its winning runtime mapping. PayloadDigest is lowercase SHA-256 hex. PayloadSize and digest identify content regardless of representation or independent upload generation. PayloadObject, when present, contains the exact winning canonical metadata. Payload bytes are private and bounded by MaxInboxPayloadBytes; nil means empty inline content when PayloadObject is nil. Binding is the actual catalog pin. These struct tags are NOT the durable spelling: stored member names are pinned separately by this package's private wire DTO and by golden byte literals, so renaming a field here cannot move a stored record.

type DispositionDueCommandPage added in v0.5.0

type DispositionDueCommandPage struct {
	Commands   []DispositionInboxEntry
	Examined   int
	Unreadable int
	Limit      int
	NextCursor sessionwire.Cursor
}

DispositionDueCommandPage is a weak deadline-ordered view, not an acceptance stream or evidence of nonexistence. Examined counts provider rows, including Unreadable rows skipped for malformed/misfiled descriptors or missing, deleted, corrupt, legacy-mode or mismatched catalogs. Such rows advance continuation. Provider unavailability, cancellation and scope-check failures fail the page. No command is returned without its actual immutable catalog binding checked.

type DispositionEvidence added in v0.6.0

type DispositionEvidence struct {
	AttemptID           DispositionAttemptID
	Kind                DispositionOutcomeKind
	AttemptJournalEpoch JournalEpoch
	AuthorJournalEpoch  JournalEpoch
	DispositionSeq      uint64
	AuthorFenceSeq      uint64
	EventID             sessionwire.EventID
	EventSeq            uint64
}

DispositionEvidence is one committed runtime disposition, as read from the bound journal by the configured reader. Every member describes a record that is already durable in that journal; nothing here is a conclusion about a caller and nothing here may be supplied by one.

AuthorJournalEpoch is the grant that AUTHORED the disposition, which is the attempt's own grant for an application or a refusal and a strictly later one for a recovery closure. AuthorFenceSeq names the verified opening fence of that later author grant and is set only for a closure.

EventID and EventSeq are retained members of the durable shape that are ALWAYS ZERO. They were written for an applied disposition that carried its public event in the same envelope, and no such record can exist: a journal append frames one envelope, so an effect is always a separate record. They are kept rather than removed because they are members of the durable wire DTO and removing one would change stored bytes; nothing derives anything from them, and a non-zero value here is refused.

type DispositionEvidenceReader added in v0.6.0

type DispositionEvidenceReader interface {
	ReadDispositionEvidence(ctx context.Context, req DispositionEvidenceRequest) (DispositionEvidence, error)
}

DispositionEvidenceReader is the narrow, trusted boundary over committed journal records. An implementation resolves ONLY the immutable binding in the request, reads committed dispositions from that journal, and returns what it found. It must not execute an agent, and this package neither imports nor knows anything about what does.

Returning an error is the correct answer for an unavailable, cancelled, incomplete or unreadable journal: the error propagates to the caller and the command is left unsettled. A reader must NEVER report a missing or unreadable record as an empty DispositionEvidence — absence is not a disposition, and the zero value is refused here precisely so that a reader which did so settles nothing.

type DispositionEvidenceRequest added in v0.6.0

type DispositionEvidenceRequest struct {
	TenantID         sessionwire.TenantID
	SessionID        sessionwire.SessionID
	CommandID        sessionwire.CommandID
	Kind             CommandKind
	RuntimeCommandID RuntimeCommandID
	Binding          SessionBinding
	Attempt          DispositionAttempt
}

DispositionEvidenceRequest is the question the store asks its reader. It is DERIVED from the store's current inbox record — the pinned binding, the durable runtime mapping and the immutable attempt — and never from a settlement caller, which is what makes the reader's scope the session's own bound journal rather than anywhere a caller could point it.

type DispositionInboxEntry added in v0.5.0

type DispositionInboxEntry struct {
	Record        DispositionInboxRecord
	Revision      uint64
	AcceptedOrder uint64
}

DispositionInboxEntry carries provider revision and opaque per-session order. AcceptedOrder is increasing, not contiguous, and not comparable across sessions or protocols. Due pages are deadline-ordered, not acceptance-ordered.

type DispositionInboxRecord added in v0.5.0

type DispositionInboxRecord struct {
	Descriptor    DispositionCommandDescriptor `json:"descriptor"`
	AcceptedAt    time.Time                    `json:"accepted_at"`
	ApplyDeadline time.Time                    `json:"apply_deadline"`
	State         InboxState                   `json:"state"`
	Claim         *DispositionClaim            `json:"claim,omitempty"`
	Attempt       *DispositionAttempt          `json:"attempt,omitempty"`
	Outcome       *DispositionOutcome          `json:"outcome,omitempty"`
}

DispositionInboxRecord is one disposition command's authoritative record. Timestamps, like the descriptor, remain exactly those chosen by the winning create; admission produces InboxStatePending and never anything else.

Claim, Attempt and Outcome are the states beyond admission, and each is absent until the state that requires it: what each state must and must not carry is validateDispositionState, because that is a property of the record rather than of whichever transition wrote it. Once written, the attempt and both of its grant identities are immutable — no transition may rewrite one to make a successor's epochs look current. A terminal REJECTION is the one state that may carry none of the three: the protocol allows a command to be rejected before any dispatch was authorized, and such a record has no attempt for an outcome to be keyed by.

These struct tags are NOT the durable spelling; see the private wire DTOs.

type DispositionOutcome added in v0.6.0

type DispositionOutcome struct {
	Kind                   DispositionOutcomeKind `json:"kind"`
	AttemptID              DispositionAttemptID   `json:"attempt_id"`
	AttemptJournalEpoch    JournalEpoch           `json:"attempt_journal_epoch"`
	AuthorJournalEpoch     JournalEpoch           `json:"author_journal_epoch"`
	DispositionSeq         uint64                 `json:"disposition_seq"`
	AuthorFenceSeq         uint64                 `json:"author_fence_seq"`
	EventID                sessionwire.EventID    `json:"event_id"`
	EventSeq               uint64                 `json:"event_seq"`
	SettlingResidencyEpoch ResidencyEpoch         `json:"settling_residency_epoch"`
	SettledAt              time.Time              `json:"settled_at"`
}

DispositionOutcome is the durable terminal result of one command, keyed by the attempt that produced it and by the journal record that proves it.

SettlingResidencyEpoch is the residency the SETTLEMENT was requested under, which is separate settlement context and may be a successor's: it is recorded beside the attempt rather than over it, so the record keeps naming the grants the dispatch was actually authorized under.

Be exact about how much it is worth, and about which LAYER holds it. It is settlement context recorded from the REQUEST, fenced only against the claim's high-water mark, so the invariant it carries is SettlingResidencyEpoch >= Claim.ResidencyEpoch and nothing more — and that is a TRANSITION-time invariant, not a stored one. SettleDispositionCommand enforces it; the record validators check only that the epoch is non-zero, so a decoded record violating the inequality is accepted. It is true of every record this package WRITES and not of every record the codec ADMITS.

It is not proof of live residency at settlement either: no lease is read on this path, so a Host whose residency merely equals the claim's may have lost that lease since, and one naming a higher epoch is taken at its word. Read it as "who asked, and that they were not already superseded" — never as "who validly held the session when this settled".

SettledAt is a DELIBERATE DEVIATION from this package's convention that a stored instant is the caller's own clock reading, and the deviation is the point rather than an oversight. Every other member of this struct is derived from verified evidence or from the store's own immutable record, and the whole of SettleDispositionCommandRequest is that a caller supplies nothing an outcome is built from; accepting a caller's timestamp would have been the one caller-authored value in a record whose entire purpose is that it has none. It is the store's clock at the moment the outcome was constructed, taken before the terminal compare-and-swap, so it is when the store DECIDED and not when the runtime acted — the journal sequences are what order the runtime's side. It is not comparable with a caller's clock and must not be used as one.

EventID and EventSeq are members of the durable shape that are always zero; see DispositionEvidence for why they are retained rather than removed.

These struct tags are NOT the durable spelling; see dispositionOutcomeWire.

type DispositionOutcomeKind added in v0.6.0

type DispositionOutcomeKind string

DispositionOutcomeKind is the closed vocabulary of terminal runtime dispositions. Each value is a different durable statement and each carries different evidence, so a value outside this set is refused rather than given a default.

const (
	// DispositionApplied means that, under the ATTEMPT's journal grant, the
	// runtime durably recorded that it accepted this command into its
	// execution path. That is the whole of it, and the narrowness is the
	// point.
	//
	// It does NOT mean the effect is in the same journal envelope — an earlier
	// spelling of this comment said so and it was FALSE.
	// SessionJournal.Append takes exactly one record, encodes one body, frames
	// one envelope and does one AppendDefinite; there is no batch, so a runtime
	// cannot put any effect in the same frame as its disposition. The
	// disposition is a separate, private, bodiless frame, and a CONFORMING
	// runtime writes it after the synchronously-observable effect.
	//
	// That ordering is an obligation on the writer, not a property this package
	// establishes: the bodiless shape is enforced by validateEnvelope, but
	// nothing here observes when the effect was written, so a caller must not
	// read this as a checked guarantee.
	//
	// Four things it therefore does not prove: that a turn started, that a turn
	// folded, that no later TurnRejected can follow, and that a queued input
	// survives a crash. It says the acceptance was recorded, and a caller that
	// needs any of the four must read the journal for it.
	DispositionApplied DispositionOutcomeKind = "applied"

	// DispositionNoOp is an explicit successful application with no effect —
	// an interrupt of an idle session. It is a success, and it fabricates no
	// public event: reject-before-dispatch and an applied no-op are different
	// outcomes and are never merged.
	DispositionNoOp DispositionOutcomeKind = "no_op"

	// DispositionRefused is the runtime's own durable statement, authored under
	// the ATTEMPT's own grant, that it did NOT accept this command into its
	// execution path. It settles the command as rejected.
	//
	// It exists for liveness, not for symmetry. A runtime has post-prefix
	// failure paths it can take under a LIVE lease, and the only other
	// rejecting kind — DispositionNotApplied — is authored by a STRICTLY LATER
	// journal grant. Without this arm a command failing that way would sit
	// applying until the lease turned over, which a healthy Host never does.
	//
	// It is not a recovery closure and is never a substitute for one: a
	// refusal is a live runtime's own answer, while a closure is a successor's
	// conclusion about a runtime that is gone. Nothing may derive one from the
	// other.
	DispositionRefused DispositionOutcomeKind = "refused"

	// DispositionNotApplied is a successor runtime's recovery closure: it
	// proves, under a strictly later journal grant whose opening fence it
	// names, that no accepted transition occurred for this attempt. It is the
	// tombstone late dispatch must consult. An empty read is not one.
	DispositionNotApplied DispositionOutcomeKind = "not_applied"
)

type DrainHostTargetRequest

type DrainHostTargetRequest struct {
	Key HostTargetKey

	HostID         sessionwire.HostID
	HostGeneration uint64
}

DrainHostTargetRequest withdraws one Host's advertisement for one target, gracefully and immediately.

It carries no timestamp, and that is deliberate rather than an omission. A withdrawal's instant is not an observation of anything — it records that THIS STORE wrote the withdrawal — and a caller-supplied instant would let a Host place its own withdrawal in the future or the distant past, in a record that nothing afterwards re-derives an expiry from. The store's own clock cannot.

type DueCommand

type DueCommand struct {
	Entry InboxEntry
}

DueCommand is one outstanding command. The identities are read from the RECORD rather than restated beside it — a sweep learns them from the row — and Entry is the same value a named read of that command returns, held to the same filing checks.

It wraps a single member rather than being one, for the reason DueGatePage is a type rather than a second return value: what a reconciler needs BESIDE the record is not settled yet, and adding a field is a smaller change to make than changing the element type of a public slice.

type DueCommandPage

type DueCommandPage struct {
	Commands   []DueCommand
	Examined   int
	Unreadable int
	Limit      int
	NextCursor sessionwire.Cursor
}

DueCommandPage is one bounded page of outstanding commands together with what producing it cost. It is where the three cost members both sweeps carry are defined; DueGatePage refers here rather than restating them.

EXAMINED is the number of rows the provider returned, and LIMIT is the EFFECTIVE limit after a zero request limit has been resolved to the store's page size, so the comparison is available to a caller that named no limit. Together they answer a question the results alone cannot: Examined == Limit with nothing reported means this page was full and none of it said anything, which is a different state from "nothing is due".

UNREADABLE counts rows this reader could not decode, that disagreed with the filing they were found under, or that belong in a different shard. Each is SKIPPED rather than failing the page, and that is the strongest rule here rather than leniency: this view is ascending by an instant that never moves and nothing rewrites such a row, so a reader that failed on one would switch reconciliation off for every tenant in the shard until someone repaired the row by hand.

LOCATING AN UNREADABLE ROW IS OUT OF BAND, and that is a real limitation rather than an oversight to be discovered. Unreadable is a count; this package has no logger and no channel to report a row's identity through, and adding one is public surface a later task should design rather than something to bolt on here. What an operator has instead is the SHARD and the DUE BOUND the page was read at, which narrow the row to one namespace and one prefix of an ordered view. The row's stable key and ordering scope are in hand at both skip sites, so a reporting channel is cheap to add when something exists to receive it.

NEXTCURSOR is what keeps that skipping from becoming starvation. An unreadable row is stepped over by the provider's own continuation, which resumes from the tuple the page ended on, so a row left in place is passed rather than met again on the next page.

type DueGate

type DueGate struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
	Gate      sessionwire.GateProjection
}

DueGate is one gate whose deadline has passed, together with the session it belongs to. Gate is the projection read back from that session's durable catalog record, not from the intent: the intent is an index into the projection and never a second copy of it.

type DueGatePage

type DueGatePage struct {
	Gates      []DueGate
	Remnants   []RemnantGateIntent
	Examined   int
	Unreadable int
	Limit      int

	// NextCursor resumes this shard's sweep after the position this page ended
	// at. It is empty when the view is exhausted.
	//
	// It is what turns the head-of-line hazard this reader used to have from
	// permanent into transient. A row that reports nothing — a remnant, or one
	// this reader could not read at all — is stepped over by the provider's own
	// continuation, which resumes from the frozen (due_at, stable_key,
	// ordering_scope) tuple the page ended on. So a blocking row is PASSED
	// rather than met again at the head of every page, and a gate behind it is
	// reached on the next page instead of never.
	//
	// A still-open gate past its deadline is deliberately NOT stepped over
	// permanently: it stays in the view, so every fresh pass reports it again,
	// because it is current due work that nothing has dealt with. It does not
	// block, because the continuation moves past it within a pass.
	NextCursor sessionwire.Cursor
}

DueGatePage is one bounded page of due gates together with what producing it cost.

Examined, Limit and Unreadable are defined on DueCommandPage, including why an unreadable row is skipped rather than failing the page and why locating one is out of band. They mean the same things here.

What is specific to this page is the distinction between the two ways a row can report no gate. A REMNANT was read and understood and can be retired, so it is reported in Remnants with the revision a retirement names; an UNREADABLE row is one nothing in this package can vouch for, so it is counted and left alone. Reporting the second as the first would aim a retirement at a row whose gate may well be open in a record nobody could decode.

type Envelope

type Envelope struct {
	Kind EnvelopeKind

	EventID  sessionwire.EventID
	RecordID string

	Public  BodySlot
	Runtime BodySlot

	LeaseEpoch       uint64
	CommandID        sessionwire.CommandID
	RuntimeCommandID uuid.UUID
	CommandKind      string

	AttemptID           string
	AttemptJournalEpoch uint64
	DispositionKind     string
}

Envelope is one deterministic raw journal record. Public and Runtime remain separate so a public reader can select the public slot without inspecting or resolving private runtime bytes.

func DecodeEnvelope

func DecodeEnvelope(frame []byte) (Envelope, error)

DecodeEnvelope strictly decodes one complete frame. It checks the one-MiB envelope bound before allocating and returns caller-owned body copies.

type EnvelopeError

type EnvelopeError struct {
	Code  EnvelopeErrorCode
	Field string
	Cause error
}

EnvelopeError reports a bounded codec failure and preserves its cause without placing attacker-controlled cause text in Error().

func (*EnvelopeError) Error

func (e *EnvelopeError) Error() string

func (*EnvelopeError) Unwrap

func (e *EnvelopeError) Unwrap() error

type EnvelopeErrorCode

type EnvelopeErrorCode string

EnvelopeErrorCode is a stable machine-readable envelope failure reason.

const (
	EnvelopeErrorMalformed EnvelopeErrorCode = "malformed"
	EnvelopeErrorVersion   EnvelopeErrorCode = "version"
	EnvelopeErrorKind      EnvelopeErrorCode = "kind"
	EnvelopeErrorField     EnvelopeErrorCode = "field"
	EnvelopeErrorOrder     EnvelopeErrorCode = "order"
	EnvelopeErrorMissing   EnvelopeErrorCode = "missing"
	EnvelopeErrorInvalid   EnvelopeErrorCode = "invalid"
	EnvelopeErrorLength    EnvelopeErrorCode = "length"
	EnvelopeErrorTooLarge  EnvelopeErrorCode = "too-large"
	EnvelopeErrorDigest    EnvelopeErrorCode = "digest"
	EnvelopeErrorTrailing  EnvelopeErrorCode = "trailing"
)

type EnvelopeKind

type EnvelopeKind uint8

EnvelopeKind identifies the closed set of raw journal record shapes.

const (
	EnvelopeKindPublicEvent       EnvelopeKind = 1
	EnvelopeKindRuntimeControl    EnvelopeKind = 2
	EnvelopeKindOpeningFence      EnvelopeKind = 3
	EnvelopeKindApplicationPrefix EnvelopeKind = 4
	// EnvelopeKindCommandDisposition is the runtime's own durable, bodiless
	// statement of what became of ONE authorized dispatch attempt.
	EnvelopeKindCommandDisposition EnvelopeKind = 5
)

type FindCommandApplicationRequest

type FindCommandApplicationRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
	CommandID sessionwire.CommandID
}

FindCommandApplicationRequest names one accepted command whose journal evidence a caller wants. It carries no positioning members: the correlation is a question about the whole of a session's history, and a caller that could bound the walk could bound away the evidence.

type GetCatalogEntryRequest

type GetCatalogEntryRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
}

GetCatalogEntryRequest reads one session's authoritative catalog record.

type GetCommandRequest

type GetCommandRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
	CommandID sessionwire.CommandID
}

GetCommandRequest reads one accepted command by its public identity.

type GetDispositionCommandRequest added in v0.5.0

type GetDispositionCommandRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
	CommandID sessionwire.CommandID
}

GetDispositionCommandRequest names one command in the disposition namespace.

type GetHostRegistrationRequest

type GetHostRegistrationRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
}

GetHostRegistrationRequest reads one session's current route.

type GetObjectMetadataRequest added in v0.4.0

type GetObjectMetadataRequest struct {
	TenantID     sessionwire.TenantID
	SessionID    sessionwire.SessionID
	ExpectedKind ObjectKind
	Reference    sessionwire.ObjectReference
}

GetObjectMetadataRequest resolves one logical reference in a caller-authorized tenant/session scope and an explicit semantic kind.

type GetObjectRequest

type GetObjectRequest struct {
	TenantID     sessionwire.TenantID
	SessionID    sessionwire.SessionID
	ExpectedKind ObjectKind
	Metadata     sessionwire.ObjectMetadata
}

GetObjectRequest names a verified object and the semantic kind the caller is authorized to consume.

type GetReconciliationClaimRequest

type GetReconciliationClaimRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
}

GetReconciliationClaimRequest reads one session's current claim.

type GetSessionPointerRequest

type GetSessionPointerRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
}

GetSessionPointerRequest reads one session's current pointer of one role.

type HostAdvertisement

type HostAdvertisement struct {
	InternalEndpoint  sessionwire.InternalEndpoint
	IsolationClass    sessionwire.HostIsolationClass
	Accepting         bool
	AvailableCapacity uint64

	// ExpiresAt is the promise the Host makes about its next heartbeat, and it
	// is the row's due time. It lives on the advertisement rather than on the
	// record because a withdrawn row has no next heartbeat to promise: putting
	// it here is what makes "withdrawn implies not due" a statement about the
	// record's SHAPE instead of a rule someone has to remember.
	ExpiresAt time.Time
}

HostAdvertisement is the offered half of a Host target row: everything a Factory needs to decide whether to place a NEW session on this Host, and nothing that would let it claim an existing one.

It is a separate pointer member rather than a group of optional fields, and that is what makes withdrawal correct BY CONSTRUCTION rather than by convention. A withdrawn row has no advertisement, and "no advertisement" is one nil rather than an enumeration of five zero values that a sixth member would silently escape — and, more importantly here, both derived views read that one nil, so a withdrawn row cannot be ranked and cannot be due.

type HostRegistration

type HostRegistration struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID

	LeaseEpoch uint64

	ObservedAt time.Time
	ExpiresAt  time.Time

	// Route is nil exactly when this registration is a released tombstone.
	Route *HostRoute
}

HostRegistration is the authoritative durable record of where one session is currently running, and of the lease epoch that fact was observed under.

It is an EXPIRING ROUTING HINT over a PERMANENT FENCE, and those two halves have opposite lifetimes:

  • The route expires. A reader past ExpiresAt must treat the registration as absent, because the Host that published it may have died at any instant after ObservedAt and nothing will tell this record about it.
  • LeaseEpoch does not expire, ever. It is the high-water mark that refuses a superseded Host's write, and it is the reason an expired registration and a released one are RETAINED rather than deleted. Dropping the row would drop the fence, and the next write from a lease that has already lost the session would be admitted.

A nil Route is the released tombstone: the registration a graceful shutdown leaves behind. It carries the fence and nothing else, so no reader can route to it however its timestamps read.

The accumulation that follows is intentional and affordable: one small, permanent row per session that has ever been registered, never listed, never ranked, never due, and never read except by name. The reader cost is nil, which is what makes permanence the right answer rather than a debt.

CARRY-FORWARD CONTRACT for whoever adds retention: THE ONLY SAFE REAPER IS ONE THAT REMOVES THE SESSION'S WHOLE SCOPE AT ONCE — this row, its catalog record, its journal, its commands, and its collision witnesses — because deleting this row ALONE destroys the fence while leaving the session registrable, which is precisely the state the retention exists to prevent. A sweep that walks record kinds independently and reclaims the cheapest one first will reach this one first, and it must not.

WHAT THIS RECORD OWNS, AND WHAT THE CATALOG OWNS. Three members appear in both records, and in every case the catalog's is Factory-authored DESIRED state or a durable status projection while this one's is the Host's OBSERVED answer:

  • Placement. CatalogRecord.DesiredPlacement is what a Factory ASKED for under its own revision/idempotency CAS. HostRoute.Placement is the admission model the session is actually running under. The two disagree for the whole interval between a desired change and its reconciliation, and a router that used the desired value during that interval would bind to a Host that is not serving the session.
  • Residency. CatalogRecord.Residency is the session's last known status, which is what a picker renders and which must survive this record's expiry. HostRoute.Residency is the ROUTABLE residency: it is only ever read together with the route it qualifies, and it disappears with it. A session whose registration has expired is cold no matter what the catalog's projection last said, which is exactly the statement the catalog cannot make because it does not expire.
  • RuntimeCompatibilityID and AgentID. The catalog's are what the session was created for and what a Factory later desired; the route's are what the running Host actually loaded. A Factory reusing a route must check the observed one, because a Host that has been restarted onto a newer runtime is not compatible with a session pinned to the older one.

LeaseEpoch appears in both too, and there the duplication is real and deliberate: each record carries the epoch ITS OWN writes are fenced at. Neither is derived from the other, neither is read to decide the other, and the two advance independently — a Host that updates its catalog projection without refreshing its registration leaves this record at the older epoch, which is correct, because the fence protects the record it lives on.

func (HostRegistration) Observation

Observation projects a live registration into Core's HostLink vocabulary, which is the form a Factory sends over a HostLink binding.

It is also the record's OWN validator for every member of the route, and that is the point of routing both through one function. Core defines what a Host route means — that the endpoint is a credential-free WebSocket address, that the placement is one of two admission models, that the residency of a routed session is attaching, resident, or releasing rather than cold, that the expiry falls after the observation — and a second enumeration of those rules here would be free to drift from the one a Factory's peer actually applies. A registration this package stores is therefore always projectable, and the day Core adds a member to the observation this stops compiling rather than silently storing a record that cannot be projected.

A released registration has no projection at all: a tombstone is not a route, and there is no version of Core's observation that expresses one.

It is a projection of the BYTES and not a routing decision. It does not know the store's clock and therefore does not know whether the route has lapsed; GetHostRegistration makes that decision, and it is the only thing that does.

type HostRegistrationEntry

type HostRegistrationEntry struct {
	Registration HostRegistration
	Revision     uint64
}

HostRegistrationEntry is a registration together with the revision a later compare-and-swap names.

The provider's immutable acceptance order is deliberately NOT exposed, for the reason CatalogEntry's is not: a session's position in a stream of registrations is not a fact any caller acts on. Commands expose theirs because arrival order is the thing consumers sort by.

type HostRoute

type HostRoute struct {
	HostID         sessionwire.HostID
	HostGeneration uint64

	AgentID                sessionwire.AgentID
	RuntimeCompatibilityID string

	Placement        sessionwire.HostPlacement
	InternalEndpoint sessionwire.InternalEndpoint
	Residency        sessionwire.SessionResidency
	Accepting        bool
}

HostRoute is the routable half of a Host registration: everything a Factory needs to establish a HostLink to the process currently holding the session.

It is a separate pointer member rather than a group of optional fields on the record, and that is what makes the tombstone below correct BY CONSTRUCTION. A released registration has no route, and "no route" is one nil rather than an enumeration of eight zero values that a ninth member would silently escape.

Every member is OBSERVED — what the Host reports it is actually doing — which is the whole difference from the catalog record. See HostRegistration.

type HostTarget

type HostTarget struct {
	Key HostTargetKey

	HostID         sessionwire.HostID
	HostGeneration uint64

	ObservedAt time.Time

	// Advertisement is nil exactly when this row is withdrawn.
	Advertisement *HostAdvertisement
}

HostTarget is one Host's current advertisement for one target: the durable row behind a placement decision.

IT IS CAPACITY, NOT AUTHORITY, AND THAT BOUNDARY IS STRUCTURAL. This record names no tenant, no session, and no lease epoch — there is nowhere in it to spell one — and the projection it publishes to a reader, core's HostLinkCapacityReport, has no such member either. A Factory that has read this row knows a Host said it could take work; it knows nothing whatsoever about who owns any session. The record that answers THAT question is HostRegistration, whose LeaseEpoch is the fence, and no code path leads from this file to it. TestHostTargetsCannotSpellSessionOwnership pins the structural half of that claim so it cannot decay into prose.

HostGeneration is on the record rather than on the advertisement because a withdrawn row keeps it. It is a WRITE-ORDERING high-water mark over one row, and the distinction from the registry's epoch is worth stating precisely, because the two would otherwise look like the same mechanism:

  • HostID is part of this row's identity, so the only writers of this row are incarnations of ONE Host. The generation orders that Host's own writes against each other and against nothing else.
  • What it prevents is a liveness fault, not a safety one. A restarted Host publishes at a higher generation; a heartbeat or a drain still in flight from the dead incarnation would otherwise overwrite live capacity with a dead process's view of it, and the target would flap.
  • It confers NO right over any session. A Host holding the highest generation on a capacity row has proven only that it is the newest incarnation of itself.

The rows are removable, which is the other half of the difference from the registry. See hostTargetDue for what removes one and why nothing here is retained forever.

func (HostTarget) Report

Report projects an advertised target into Core's capacity vocabulary, which is the form a Factory reads when choosing where to place a session.

It is also the record's OWN validator for every member of the advertisement, and that is the point of routing both through one function. Core defines what a capacity report means — that the endpoint is a credential-free WebSocket address, that the placement is one of two admission models, that the isolation class is one of two boundaries, that a dedicated target cannot advertise more than one seat, that the expiry falls after the observation — and a second enumeration of those rules here would be free to drift from the one a Factory's peer actually applies. A target this package stores is therefore always projectable, and the day Core adds a member to the report this stops compiling rather than silently storing a record that cannot be projected.

A withdrawn row has no projection at all: a withdrawal is not an offer of capacity, and there is no version of Core's report that expresses one.

It is a projection of the BYTES and not a placement decision. It does not know the store's clock and therefore does not know whether the advertisement has lapsed; hostTargetLiveness makes that decision, and it is the only thing that does.

type HostTargetEntry

type HostTargetEntry struct {
	Target   HostTarget
	Revision uint64
}

HostTargetEntry is one advertisement together with the revision a later compare-and-swap names.

It is returned to the HOST that owns the row — from a publish, a drain, and the reconciler's own bookkeeping — and deliberately not to a placement reader, which receives projections instead. See HostTargetPage.

The provider's immutable acceptance order is not exposed, for the reason CatalogEntry's is not: a row's position in a stream of advertisements is not a fact any caller acts on.

type HostTargetError

type HostTargetError struct {
	Code       HostTargetErrorCode
	Field      string
	Generation uint64
	Revision   uint64
	Cause      error
}

HostTargetError is a typed, redacted Host target directory failure. Field names the offending input or stage and never carries a provider name, a key, or a record payload.

Revision is populated only for HostTargetErrorConflict, carrying the value that is itself the answer, as the other record kinds do for that code.

Generation is populated only for HostTargetErrorGeneration, carrying the high-water mark that refused the write so a superseded incarnation learns it has been superseded rather than retrying forever.

There is deliberately no epoch member of any kind. A caller cannot obtain a lease epoch from this type because there is no lease epoch in this record to obtain.

func (*HostTargetError) Error

func (e *HostTargetError) Error() string

func (*HostTargetError) Unwrap

func (e *HostTargetError) Unwrap() error

type HostTargetErrorCode

type HostTargetErrorCode string

HostTargetErrorCode classifies a Host target directory failure.

It is its own vocabulary rather than the registry's, and the reason is not merely that they are separate aggregates. It is that a caller MUST NOT be able to write one handler for both. A RegistryError is the public account of who is running a session; a HostTargetError is the public account of who might be able to take one. Sharing a type would let a caller branch on "expired" without knowing which of those two questions it had just asked, and the whole discipline of this record is that capacity is never authority.

The codes are the ones the ordinary vocabulary supplies, with three that need their reasons stated:

  • Withdrawn — the row exists and offers no capacity. It is what a drain leaves and what the reconciler writes; it is not an error in a caller and it is not a claim about any session.
  • Generation — the caller named a Host generation BELOW the row's committed high-water mark, so the write comes from a superseded incarnation of that same Host. It is deliberately NOT called Epoch: RegistryErrorEpoch means a lease has provably lost a SESSION, and a code sharing that name would invite a reader to believe this record fences ownership. It does not. See HostTarget.
  • Cursor — a page token this store did not issue for this exact target. The walk restarts from the first page; nothing is wrong with the store.

Conflict means a lost revision compare-and-swap and nothing else — re-read and retry — which is the meaning it has for every other record kind here.

TWO ERROR TYPES REACH A CALLER OF THIS RECORD, and a consumer must handle both. Validating a target reports *InvalidIdentityError for AgentID, because that is what every identity derivation in this package reports for a sessionwire identity, while the opaque runtime id and the placement enum report *HostTargetError — so one malformed request surfaces as either type depending on which member is wrong. That is the package's convention rather than this record's choice, and it is written down here because this record is the first one a Host or a Factory calls.

const (
	HostTargetErrorInvalid    HostTargetErrorCode = "invalid"
	HostTargetErrorNotFound   HostTargetErrorCode = "not_found"
	HostTargetErrorWithdrawn  HostTargetErrorCode = "withdrawn"
	HostTargetErrorDeleted    HostTargetErrorCode = "deleted"
	HostTargetErrorIdentity   HostTargetErrorCode = "identity"
	HostTargetErrorGeneration HostTargetErrorCode = "generation"
	HostTargetErrorConflict   HostTargetErrorCode = "conflict"
	HostTargetErrorCursor     HostTargetErrorCode = "cursor"
	HostTargetErrorUnknown    HostTargetErrorCode = "unknown"
	HostTargetErrorBackend    HostTargetErrorCode = "backend"
	HostTargetErrorMalformed  HostTargetErrorCode = "malformed"
	HostTargetErrorVersion    HostTargetErrorCode = "version"
	HostTargetErrorTooLarge   HostTargetErrorCode = "too_large"
)

type HostTargetKey

type HostTargetKey struct {
	AgentID                sessionwire.AgentID
	RuntimeCompatibilityID string
	Placement              sessionwire.HostPlacement
}

HostTargetKey is the target half of an advertisement's identity: the (agent, runtime, placement) triple a Host offers capacity FOR.

It is a struct rather than three parameters because it is threaded through the derivation, the request types, and the page, and because the whole triple is what selects a provider scope. A caller that dropped one member would otherwise be selecting a different target while looking correct.

This is derived capacity, not a definition catalogue. Nothing here says an agent or a runtime EXISTS; it says a Host is currently willing to serve one.

type HostTargetPage

type HostTargetPage struct {
	Hosts             []sessionwire.HostLinkCapacityReport
	LapsedSkipped     int
	UnreadableSkipped int
	NextCursor        sessionwire.Cursor
}

HostTargetPage is one bounded page of live capacity for one target.

Hosts carries core's capacity reports rather than this package's entries, and that is the capacity/authority boundary made structural on the read side. A HostLinkCapacityReport has no tenant member, no session member, and no epoch member, so a Factory holding one cannot mistake it for a claim on anything — there is nothing in it to mistake. The revision a compare-and-swap needs goes to the HOST that owns the row, through publish and drain, and a placement reader has no business rewriting another process's advertisement.

TWO COUNTS say why a page is shorter than its limit, and neither is a diagnostic afterthought. A page that silently returned fewer entries would leave a caller unable to tell "this target has little capacity" from "this target is full of rows I passed over", and the two causes want different responses:

  • LapsedSkipped counts rows whose heartbeat promise had already lapsed at the store's clock. Such a row is still RANKED, so it still occupies a position in every later page; a nonzero count means the directory is owed a ReconcileHostTargets pass, which is the only thing that clears it.
  • UnreadableSkipped counts rows this build could not decode, or that disagreed with the filing they were found under.

THE RULE FOR AN UNREADABLE ROW IS STATED HERE AND NOWHERE ELSE, because a rule restated in five places is a rule that drifts in four of them. Such a row is skipped rather than FAILING THE PAGE, and that is load-bearing rather than lenient: nothing in this package ever rewrites a row it cannot read — a newer writer may have produced it — so the row is permanent, and a reader that failed the page on one would take every Host serving that target out of service for as long as it existed, with no recovery path anywhere in the system. Skipping keeps the newer writer's row untouched and starts publishing it the instant a reader that understands it asks; the count keeps the condition visible; and README.md records that a genuinely corrupt row has no in-band repair at all. ListDueGates and ListSessions obey the same rule for the same reason.

A page may therefore contain fewer than Limit entries while still issuing a continuation. A caller that wants a specific number of candidates pages until NextCursor is empty; it must not treat a short page as the end of the target.

type HostTargetReconcileResult

type HostTargetReconcileResult struct {
	Scanned    int
	Withdrawn  int
	StillLive  int
	Contended  int
	Unreadable int
	Unverified int

	Exhausted  bool
	NextCursor sessionwire.Cursor
}

HostTargetReconcileResult accounts for every row one sweep scanned. The five outcomes sum to Scanned, which is asserted rather than assumed: a sweep that silently dropped a row would otherwise look like a sweep that had nothing to do.

  • Withdrawn — the row's stored expiry had genuinely lapsed and the sweep removed it from both views.
  • StillLive — the row named by the due page had not actually lapsed when the sweep revalidated its stored expiry. The due view is weakly consistent and a Host may have heartbeated since; this is the count of rows the revalidation SAVED.
  • Contended — the compare-and-swap lost to a concurrent write, or the row moved out from under it. Nothing was decided and a later sweep will see the row again if it is still lapsed.
  • Unreadable — the row could not be decoded, or disagreed with the filing it was found under. The sweep steps over it and does NOT rewrite it, for the reason HostTargetPage gives. A nonzero count is an operator's signal, not a transient: nothing retires such a row.
  • Unverified — the compare-and-swap COMMITTED and the provider's reply then failed this package's checks on it. The withdrawal is durable, so the row is handled and no later sweep will revisit it, but this sweep cannot say that it is: the reply it was given does not describe what it wrote. It is its own outcome rather than folded into Contended, which means the opposite — that nothing was decided.

Exhausted reports whether the sweep reached the end of the due view within its page budget. NextCursor is nonempty exactly when it did not, and a caller that wants the whole view hands it back — see ReconcileHostTargetsRequest for why that is a correctness property rather than a convenience.

type InboxEntry

type InboxEntry struct {
	Record        InboxRecord
	Revision      uint64
	AcceptedOrder uint64
}

InboxEntry is a command record together with the provider state a caller needs: the revision a later compare-and-swap names, and the immutable acceptance order.

AcceptedOrder is exposed, and CatalogEntry's order deliberately is not. The difference is that a catalog record's order means nothing to anyone — a session's position in a creation stream is not a fact any caller acts on — while a command's acceptance order is the durable arrival order of commands within a session, which consumers sort a bounded ListOrdered page by and which a retry must receive unchanged as evidence that it is the same acceptance.

It is an OPAQUE COMPARISON KEY and nothing else:

  • It is strictly increasing within one session's order scope: once a command has order 12, no command in that session becomes newly observable at 11.
  • It is NOT contiguous and NOT one-based. A provider may allocate from a JetStream stream sequence or a shared SQL sequence, so a session's first command can have order 5000 and its second 9000. Nothing may derive a count, a position, or "the next" order from it.
  • It is not comparable across sessions or tenants. Two sessions' orders come from different scopes and may interleave arbitrarily.

func (InboxEntry) CommandStatus

func (e InboxEntry) CommandStatus() (sessionwire.CommandStatus, error)

CommandStatus projects the durable record onto core's public command status.

The durable machine has five states and the public vocabulary has four, so the projection makes one semantic choice, and it is this: an UNCLAIMED pending command is accepted, while claimed and applying are pending.

Core's own words settle it. "Accepted means the inbox commit succeeded; it does not promise that a Host has already applied the command" — which is exactly and only what this store knows about a command nobody has picked up. Once a writer has claimed it, something more than the commit is true: the command is being worked on, and the public state that says so is pending. The alternative — reporting every non-terminal command as accepted — would make the public status say nothing that the acknowledgement of the original request had not already said, for the whole life of the command.

The claim's LIVENESS deliberately does not enter into it, and the reason is a property the mapping has and would otherwise lose: it is MONOTONE. No transition in this file writes a state that projects backwards — nothing returns a claimed or applying command to pending — so a public status never regresses from pending to accepted, and a caller polling one sees a sequence that only moves forward. A liveness-sensitive mapping would break exactly that: a command whose claim lapsed would report accepted again, and would flap between the two as claims were taken and expired, on a schedule that is a property of the clock rather than of the command.

It lives here rather than beside the record because the mapping is a statement about the machine, and it is offered here rather than left to each consumer because Factory, Host and any later reader answering "what happened to my command" must not each invent their own answer.

type InboxError

type InboxError struct {
	Code     InboxErrorCode
	Field    string
	Epoch    uint64
	Order    uint64
	Revision uint64
	Cause    error
}

InboxError is a typed, redacted command failure. Field names the offending input or stage and never carries a provider name, a key, or any part of the command's private payload.

Revision is populated only for InboxErrorConflict, carrying the value that is itself the answer, as CatalogError does for that code.

Epoch and Order are the two consumption-cursor high-water marks, and BOTH are populated for BOTH of the two fence codes — InboxErrorEpoch and InboxErrorOrder — rather than one each. That is deliberate and is the rule PointerError already follows: a caller that has to raise its epoch will have to satisfy the position too, and one round trip is enough to learn both. Epoch is additionally populated for InboxErrorEpoch on every other path that fences an epoch. Neither member means anything for any other code.

func (*InboxError) Error

func (e *InboxError) Error() string

func (*InboxError) Unwrap

func (e *InboxError) Unwrap() error

type InboxErrorCode

type InboxErrorCode string

InboxErrorCode classifies a durable command record failure.

It is the inbox's own vocabulary rather than the catalog's, and the reason is ownership. Gates reuse CatalogError because a gate IS catalog state: the projection lives in the catalog record and the deadline intent is an index into it, so a gate failure is a statement about that record. A command is a separate aggregate — its own namespace, its own record, its own identity, its own lifecycle — and admission never reads or writes a session's catalog record. A caller branching on an inbox failure should not have to match the catalog's type to learn that its command was not stored, and the command lifecycle's later states need failures the catalog has no business naming.

CommandMismatch is definite and caller-caused: one command id was reused for a DIFFERENT command, and the stored command is untouched. No retry helps — the caller must mint a new id or send the command it originally sent.

It is deliberately NOT called "conflict", and the omission is the point. This file already spells "conflict" two ways. CatalogErrorConflict means a lost revision compare-and-swap: recoverable, provider-caused, carrying the actual revision, and explicitly inviting a re-read and a retry. ObjectErrorConflict means a key already holding DIFFERENT CONTENT, which is the near-twin of what a reused command id is — so a reader who met that one first would reasonably expect "conflict" here and get the catalog's recovery advice instead.

The tie goes to the catalog's meaning because of what this record IS: an OrderedIndex row has a Revision, so the command transition machine will compare-and-swap it and will need a name for losing that race. The object aggregate never will. InboxErrorConflict therefore carries the catalog's meaning exactly — a lost revision compare-and-swap, recoverable, reporting the actual revision, inviting a re-read and a retry — and the caller-caused content case takes a name that cannot be mistaken for either neighbour.

Unknown means the mutation's outcome could not be resolved at all, so the caller learns nothing about what is stored and must retry the same identity to find out.

Identity means a stored record disagreed with the identity it was filed under or asked for. It is not a caller error and not a conflict: it means the provider's answer cannot be trusted, and no retry of the caller's fixes it.

The transition machine adds the rest, and each one names a DIFFERENT recovery so that a caller can branch without reading prose:

  • NotFound — no command has ever been admitted under that identity. The caller is asking about something it never accepted.
  • Conflict — the record moved under the caller. Re-read and decide again. Revision carries what the record is at now when the store could see it.
  • Epoch — the caller named a lease epoch BELOW the epoch the record's claim was taken under. That lease has provably been superseded and must not retry under the same epoch; Epoch carries the committed high-water mark, as CatalogErrorEpoch does.
  • ClaimHeld — a LIVE claim is held on this command and is not provably the caller's. It cannot say "someone else": a claim records the lease epoch it was taken under and no claimant identity, so two writers under one epoch are indistinguishable to it. That matters for the likeliest recipient, which is not a rival but a claimer meeting its OWN live claim: a claim cannot be renewed, so a caller that wants more time must enter applying before its claim lapses, and waiting for the claim to expire — the advice that fits a rival — is the one thing that caller must not do.
  • ClaimLost — the caller does not hold the live claim the transition requires, and Field says which of the two situations it is. "lease_epoch" means the claim is held under another epoch, so the caller never held this command; "claim" means the caller's own claim lapsed. Both are answered by claiming again, which the apply deadline may no longer permit, but they read very differently to an operator: the first is a writer working on a command that is not its own, the second is a writer that was too slow.

ClaimHeld and ClaimLost are the pair the journal already spells for lease ownership, and they mean the corresponding two things here.

  • Deadline — a NEW claim was attempted at or after the command's apply deadline. No retry helps: the command is now the deadline reconciler's, and the caller learns its answer by reading the terminal record.
  • State — the record is in a state this transition has no edge out of, and the caller had a current revision when it asked. It is a caller mistake about the machine rather than a race.
  • Evidence — the journal does not support the settlement asked for, and Field says which question it failed. "application" means the correlation did not establish that no effect committed — either one did, or the evidence is not readable — so the command must be finished or left alone rather than rejected. "result" means a recovering successor named a terminal result that is not the effect its prefix is correlated with. "applying_lease" means the lease holding the record's claim is not yet provably fenced out of the journal, so it could still commit the effect this settlement would orphan. None of the three is a race and none is answered by retrying the same call unchanged: the first two are permanent for the journal as it stands, and the third becomes settleable only once a later lease has opened the stream.
  • Terminal — the command's outcome is already settled. It is separate from State because it is the one state failure that is PERMANENT and that carries an answer: a caller meeting it should read the record and report the outcome rather than re-deciding anything.
const (
	InboxErrorInvalid         InboxErrorCode = "invalid"
	InboxErrorCursor          InboxErrorCode = "cursor"
	InboxErrorCommandMismatch InboxErrorCode = "command_mismatch"
	InboxErrorNotFound        InboxErrorCode = "not_found"
	InboxErrorDeleted         InboxErrorCode = "deleted"
	InboxErrorIdentity        InboxErrorCode = "identity"
	InboxErrorConflict        InboxErrorCode = "conflict"
	InboxErrorEpoch           InboxErrorCode = "epoch"
	InboxErrorOrder           InboxErrorCode = "order"
	InboxErrorClaimHeld       InboxErrorCode = "claim_held"
	InboxErrorClaimLost       InboxErrorCode = "claim_lost"
	InboxErrorDeadline        InboxErrorCode = "deadline"
	InboxErrorState           InboxErrorCode = "state"
	InboxErrorEvidence        InboxErrorCode = "evidence"
	InboxErrorTerminal        InboxErrorCode = "terminal"
	InboxErrorUnknown         InboxErrorCode = "unknown"
	InboxErrorBackend         InboxErrorCode = "backend"
	InboxErrorMalformed       InboxErrorCode = "malformed"
	InboxErrorVersion         InboxErrorCode = "version"
	InboxErrorTooLarge        InboxErrorCode = "too_large"
)

type InboxRecord

type InboxRecord struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
	CommandID sessionwire.CommandID

	RuntimeCommandID RuntimeCommandID
	Kind             CommandKind

	Payload    []byte
	PayloadRef sessionwire.ObjectReference

	AcceptedAt    time.Time
	ApplyDeadline time.Time

	State     InboxState
	Claim     CommandClaim
	Result    CommandResult
	Rejection *sessionwire.ErrorDetail
}

InboxRecord is the authoritative durable record of one accepted command.

Payload and PayloadRef are PRIVATE: they are the command's body, they are never part of a public projection, and no failure this package returns carries either of them. At most one of the two is set — an inline body up to MaxInboxPayloadBytes, or a reference to an object persisted first — and neither is set for a command that has no body. They are also IMMUTABLE for the life of the record, as Kind is: emptying or rewriting one turns every later retry of the command into a permanent mismatch — see sameCommandAs.

The acceptance order is deliberately NOT a member here. It is allocated by the provider at Create and is therefore not part of the bytes this record encodes; it is reported on InboxEntry, where its origin is unambiguous.

type InboxState

type InboxState string

InboxState is the durable processing state of one accepted command.

The transitions between them — including which writer may make each one, what a claim epoch fences, and what a terminal CAS must set — are inbox_claim.go's. Admission produces InboxStatePending and never anything else. What each state must and must not carry is validateInboxState, below, because that is a property of the stored record rather than of the operation that wrote it.

const (
	InboxStatePending  InboxState = "pending"
	InboxStateClaimed  InboxState = "claimed"
	InboxStateApplying InboxState = "applying"
	InboxStateApplied  InboxState = "applied"
	InboxStateRejected InboxState = "rejected"
)

type InvalidBackendError

type InvalidBackendError struct {
	Component string
}

InvalidBackendError reports a storage component or required capability that was not wired at Open. Component is one of Composite, Ledger, Leaser, KV, OrderedIndex, Blobs, or BlobReaderLifecycle.

func (*InvalidBackendError) Error

func (e *InvalidBackendError) Error() string

type InvalidBackgroundWorkError

type InvalidBackgroundWorkError struct{}

InvalidBackgroundWorkError reports a nil internal background work function.

func (*InvalidBackgroundWorkError) Error

type InvalidIdentityError

type InvalidIdentityError struct {
	Field string
	Cause error
}

InvalidIdentityError identifies which opaque identity failed validation without retaining or rendering its value.

func (*InvalidIdentityError) Error

func (e *InvalidIdentityError) Error() string

func (*InvalidIdentityError) Unwrap

func (e *InvalidIdentityError) Unwrap() error

type InvalidLimitError

type InvalidLimitError struct {
	Field string
	Value int64
	Min   int64
	Max   int64
}

InvalidLimitError reports a limit outside its inclusive valid range.

func (*InvalidLimitError) Error

func (e *InvalidLimitError) Error() string

type InvalidOptionError

type InvalidOptionError struct {
	Field string
	Cause error
}

InvalidOptionError reports an invalid Open option. When Cause is non-nil it is preserved for errors.Is and errors.As.

func (*InvalidOptionError) Error

func (e *InvalidOptionError) Error() string

func (*InvalidOptionError) Unwrap

func (e *InvalidOptionError) Unwrap() error

type JournalEpoch added in v0.6.0

type JournalEpoch uint64

JournalEpoch is a runtime grant in a session's bound agent journal. It is meaningful only with that journal's lease namespace and the session binding that resolves it, and it must never be compared with, or assigned from, a ResidencyEpoch: the two are different authorities over different stores. The runtime returns this value from its own journal ownership capability; a Host may not copy its residency epoch into it.

type JournalError

type JournalError struct {
	Code  JournalErrorCode
	Field string
	Epoch uint64
	Cause error
}

JournalError is a typed, redacted journal failure. Field names the offending input or stage and never carries a provider name, key, or record payload. Epoch is populated only where a fencing epoch is itself the answer — the live holder's epoch for lease_held, this writer's epoch for lease_lost.

func (*JournalError) Error

func (e *JournalError) Error() string

func (*JournalError) Unwrap

func (e *JournalError) Unwrap() error

type JournalErrorCode

type JournalErrorCode string

JournalErrorCode classifies a journal ownership, append, or read failure.

Fenced and Unknown are deliberately distinct outcomes of one CAS append. Fenced is definite — a successor's record occupies the contested sequence, so this writer has provably lost the stream. Unknown means the outcome could not be resolved at all, so the writer's own tip is no longer trustworthy. Both end the writer permanently; only Fenced asserts that someone else won.

const (
	JournalErrorInvalid   JournalErrorCode = "invalid"
	JournalErrorLeaseHeld JournalErrorCode = "lease_held"
	JournalErrorLeaseLost JournalErrorCode = "lease_lost"
	JournalErrorFenced    JournalErrorCode = "fenced"
	JournalErrorUnknown   JournalErrorCode = "unknown"
	JournalErrorClosed    JournalErrorCode = "closed"
	JournalErrorBackend   JournalErrorCode = "backend"
	JournalErrorIntegrity JournalErrorCode = "integrity"
	JournalErrorTooLarge  JournalErrorCode = "too_large"
	JournalErrorCursor    JournalErrorCode = "cursor"
)

type JournalWriter

type JournalWriter struct {
	// contains filtered or unexported fields
}

JournalWriter is one epoch-fenced single-writer grant over a session's journal. It is safe for concurrent use; every append is serialized.

Ownership algorithm. OpenJournal acquires a lease grant, reads the tip exactly once, and appends an opening fence at precisely that tip stamped with the grant's epoch. If that CAS conflicts the grant is spent: OpenJournal releases the lease and returns a typed conflict, and the caller may acquire a fresh, strictly higher epoch and reopen. It deliberately does NOT refresh the tip and retry, because a retry loop lets a writer silently reorder itself behind records it never observed.

After a successful open the writer tracks only its own committed sequence and CASes every later append on it. It never re-reads the tip, so a successor's opening fence permanently fails this writer at its next append. An append whose outcome could not be resolved is equally terminal: rather than re-read and rebase onto whatever is now durable, the writer latches the failure and refuses every later append.

func (*JournalWriter) Append

func (w *JournalWriter) Append(ctx context.Context, env Envelope) (uint64, error)

Append commits one record and returns its journal sequence.

The writer owns the fields that carry ownership: an opening fence may not be appended by a caller at all, and an application prefix must leave LeaseEpoch zero for the writer to stamp.

Stamping the epoch is ALL this file checks about a prefix. What a prefix may be appended for — which command, in what position relative to its effect, and never for a command claimed under a lower grant than this writer's — is the writer obligation stated in inbox_recovery.go, and it is unenforceable here: this writer has no view of the inbox. A prefix appended against it does not fail, it makes its command unrecoverable, so read that list before emitting one. A body above the overflow threshold is uploaded as an immutable object and verified before its reference is appended; if the append then fails the verified object is deliberately left behind as an orphan for garbage collection rather than deleted against a provider that has just proved unreliable.

A conflicting, unresolved, or ownership-lost append ends the writer: the failure is latched and every later Append returns it. A definite backend failure leaves the tracked tip untouched and does not latch, so the caller may retry the same record.

func (*JournalWriter) Close

func (w *JournalWriter) Close(ctx context.Context) error

Close releases the lease grant and the Store admission. It is idempotent and refuses every later append.

func (*JournalWriter) Epoch

func (w *JournalWriter) Epoch() uint64

Epoch returns the fencing epoch of this writer's lease grant.

func (*JournalWriter) Sequence

func (w *JournalWriter) Sequence() uint64

Sequence returns the last journal sequence this writer committed, starting at its own opening fence. It is never refreshed from the provider.

type KeyspaceError

type KeyspaceError struct {
	Code  KeyspaceErrorCode
	Cause error
}

KeyspaceError reports a fail-closed layout or physical-key failure. Cause is available to errors.Is/As, while Error deliberately omits provider and raw ID details.

func (*KeyspaceError) Error

func (e *KeyspaceError) Error() string

func (*KeyspaceError) Unwrap

func (e *KeyspaceError) Unwrap() error

type KeyspaceErrorCode

type KeyspaceErrorCode string

KeyspaceErrorCode is a stable machine-readable keyspace failure class.

const (
	KeyspaceBackend          KeyspaceErrorCode = "backend"
	KeyspaceMarkerMalformed  KeyspaceErrorCode = "marker_malformed"
	KeyspaceLayoutMismatch   KeyspaceErrorCode = "layout_mismatch"
	KeyspaceMarkerAmbiguous  KeyspaceErrorCode = "marker_ambiguous"
	KeyspaceBindingNotFound  KeyspaceErrorCode = "binding_not_found"
	KeyspaceBindingAmbiguous KeyspaceErrorCode = "binding_ambiguous"
	KeyspaceScopeInvalid     KeyspaceErrorCode = "scope_invalid"
	KeyspaceHashCollision    KeyspaceErrorCode = "hash_collision"
	KeyspaceLegacyTenant     KeyspaceErrorCode = "legacy_tenant"
	KeyspaceLegacySession    KeyspaceErrorCode = "legacy_session"
)

type Limits

type Limits struct {
	MaxPageSize int
}

Limits contains Store-wide ceilings. MaxPageSize bounds every provider page requested by SessionStore; individual operations may request a smaller page.

func DefaultLimits

func DefaultLimits() Limits

DefaultLimits returns bounded defaults for provider queries.

type ListCompatibleHostsRequest

type ListCompatibleHostsRequest struct {
	Key HostTargetKey

	// Cursor is a token a previous page of THIS target issued. It is opaque:
	// retain it and hand it back, but do not parse it or derive ordering,
	// identity, or authority from it. Possessing one authorizes nothing, and a
	// cursor this store did not issue for this target is refused with
	// HostTargetErrorCursor, which means the walk restarts from the first page
	// rather than that anything is wrong with the store.
	Cursor sessionwire.Cursor

	// Limit is the page's record ceiling. Zero means the store's configured
	// page size. It bounds the rows the PROVIDER returns, not the reports this
	// page publishes; see HostTargetPage.
	Limit int
}

ListCompatibleHostsRequest positions one bounded page of the Hosts currently offering capacity for one target, most free capacity first.

type ListDueCommandsRequest

type ListDueCommandsRequest struct {
	Shard         int
	DueAtOrBefore time.Time
	Limit         int
	Cursor        sessionwire.Cursor
}

ListDueCommandsRequest positions one bounded page of one shard's outstanding commands.

Shard names the control shard to read and must be below the store's ControlShards. A caller sweeps by visiting every shard round-robin; the store deliberately does not do that for it, because a replica that swept every shard in one call would hold the whole deployment's reconciliation in one request's latency and one caller's failure.

DueAtOrBefore is the inclusive wall-clock bound and is the FIRST page's query. A continuation carries its own bound, so a resumed request must leave this zero: presenting both would be two answers to one question, and silently preferring either is how a resumed sweep starts querying a bound it was never bound to.

type ListDueDispositionCommandsRequest added in v0.5.0

type ListDueDispositionCommandsRequest struct {
	Shard         int
	DueAtOrBefore time.Time
	Limit         int
	Cursor        sessionwire.Cursor
}

ListDueDispositionCommandsRequest pages one disposition-only control shard. Supply DueAtOrBefore on the first request or Cursor on continuations, never both. The cursor retains the original bound and cannot cross protocol kinds.

type ListDueGatesRequest

type ListDueGatesRequest struct {
	// Shard names the control shard to read and must be below the store's
	// ControlShards. A caller sweeps by visiting every shard round-robin.
	Shard int

	// DueAtOrBefore is the inclusive wall-clock bound and is the FIRST page's
	// query. A continuation carries its own bound, so a resumed request must
	// leave this zero; see dueGatePosition.
	DueAtOrBefore time.Time

	// Limit is the page's record ceiling. Zero means the store's configured
	// page size.
	Limit int

	// Cursor resumes a sweep of this shard from the position a previous page
	// ended at. It is opaque and is bound to this cursor kind and this shard.
	Cursor sessionwire.Cursor
}

ListDueGatesRequest positions one bounded page of one control shard's gates whose deadline has passed. It is cross-tenant rather than tenant-scoped, because the due view is: see gateNamespace, and see shards.go for what a shard is and for what "service-only" does and does not mean here.

type ListSessionDispositionCommandsRequest added in v0.7.0

type ListSessionDispositionCommandsRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID

	AfterOrder uint64
	Limit      int
}

ListSessionDispositionCommandsRequest positions one bounded page of ONE session's disposition inbox in immutable acceptance order.

AfterOrder is an EXCLUSIVE lower bound and is the caller's. Zero starts at the head of the session's stream, which is the provider's own spelling and is unambiguous because no provider allocates order zero. A caller resumes by passing the previous page's NextAfterOrder, or its own durable cursor.

THE BOUND IS THE CALLER'S AND THE ORDERING IS THE STORE'S. A consumer that sorted a page for itself would be inferring an order rather than reading one, and the order it inferred would be its own opinion about rows the provider had already ranked. Nothing in this request selects an order, a direction or a sort key, because there is exactly one and it is the provider's immutable acceptance order.

There is no Cursor member, deliberately. A page token would be a second way to say the one thing AfterOrder says, and the two would have to be reconciled on every continuation — which is the failure ListDueDispositionCommandsRequest documents at length for a query whose bound genuinely cannot be restated. This one's can: the bound IS a row's order, and a caller that has the row has the bound.

type ListSessionsRequest

type ListSessionsRequest struct {
	TenantID sessionwire.TenantID

	// Cursor is a token a previous page of THIS tenant issued. It is opaque:
	// retain it and hand it back, but do not parse it or derive ordering,
	// tenancy, or authority from it. Possessing one authorizes nothing — a
	// caller must authorize TenantID on its own — and a cursor this store did
	// not issue for this tenant is refused with CatalogErrorCursor, which
	// means the walk restarts from the first page rather than that anything is
	// wrong with the store.
	Cursor sessionwire.Cursor

	// Limit is the page's record ceiling. Zero means the store's configured
	// page size.
	Limit int
}

ListSessionsRequest positions one bounded recent-first page of a tenant's sessions. Cursor is a token a previous page issued; Limit is that page's record ceiling, and zero means the store's configured page size.

type LoadDispositionCommandCursorRequest added in v0.7.0

type LoadDispositionCommandCursorRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
}

LoadDispositionCommandCursorRequest reads one session's consumption cursor.

type ObjectError

type ObjectError struct {
	Code  ObjectErrorCode
	Field string
	Cause error
}

ObjectError is a typed, redacted object operation failure. Field names the offending input or stage and never carries a provider path, key, or payload.

Terminating an object stream can produce more than one failure at once — the cause that ended the stream, a read error observed by a racing reader, and a provider Close error — and those are reported as an errors.Join tree, so a returned error may contain several *ObjectError values. The FIRST one found by errors.As is the primary classification: it is the failure that caused termination, and later ones are subsidiary consequences of it. A caller that genuinely needs every code can walk the tree itself through the `Unwrap() []error` that errors.Join returns; the package deliberately does not export a set extractor, because classifying on the primary cause is the supported contract and an exported extractor would freeze the join shape.

func (*ObjectError) Error

func (e *ObjectError) Error() string

func (*ObjectError) Unwrap

func (e *ObjectError) Unwrap() error

type ObjectErrorCode

type ObjectErrorCode string

ObjectErrorCode classifies redacted object operation failures.

Digest and Integrity are deliberately distinct: Digest means the caller's own metadata is self-inconsistent (its Digest field disagrees with the digest inside its ObjectID), while Integrity means bytes or a stored key did not match what the object identity promised.

const (
	ObjectErrorInvalid   ObjectErrorCode = "invalid"
	ObjectErrorSize      ObjectErrorCode = "size"
	ObjectErrorDigest    ObjectErrorCode = "digest"
	ObjectErrorSource    ObjectErrorCode = "source"
	ObjectErrorBackend   ObjectErrorCode = "backend"
	ObjectErrorConflict  ObjectErrorCode = "conflict"
	ObjectErrorIntegrity ObjectErrorCode = "integrity"
	ObjectErrorCanceled  ObjectErrorCode = "canceled"
	// MetadataUnavailable means the scoped immutable index has no record. The
	// bytes may still exist (for example, objects written before the index).
	ObjectErrorMetadataUnavailable ObjectErrorCode = "metadata_unavailable"
)

type ObjectKind

type ObjectKind string

ObjectKind is a closed semantic class for immutable session objects.

const (
	ObjectKindJournalPublic       ObjectKind = "journal-public"
	ObjectKindJournalRuntime      ObjectKind = "journal-runtime"
	ObjectKindCommandPayload      ObjectKind = "command-payload"
	ObjectKindToolResult          ObjectKind = "tool-result"
	ObjectKindWorkspaceCheckpoint ObjectKind = "workspace-checkpoint"
	ObjectKindRuntimeCheckpoint   ObjectKind = "runtime-checkpoint"
	ObjectKindArtifact            ObjectKind = "artifact"
	ObjectKindAttachment          ObjectKind = "attachment"
	ObjectKindContinuation        ObjectKind = "continuation"
	ObjectKindRuntimeObject       ObjectKind = "runtime-object"
)

type OpenGateRequest

type OpenGateRequest struct {
	TenantID   sessionwire.TenantID
	SessionID  sessionwire.SessionID
	LeaseEpoch uint64
	Gate       sessionwire.GateProjection
}

OpenGateRequest projects one gate as publicly open and records its absolute deadline. LeaseEpoch is the writing Host's grant epoch, compared against the record's committed high-water mark exactly as UpdateCatalogHostState's is: open gates are Host-owned state.

Gate.OpenedJournalSeq must name an event at or below the record's durable journal tip. A gate whose opening event is not yet durable is refused rather than stored, because a reader would otherwise be handed a page claiming an event its own tip says does not exist.

type OpenJournalRequest

type OpenJournalRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
}

OpenJournalRequest names the session whose stream a writer wants to own.

type Option

type Option func(*config) error

Option configures Open.

func WithClock

func WithClock(clock Clock) Option

WithClock supplies the clock used by Store.

Only the clock VALUE is validated, and only for being non-nil. Nothing checks what it returns: there is no monotonicity requirement, no bound, and no comparison against the machine's own clock. That is a deliberate limit on what this package claims, and it has a consequence worth stating where the clock is supplied rather than leaving it to be discovered from whichever guard survived it.

The rule the package holds itself to instead is that a guard is a function of the RECORD, never of the clock alone. A stored record is validated against rankableTime, which bounds the instants a record may CARRY; it says nothing about a clock, so a predicate that compares against an absent or zero instant has to be total on its own — see claimLive in inbox_claim.go, whose zero-claim conjunct exists for exactly that reason.

What a wrong clock costs is therefore LIVENESS rather than safety. A clock running slow leaves claims looking live and deadlines looking distant, so work waits; one running fast expires claims early, so work is redone. Neither puts two writers on one command, because ownership is decided by the lease epoch and by the record's revision, and neither of those is a clock reading.

func WithControlShards

func WithControlShards(shards int) Option

WithControlShards names the number of service-control shards outstanding work is spread across.

IT IS NOT A RUNTIME SETTING, and the option is where that has to be said, because the name reads like one. The count is an input to controlShardOf, so it decides the namespace every inbox command and every gate deadline intent is FILED IN. Open persists it in the backend's layout marker and refuses a later Open of the same backend that names a different one; changing it for a backend that already holds records is an offline migration that must move them, not a redeploy with a new flag.

A larger count spreads a sweep across more replicas and makes any one shard's due page shorter. It is not free: a sweep visits every shard, so the count is a floor on the provider queries one pass costs even when nothing is due.

func WithDispositionEvidence added in v0.6.0

func WithDispositionEvidence(reader DispositionEvidenceReader) Option

WithDispositionEvidence configures the settlement evidence boundary. Without it SettleDispositionCommand refuses: a store with no reader has no way to verify anything and settling on record state alone is the overwrite the protocol exists to prevent.

func WithIOProviderOwnership

func WithIOProviderOwnership(closer io.Closer) Option

WithIOProviderOwnership explicitly transfers ownership of a provider whose released lifecycle contract is the standard io.Closer shape. Because io.Closer has no context, ShutdownTimeout can release the Store lifecycle but cannot force the underlying Close to return; its adapter goroutine may outlive the Store until the provider eventually returns. Transfer takes effect only after Open succeeds; a failed Open never closes the provider.

func WithJournalDispositionEvidence added in v0.9.0

func WithJournalDispositionEvidence() Option

WithJournalDispositionEvidence makes the Store its own settlement evidence reader, over each session's bound journal.

It is opt-in rather than the default because reading evidence is a privileged whole-journal replay, and a deployment that obtains its evidence elsewhere — or that never settles a disposition command at all — should not acquire that behaviour by omission.

It does not stack with WithDispositionEvidence, in either order, and it does not stack with itself. A store with two configured readers has two answers to one question and no rule for choosing between them, and silently keeping the last one would make the ORDER of two options decide which journal a settlement believes.

func WithLegacySingleTenant

func WithLegacySingleTenant(defaultTenant sessionwire.TenantID) Option

WithLegacySingleTenant explicitly adopts the historical unscoped layout for one tenant. It never probes for legacy data; Open atomically persists the choice and exact tenant in the backend layout marker.

func WithLimits

func WithLimits(limits Limits) Option

WithLimits sets Store-wide ceilings.

func WithLogger

func WithLogger(logger *slog.Logger) Option

WithLogger supplies the structured logger used by Store.

func WithProviderOwnership

func WithProviderOwnership(closer ProviderCloser) Option

WithProviderOwnership explicitly transfers provider lifecycle ownership to Store after Open succeeds. A failed Open leaves the provider caller-owned and never closes it. Without this option Close never closes caller-supplied storage.

func WithShutdownTimeout

func WithShutdownTimeout(timeout time.Duration) Option

WithShutdownTimeout bounds explicitly owned provider cleanup after Store background work drains.

type PlacementIntent

type PlacementIntent struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID

	AgentID                sessionwire.AgentID
	RuntimeCompatibilityID string

	Placement  sessionwire.HostPlacement
	Workload   DesiredWorkload
	Generation uint64
}

PlacementIntent is the complete Factory-authored answer to "what should exist for this session", and nothing else.

COMPLETE AS TO AUTHORSHIP, NOT AS TO SUFFICIENCY, and the difference decides whether a controller acting on one alone is correct. This is the only one of the record's three projections that drops State — Summary and Status both carry it — so an intent cannot say whether the session it describes is still alive. A controller holding only this could create a dedicated workload for a session that has ended. Read CatalogRecord.State, or Status(), from the SAME entry: one read of one record answers both questions, and taking them from one entry is what makes the pair consistent.

Excluding the observation is nonetheless right, and is this type's whole discipline — an intent that carried liveness would be a request and a fact in one value, and the next reader would not know which half it was acting on.

It is what a placement controller reads, and its shape is the reason it exists as a type rather than as a handful of catalog members a caller picks out. Every member here is a REQUEST. There is no lease epoch, no HostID, no endpoint, no residency and no journal position, so a consumer cannot read an observation out of it and cannot be one refactor away from treating a request as a fact — the Host registry's tuple is where observed placement lives, and it is fenced by an epoch this intent structurally cannot name.

Generation is what a controller records against the workload it created. A controller that reconciled generation 7 and reads 7 again has nothing to do, however many times the record has been rewritten in between.

type PointerError

type PointerError struct {
	Code     PointerErrorCode
	Field    string
	Epoch    uint64
	Sequence uint64
	Revision uint64
	Cause    error
}

PointerError is a typed, redacted object pointer failure. Field names the offending input or stage and never carries a provider name, a key, or a record payload.

Revision is populated only for PointerErrorConflict, carrying the value that is itself the answer, as the other record kinds do for that code.

Epoch and Sequence are the two high-water marks the record retains, and they are populated by exactly the three codes that refuse a write or withhold a target: Epoch, Sequence and Cleared. Disclosing them is not a courtesy. They are the only durable facts a caller's next attempt has to satisfy, nothing else in this package reads them out, and a cleared pointer in particular hands back no record at all — so withholding them would leave probing by rejected write as a caller's only way to learn what it must name. The registry discloses its epoch on its two no-route codes for the same reason.

A DELIBERATE ASYMMETRY WITH THE TARGET: neither the refusals nor the cleared report names the object that IS stored. A caller learns what it must beat, never what it lost to.

func (*PointerError) Error

func (e *PointerError) Error() string

func (*PointerError) Unwrap

func (e *PointerError) Unwrap() error

type PointerErrorCode

type PointerErrorCode string

PointerErrorCode classifies an object pointer failure.

It is its own vocabulary rather than the catalog's, and the reason is the one this whole record exists for: the catalog carries a checkpoint SUMMARY that a projection write replaces wholesale, and this record carries the authoritative name. A caller that could handle both with one code set would be one refactor away from treating a failure to write the copy as a failure to write the truth, or the reverse.

The codes that need their reasons stated:

  • Cleared — the pointer exists and names nothing. It is not an error in the caller and not an absence: the record is there, it holds both high-water marks, and it is those marks the failure carries. Reporting it as NotFound would tell a caller that a session had never had this pointer, which licenses a first write at any epoch and any sequence.
  • Epoch — the request names a strictly lower lease epoch than the record's committed high-water mark, so the caller has provably lost the session.
  • Sequence — the epoch was accepted and the target is OLDER than the one already named. Distinct from Epoch because the caller's authority is not in question and retrying will not help: what it holds is stale.
  • NotFound — no pointer record of this kind exists at all. Distinct from Cleared for the reason above, and the distinction is what stops a clear from being confused with a session that has never checkpointed.

Conflict means a lost revision compare-and-swap and nothing else — re-read and retry — which is the meaning it has for every other record kind here.

const (
	PointerErrorInvalid   PointerErrorCode = "invalid"
	PointerErrorNotFound  PointerErrorCode = "not_found"
	PointerErrorCleared   PointerErrorCode = "cleared"
	PointerErrorEpoch     PointerErrorCode = "epoch"
	PointerErrorSequence  PointerErrorCode = "sequence"
	PointerErrorDeleted   PointerErrorCode = "deleted"
	PointerErrorIdentity  PointerErrorCode = "identity"
	PointerErrorConflict  PointerErrorCode = "conflict"
	PointerErrorUnknown   PointerErrorCode = "unknown"
	PointerErrorBackend   PointerErrorCode = "backend"
	PointerErrorMalformed PointerErrorCode = "malformed"
	PointerErrorVersion   PointerErrorCode = "version"
	PointerErrorTooLarge  PointerErrorCode = "too_large"
)

type PreparePublicCreateRequest added in v0.5.0

type PreparePublicCreateRequest struct {
	Identity                 PublicCreateIdentity
	ProposedRuntimeCommandID RuntimeCommandID
	AcceptedAt               time.Time
	ApplyDeadline            time.Time
	InitialWorkload          DesiredWorkload
}

PreparePublicCreateRequest reserves identity before payload bytes need exist. An identical retry returns the original mapping, times and initial workload.

type ProtocolMode added in v0.4.0

type ProtocolMode string

ProtocolMode identifies a session's immutable dispatch protocol. Record upgrades do not convert this mode; conversion requires an offline migration.

const (
	// ProtocolModeLegacy uses the released single-store epoch protocol.
	ProtocolModeLegacy ProtocolMode = "legacy"
	// ProtocolModeDisposition reserves the independent ownership/settlement
	// protocol. Its execution APIs are not implemented by this prerequisite.
	ProtocolModeDisposition ProtocolMode = "disposition"
)

type ProviderCloser

type ProviderCloser interface {
	Close(context.Context) error
}

ProviderCloser is the lifecycle boundary for a provider whose ownership is explicitly transferred to Store.

type PublicCreateIdentity added in v0.5.0

type PublicCreateIdentity struct {
	TenantID      sessionwire.TenantID  `json:"tenant_id"`
	SessionID     sessionwire.SessionID `json:"session_id"`
	CommandID     sessionwire.CommandID `json:"command_id"`
	Target        HostTargetKey         `json:"target"`
	Binding       SessionBinding        `json:"binding"`
	Kind          CommandKind           `json:"kind"`
	PayloadDigest string                `json:"payload_digest"`
	PayloadSize   uint64                `json:"payload_size"`
}

PublicCreateIdentity binds a public create command to one immutable launch. PayloadDigest is lowercase SHA-256 hex; size/digest identify content, not an upload generation. Kind remains opaque; the dedicated API marks public create.

type PublicCreatePreparation added in v0.5.0

type PublicCreatePreparation struct {
	Reservation PublicCreateReservation
	Catalog     CatalogEntry
}

PublicCreatePreparation proves matching reservation/catalog only. It is not an ACK, a terminal outcome, or execution authority. Upload oversized bytes with PutCommandPayload, then call AdmitPublicCreate. A crash may require resending bytes; there is no background recovery, body reconstruction or orphan reaping.

type PublicCreateReservation added in v0.5.0

type PublicCreateReservation struct {
	Identity         PublicCreateIdentity `json:"identity"`
	RuntimeCommandID RuntimeCommandID     `json:"runtime_command_id"`
	AcceptedAt       time.Time            `json:"accepted_at"`
	ApplyDeadline    time.Time            `json:"apply_deadline"`
	InitialWorkload  DesiredWorkload      `json:"initial_workload"`
}

PublicCreateReservation is a durable proposal winner, NOT an accepted command. It can outlive a lost catalog race or a missing payload. Runtime mapping, times and InitialWorkload are first-writer proposals, never retry comparisons.

type PublishHostTargetRequest

type PublishHostTargetRequest struct {
	Key HostTargetKey

	HostID         sessionwire.HostID
	HostGeneration uint64

	ObservedAt    time.Time
	Advertisement HostAdvertisement
}

PublishHostTargetRequest advertises one Host's current capacity for one target. It is also the HEARTBEAT: a Host republishes on its own cadence and this one operation moves the stored value, the rank, and the due time in a single compare-and-swap.

It carries a HostAdvertisement by VALUE, which is what makes "publish" and "drain" two different operations rather than one operation with a nil argument. A caller cannot accidentally remove its own capacity from every placement page by forgetting to set a member, and the withdrawn row has exactly two writers: DrainHostTarget and ReconcileHostTargets.

ObservedAt and the advertisement's ExpiresAt are the Host's own clock readings, as every other timestamp this package stores is. ExpiresAt is the promise the Host makes about its next heartbeat, so it must lie in the store's future and within MaxHostTargetTTL of it.

There is no expected revision. An advertisement is not a decision a caller makes about a row it has read — it is the current truth about one process's spare capacity — and the write is closed against the revision this store reads for itself. The generation is what establishes the right to write at all, and it establishes nothing else; see HostTarget.

type PutCommandPayloadRequest added in v0.5.0

type PutCommandPayloadRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
	SizeBytes uint64
	SHA256    [32]byte
	MediaType string
	Body      io.Reader
}

PutCommandPayloadRequest declares exact content for an orchestration inbox object. There is no caller-selectable kind: only command-payload is allowed.

type PutHostRegistrationRequest

type PutHostRegistrationRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID

	LeaseEpoch uint64

	ObservedAt time.Time
	ExpiresAt  time.Time

	Route HostRoute
}

PutHostRegistrationRequest publishes the current observed route to one session, creating the registration if no Host has ever registered it.

It carries a HostRoute by VALUE, which is what makes "publish" and "release" two different operations rather than one operation with a nil argument. A caller cannot accidentally erase a session's route by forgetting to set a member, and the tombstone has exactly one writer: ClearHostRegistration.

ObservedAt and ExpiresAt are the Host's own clock readings, as every other timestamp this package stores is. ExpiresAt is the promise the Host makes about its next heartbeat, so it must lie in the store's future and within MaxHostRegistrationTTL of it.

There is no expected revision. A registration is not a decision a caller makes about a record it has read — it is the current truth about where the session is running — and the write is closed against the revision this store reads for itself, exactly as UpdateCatalogHostState is. The epoch is what establishes the right to write at all.

type PutObjectRequest

type PutObjectRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
	Kind      ObjectKind
	SizeBytes uint64
	SHA256    [32]byte
	MediaType string
	Body      io.Reader
}

PutObjectRequest declares an immutable object's exact content properties. MediaType is optional, bounded, validated descriptive metadata; it is untrusted and does not participate in object identity.

SizeBytes is the exact byte length of Body, not a hint: a body that ends early or runs long is rejected. SessionStore imposes no ceiling on it, by design — the effective bound is whatever the storage provider accepts. Verification streams through a fixed buffer and nothing here allocates in proportion to SizeBytes, so a large declared size costs a rejected write, not memory.

type ReadGatesRequest

type ReadGatesRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
}

ReadGatesRequest reads one session's open public gates.

It has no cursor and no limit. The catalog holds at most MaxCatalogOpenGates gates for a session, so the whole answer is bounded by construction and is read from one record; a continuation would be a token that could never be issued. A future API that pages a larger open-gate set is what GatePage.OpenGateCount is reserved for.

type ReadPublicJournalRequest

type ReadPublicJournalRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
	FromSeq   uint64

	// Tail starts within the last Limit sequence positions at the tip captured
	// by this read. Limit zero uses the store's configured page size. Private
	// records occupy positions without returning events, so a tail may contain
	// fewer events than Limit. The byte budget can shorten the page further;
	// its continuation cursor remains pinned to the same captured tip.
	Tail bool

	// ScanLimit bounds examined journal records, including withheld private
	// records. Zero preserves the unbounded-by-records legacy scan; a positive
	// value must not exceed storage.MaxOrderedPageLimit. Exhaustion may return
	// an empty event page with advanced coverage and a continuation cursor.
	// Supply this budget again on each continuation; it is not part of the
	// cursor. Event and byte limits may stop earlier, deferring the next event
	// without counting it as covered. This bounds record work, not total bytes
	// fetched to resolve the public bodies of the examined records.
	ScanLimit int

	// Cursor is a token a previous page of THIS session issued. It is opaque:
	// retain it and hand it back, but do not parse it or derive position,
	// tenancy, or authority from it. Possessing one authorizes nothing — a
	// caller must authorize TenantID and SessionID on its own — and a cursor
	// this store did not issue for this session and this projection is refused
	// with JournalErrorCursor, which means the walk restarts rather than that
	// anything is wrong with the journal.
	Cursor sessionwire.Cursor

	Limit int
}

ReadPublicJournalRequest positions one bounded public journal page. FromSeq (inclusive, zero meaning the first record), Cursor and Tail are mutually exclusive. Continue a page with its returned Cursor and Tail false.

type ReadRuntimeJournalRequest

type ReadRuntimeJournalRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
	FromSeq   uint64

	// Cursor carries the same rules as ReadPublicJournalRequest.Cursor, and the
	// two are not interchangeable: a public token presented here, or a runtime
	// token presented to the public read, is refused.
	Cursor sessionwire.Cursor

	Limit int
}

ReadRuntimeJournalRequest positions one bounded privileged replay page. Its FromSeq and Cursor positioning rules match ReadPublicJournalRequest; runtime replay does not offer tail positioning.

type ReconcileError

type ReconcileError struct {
	Code      ReconcileErrorCode
	Field     string
	ExpiresAt time.Time
	Revision  uint64
	Cause     error
}

ReconcileError is a typed, redacted reconciliation claim failure. Field names the offending input or stage and never carries a provider name, a key, or a record payload.

Revision is populated only for ReconcileErrorConflict, carrying the value that is itself the answer, as the other record kinds do for that code.

ExpiresAt is populated only for ReconcileErrorHeld, and it is the whole reason the code is useful: a replica told only "someone else has it" can do nothing but poll, while one told when the claim lapses can wait exactly that long. It deliberately does NOT disclose the holder — knowing which replica is working is not a fact any decision here turns on.

func (*ReconcileError) Error

func (e *ReconcileError) Error() string

func (*ReconcileError) Unwrap

func (e *ReconcileError) Unwrap() error

type ReconcileErrorCode

type ReconcileErrorCode string

ReconcileErrorCode classifies a reconciliation claim failure.

It is its own vocabulary rather than the registry's or the catalog's, and for the reason HostTargetErrorCode is: a caller must not be able to write one handler for both. A RegistryError is the public account of who OWNS a session; a ReconcileError is the public account of who is currently doing scaling work for one. Sharing a type would let a caller branch on "held" without knowing which of those two questions it had asked, and the whole discipline of this record is that a claim is not ownership.

The codes that need their reasons stated:

  • Held — another holder's claim is LIVE. It is not a failure in the caller and says nothing about any session's lease; it means the work is already being done, so do it later or not at all. ExpiresAt carries the horizon.
  • Lapsed — no live claim is present. GetReconciliationClaim reports it against expires_at, meaning the stored claim has run out; a release reports it against holder_id, meaning the claim on this session is not the caller's and has run out, so there is nothing of the caller's to release. Both say the same thing about the world and differ in which of the caller's assumptions was wrong, which is why the field distinguishes them rather than a fourth code.
  • NotFound — no claim record exists at all. Distinct from Lapsed because a session nobody has ever reconciled and one whose reconciler crashed are different operational facts, and identical to a caller that only wants to know whether it may proceed.

Conflict means a lost revision compare-and-swap and nothing else — re-read and retry — which is the meaning it has for every other record kind here.

There is deliberately no epoch code and no epoch member. A caller cannot obtain a lease epoch from this vocabulary because there is no lease epoch in this record to obtain.

const (
	ReconcileErrorInvalid   ReconcileErrorCode = "invalid"
	ReconcileErrorNotFound  ReconcileErrorCode = "not_found"
	ReconcileErrorHeld      ReconcileErrorCode = "held"
	ReconcileErrorLapsed    ReconcileErrorCode = "lapsed"
	ReconcileErrorDeleted   ReconcileErrorCode = "deleted"
	ReconcileErrorIdentity  ReconcileErrorCode = "identity"
	ReconcileErrorConflict  ReconcileErrorCode = "conflict"
	ReconcileErrorUnknown   ReconcileErrorCode = "unknown"
	ReconcileErrorBackend   ReconcileErrorCode = "backend"
	ReconcileErrorMalformed ReconcileErrorCode = "malformed"
	ReconcileErrorVersion   ReconcileErrorCode = "version"
	ReconcileErrorTooLarge  ReconcileErrorCode = "too_large"
)

type ReconcileHostTargetsRequest

type ReconcileHostTargetsRequest struct {
	Limit    int
	MaxPages int

	// Cursor resumes a sweep that ran out of page budget. It is opaque: retain
	// it and hand it back, but do not parse it. Possessing one authorizes
	// nothing — this operation is service-owned and a caller must establish
	// that on its own — and a token this store did not issue for a sweep is
	// refused with HostTargetErrorCursor.
	//
	// Resuming is not an optimization; see DefaultHostTargetReconcilePages for
	// why a page budget alone leaves this sweep able to make no progress at
	// all.
	Cursor sessionwire.Cursor
}

ReconcileHostTargetsRequest bounds one service-owned sweep of the directory's deadline view. Zero means the default in Limit and MaxPages.

type ReconciliationClaim

type ReconciliationClaim struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID

	HolderID string

	ClaimedAt time.Time
	ExpiresAt time.Time
}

ReconciliationClaim is the durable record of which Factory replica is currently doing reconciliation work for one session, and until when.

HolderID names the replica, opaquely. It is the only thing a later write is compared against, and it is not authority: two replicas that chose the same holder string are indistinguishable here, which costs exactly the duplicate work this record exists to reduce and costs nothing else.

ClaimedAt is the store's own clock reading at the moment the claim was accepted; ExpiresAt is the holder's promise about when it will be finished. The two are stamped from different clocks on purpose. A caller-supplied claim instant could be placed after its own expiry, or before a takeover that has already happened, and nothing could tell; the horizon must be the caller's because only the caller knows how long its work takes, and it is bounded above for that reason.

WHICH CLOCK, AND WHAT THAT DOES NOT BUY. Taking the claim instant from the store rather than the request removes a degree of freedom from the REQUEST; it does not make the instant authoritative. The clock is caller-injected and unchecked (see WithClock), and a Factory replica embeds its own Store, so s.clock is that replica's clock one level down. Liveness is therefore evaluated against the READING replica's clock and is skew-relative in both directions: a slow replica sees another's claim live longer than its holder meant, and a fast one takes it over early. There is no shared time here and none is available.

That is affordable for the reason this file's header gives and for no other: the claim licenses nothing, so the worst either direction produces is duplicated or delayed work that was already safe to do concurrently. It would NOT be affordable for a record whose expiry decided who may write, which is why the Host lease is a lease and this is not one.

A claim whose expiry EQUALS its claim instant has lapsed on arrival, which is exactly what ReleaseReconciliationClaim writes. That is not a second state needing a marker of its own: "released" and "expired" are the same fact to every reader — no live claim — and giving them one spelling means no reader has to know which one it is looking at.

type ReconciliationClaimEntry

type ReconciliationClaimEntry struct {
	Claim    ReconciliationClaim
	Revision uint64
}

ReconciliationClaimEntry is a claim together with the revision a later compare-and-swap names.

The provider's immutable acceptance order is deliberately not exposed, for the reason HostRegistrationEntry's is not: a session's position in a stream of claims is not a fact any caller acts on.

type RegistryError

type RegistryError struct {
	Code     RegistryErrorCode
	Field    string
	Epoch    uint64
	Revision uint64
	Cause    error
}

RegistryError is a typed, redacted Host registration failure. Field names the offending input or stage and never carries a provider name, a key, or a record payload.

Revision is populated only for RegistryErrorConflict, carrying the value that is itself the answer, as CatalogError and InboxError do for that code.

Epoch is populated more widely than the catalog's and the inbox's, and the extra two codes are the point rather than an inconsistency. For RegistryErrorEpoch it is the high-water mark that refused the write, as it is there. For RegistryErrorExpired and RegistryErrorReleased it is the fence the retained record still carries — because those two codes are the whole public account of a session that has no route, they are what a retention sweep acts on, and this package offers no other way to observe a registration's epoch. Without it a caller's only route to the record's most consequential permanent state would be to attempt a write it expects to fail and read the refusal. RegistryErrorNotFound carries no epoch: there is no record and so no fence, and a zero there means exactly that.

func (*RegistryError) Error

func (e *RegistryError) Error() string

func (*RegistryError) Unwrap

func (e *RegistryError) Unwrap() error

type RegistryErrorCode

type RegistryErrorCode string

RegistryErrorCode classifies a Host registration failure.

It is the registry's own vocabulary rather than the catalog's, and the reason is the one InboxErrorCode gives. Gates reuse CatalogError because a gate IS catalog state. A registration is a separate aggregate: its own namespace, its own record, its own identity, its own fencing high-water mark, and — unlike every other record in this package — its own LIFETIME, because it expires while nothing else here does. Its writes never read or write a session's catalog record, and a caller branching on "there is no live route" should not have to match the type that reports "there is no such session".

The three ways a registration can fail to be a route are deliberately separate codes, and none of them is an error in the caller:

  • NotFound — no registration has ever been written for this session. No Host has ever held it, or none has ever reported holding it.
  • Expired — a registration exists and names a Host, but its expiry has passed. The Host may be alive and merely slow to heartbeat, or it may be gone; this record cannot tell the difference and neither may its reader.
  • Released — a registration exists and is the tombstone a graceful shutdown left behind. The Host that held the session let it go on purpose.

A ROUTER MUST TREAT ALL THREE ALIKE: none of them is a route, and the difference between them is diagnostic. They are separate rather than collapsed into NotFound because of what an undifferentiated "absent" invites. A future writer that reads a registration, sees "not found", and creates a fresh record has just dropped the fencing high-water mark of whatever was really there — which is precisely the write the epoch fence exists to refuse. Nothing in this package reaches a write path through a reader that reports these codes, and the codes being distinct is what makes a future one that tries to look wrong rather than plausible.

The rest name the same failures the catalog's and the inbox's codes do:

  • Invalid — a caller mistake in the request or a stored record that no longer satisfies its own rules.
  • Deleted — the provider holds a TOMBSTONE for this identity. This package never deletes a registration, so it means the fencing high-water mark has been physically destroyed by something outside it; it is reported and never worked around, because the alternative is admitting a write from a lease that has already lost the session.
  • Identity — a stored record disagreed with the identity it was filed under or asked for. It is not a caller error and no retry fixes it.
  • Epoch — the caller named a lease epoch BELOW the record's committed high-water mark. That lease has provably been superseded and must not retry under the same epoch; Epoch carries the high-water mark, as CatalogErrorEpoch does.
  • Conflict — a compare-and-swap lost a race on the record's revision, with no statement about ownership. Re-read and retry. Revision carries what the record is at now when the store could see it.
  • Unknown — the mutation's outcome could not be resolved at all.
const (
	RegistryErrorInvalid   RegistryErrorCode = "invalid"
	RegistryErrorNotFound  RegistryErrorCode = "not_found"
	RegistryErrorExpired   RegistryErrorCode = "expired"
	RegistryErrorReleased  RegistryErrorCode = "released"
	RegistryErrorDeleted   RegistryErrorCode = "deleted"
	RegistryErrorIdentity  RegistryErrorCode = "identity"
	RegistryErrorEpoch     RegistryErrorCode = "epoch"
	RegistryErrorConflict  RegistryErrorCode = "conflict"
	RegistryErrorUnknown   RegistryErrorCode = "unknown"
	RegistryErrorBackend   RegistryErrorCode = "backend"
	RegistryErrorMalformed RegistryErrorCode = "malformed"
	RegistryErrorVersion   RegistryErrorCode = "version"
	RegistryErrorTooLarge  RegistryErrorCode = "too_large"
)

type RejectCommandRequest

type RejectCommandRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
	CommandID sessionwire.CommandID

	ExpectedRevision uint64
	LeaseEpoch       uint64
	Rejection        sessionwire.ErrorDetail
}

RejectCommandRequest records the terminal typed rejection of a command.

LeaseEpoch is OPTIONAL here and required everywhere else in this file, and the asymmetry is the deadline reconciler's. Claiming, applying, and completing are things a session's lease holder does; rejecting is also what a Factory replica does to a command that has run out of deadline, and such a replica may be reconciling a session that has never had a lease at all. A zero epoch is therefore the honest statement "I am not acting under a session lease", and it buys exactly the authority the state machine grants that caller: it may settle a command that nobody is working on, and nothing else.

A NONZERO epoch here is a consistency check on a view the caller asserts, not an authority boundary. Nothing forces a caller to name one — a superseded Host obtains the reconciler's authority simply by passing zero — so the fence cannot be what keeps a stale lease out. What keeps it out is the claim rule below it, which is a property of the RECORD and applies identically at every epoch: a live claim admits only its own, and an applying record admits nobody. The fence's job is narrower and still worth doing: a caller that volunteers an epoch below the record's high-water mark is telling the store its view of the session is stale, and is told so rather than acting on it.

Rejection is a value rather than a pointer because a rejection without a reason is not a state this record has. It is validated as a public projection, so it carries a stable typed cause rather than a provider or runtime message.

type RejectDispositionCommandRequest added in v0.8.0

type RejectDispositionCommandRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
	CommandID sessionwire.CommandID

	ExpectedRevision uint64
	ResidencyEpoch   ResidencyEpoch
}

RejectDispositionCommandRequest refuses one command BEFORE any dispatch of it was durably authorized.

ExpectedRevision means what it means everywhere else here. ResidencyEpoch is OPTIONAL: see this file's header for why, and for what a zero buys.

There is no rejection reason, and its absence is a stated cost rather than an oversight — see the header's third divergence.

type ReleaseReconciliationClaimRequest

type ReleaseReconciliationClaimRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID

	HolderID string
}

ReleaseReconciliationClaimRequest gives one session's claim back early.

It carries no timestamp, and that is deliberate rather than an omission, for the reason ClearHostRegistrationRequest carries none: a released claim's instants record that THIS STORE released it, and a caller-supplied one could place the release in the future, producing a record that reads as released by intent and as live by time.

type RemnantGateIntent

type RemnantGateIntent struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
	GateID    sessionwire.GateID
	Revision  uint64
}

RemnantGateIntent is one deadline intent whose gate the session's durable record does not project as open, together with the revision a retirement names.

It is reported rather than acted on, and rather than merely dropped, because this reader cannot decide the question a retirement has to answer: whether the open that wrote the intent crashed or is still in flight. Only elapsed time can, and the service that sweeps is the one holding the clock the retirement is evaluated against. See RetireGateDeadlineIntent.

The revision travels with it so the retirement is a compare-and-swap onto the row this page actually saw. Without it a sweeper would have to re-read, and a re-read is a second decision point at which a gate could have been reopened.

IT IS DELIBERATELY NOT RetireGateDeadlineIntentRequest, though the two spell the same four members today and staticcheck reports the conversion (S1016). One is a SWEEP RESULT and the other an OPERATION REQUEST, and collapsing them would make the request hostage to whatever a future page decides to report: a member added here for a caller's convenience would silently become a member the operation accepts. The conversion is used once, in the test that drives the whole sweeper path, which is the right place for it — it holds the two shapes together where they are meant to line up, and stops compiling if they ever diverge, which is the loud failure rather than a silent one.

type ResidencyAcquireCleanupError added in v0.4.0

type ResidencyAcquireCleanupError struct {
	// contains filtered or unexported fields
}

ResidencyAcquireCleanupError means acquisition was canceled after a provider grant arrived and its release also failed. No ownership grant is returned. Use errors.As to retain this cleanup obligation and retry Release; until a release succeeds the Store admission remains held and Close may time out. Unwrap exposes both the acquisition refusal and provider cleanup failure.

func (*ResidencyAcquireCleanupError) Error added in v0.4.0

func (*ResidencyAcquireCleanupError) Release added in v0.4.0

Release retries cleanup only; this error exposes no ownership capability.

func (*ResidencyAcquireCleanupError) Unwrap added in v0.4.0

func (e *ResidencyAcquireCleanupError) Unwrap() error

type ResidencyEpoch added in v0.4.0

type ResidencyEpoch uint64

ResidencyEpoch identifies a Host residency grant. It is meaningful only in that session's residency namespace and must never be compared with, or used as, a journal epoch. Residency alone authorizes no journal writes or command application; the disposition protocol is not activated by this API.

type ResidencyError added in v0.4.0

type ResidencyError struct {
	Operation string
	Cause     error
}

ResidencyError is a provider failure acquiring or releasing residency. Cause preserves provider errors, including storage.LeaseHeldError for contention. Catalog, scope and Store lifecycle refusals retain their existing typed errors.

func (*ResidencyError) Error added in v0.4.0

func (e *ResidencyError) Error() string

func (*ResidencyError) Unwrap added in v0.4.0

func (e *ResidencyError) Unwrap() error

type ResidencyGrant added in v0.4.0

type ResidencyGrant struct {
	// contains filtered or unexported fields
}

ResidencyGrant owns only an orchestration residency lease. Epoch and Lost come directly from Storage; Lost is the provider's actual notification, never inferred from journal events, release errors or caller cancellation.

Liveness is provider-dependent. In particular memstore provides neither TTL nor crash takeover. Residency loss does not fence a journal: a successor must independently acquire the journal grant before application.

The grant is safe for concurrent use and holds a Store admission until a successful Release. Store shutdown attempts bounded cleanup once; failed cleanup retains the admission for an explicit retry. Providers must honor Release context cancellation. There is no background retry loop.

func (*ResidencyGrant) Epoch added in v0.4.0

func (g *ResidencyGrant) Epoch() ResidencyEpoch

Epoch returns the provider's epoch in the residency domain.

func (*ResidencyGrant) Lost added in v0.4.0

func (g *ResidencyGrant) Lost() <-chan struct{}

Lost returns the provider's loss signal, including successful release.

func (*ResidencyGrant) Release added in v0.4.0

func (g *ResidencyGrant) Release(ctx context.Context) error

Release returns this specific grant to the provider. Success is idempotent; failure remains retryable and does not pretend cleanup completed or free the Store admission. Each provider attempt is bounded by the caller context and the Store shutdown timeout. It cannot release a successor's grant.

type ResolveGateRequest

type ResolveGateRequest struct {
	TenantID   sessionwire.TenantID
	SessionID  sessionwire.SessionID
	LeaseEpoch uint64
	GateID     sessionwire.GateID
}

ResolveGateRequest retires one open gate. It records only that the gate is no longer open and awaiting an answer: it carries no response, decides nothing about what the session does next, and starts no continuation.

It is idempotent, and it must be: a resolve interrupted between clearing the projection and retiring the intent is completed by repeating it.

type RetireGateDeadlineIntentRequest

type RetireGateDeadlineIntentRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
	GateID    sessionwire.GateID
	Revision  uint64
}

RetireGateDeadlineIntentRequest tombstones one gate's deadline intent.

Revision is the revision the caller observed on the row, in a RemnantGateIntent from ListDueGates. The write is a compare-and-swap onto it, so a row that moved between the page and this call is refused rather than retired on stale evidence.

It is the counterpart of RemnantGateIntent, which is what a sweep reports and what a caller builds this from; the two are deliberately separate types for the reason stated there.

It names a tenant and a session because it must: the intent is filed under the session's scope and there is no way to reach it without them. So this operation cannot carry the structural hint the sweeps carry — a request that names no tenant — and its service-only status is prose alone. What protects it is that there is nothing a tenant could aim it at productively: it refuses any gate the session's own durable record still projects as open.

type RuntimeCommandID

type RuntimeCommandID string

RuntimeCommandID is the runtime-facing identity a Host forwards into the Harness API. It is a named type rather than a bare string because it travels beside the public CommandID in almost every signature, and two adjacent strings of one type are silently swappable.

This package does not impose a UUID grammar on it. Harness allocates UUIDs today, but the durable MAPPING is what this record exists to make authoritative; a grammar check here would be a second statement of a rule this package does not own, and it would refuse a future runtime identity without adding any safety. It is validated as a bounded opaque UTF-8 value, exactly as the catalog validates the identities it does not own.

type RuntimePage

type RuntimePage struct {
	Records        []RuntimeRecord
	CapturedTip    uint64
	CoveredThrough uint64
	NextCursor     sessionwire.Cursor
}

RuntimePage is a bounded privileged replay page captured at CapturedTip.

type RuntimeRecord

type RuntimeRecord struct {
	Seq      uint64
	Envelope Envelope
}

RuntimeRecord is one raw journal record as it is stored. Object-backed bodies stay unresolved: the caller decides which of them to fetch, through the ordinary object API, so a replay never pays for bytes it does not want.

type SaveDispositionCommandCursorRequest added in v0.7.0

type SaveDispositionCommandCursorRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID

	LeaseEpoch    uint64
	ConsumedOrder uint64
}

SaveDispositionCommandCursorRequest records one session's consumption cursor.

LeaseEpoch is the consumer's residency epoch and is compared against the record's committed high-water mark. ConsumedOrder is the acceptance order the consumer is done through and is compared against the record's committed order; both are high-water marks and neither ever falls.

There is no expected revision and no timestamp, for the reason SetSessionPointerRequest carries neither: a cursor is not a decision a caller makes about a record it has read — it is the current truth about how far a consumer has got — so the write is closed against the revision this store reads for itself.

type SessionBinding added in v0.4.0

type SessionBinding struct {
	StorageBindingID string       `json:"storage_binding_id"`
	BindingVersion   string       `json:"binding_version"`
	RuntimeSessionID string       `json:"runtime_session_id"`
	ProtocolMode     ProtocolMode `json:"protocol_mode"`
}

SessionBinding pins the storage configuration and runtime identity chosen at creation. IDs are bounded opaque configuration names, not provider keys or credentials. BindingVersion identifies immutable configuration, never the current agent default. The all-zero value denotes an unbound legacy record. A nonzero binding must contain all four members.

type SessionDispositionCommandPage added in v0.7.0

type SessionDispositionCommandPage struct {
	Commands       []DispositionInboxEntry
	Limit          int
	NextAfterOrder uint64
}

SessionDispositionCommandPage is one bounded ascending page of one session's disposition inbox.

Commands are in STRICTLY increasing AcceptedOrder, every one of them strictly above the request's bound. They are the SAME DispositionInboxEntry values a named read of each command returns, held to the same filing checks and to the same catalog binding — there is no weaker sweep-shaped variant, because a consumer acts on these rows and every write it then makes is a compare-and-swap against the revision reported here.

LIMIT is the EFFECTIVE limit after a zero request limit has been resolved to the store's page size, so a caller that named no limit can still tell a full page from a short one.

NEXTAFTERORDER is the last row's order, and is zero exactly when the page is empty. An empty page means the stream is exhausted at this bound: unlike a due page, nothing here is skipped, so there is no "empty but not finished" state to distinguish. A caller that receives one keeps the bound it asked with.

There is no Unreadable count, and its absence is the contract rather than an omission — see ListSessionDispositionCommands.

type SessionPage

type SessionPage struct {
	sessionwire.SessionPage

	UnreadableSkipped int
}

SessionPage is one bounded page of a tenant's sessions together with what producing it cost, as DueGatePage is for the deadline view.

It embeds Core's page rather than replacing it, so a caller still reads Sessions and NextCursor directly and can hand the embedded value to anything that takes a sessionwire.SessionPage.

UnreadableSkipped counts rows this reader could not hold to their own identity and therefore did not publish. It is not a diagnostic afterthought: it is what makes skipping such a row safe to do at all, because it leaves the caller able to tell "this tenant has three sessions" from "this tenant has three sessions and one row I could not vouch for". A nonzero count is durable — nothing in this package rewrites such a row — so it means a build that understands the row is needed, not that a retry will help.

type SessionPointer

type SessionPointer struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID

	Kind SessionPointerKind

	LeaseEpoch uint64
	Sequence   uint64

	UpdatedAt time.Time

	// Target is nil exactly when this pointer has been cleared.
	Target *sessionwire.ObjectReference
}

SessionPointer is the durable record of which immutable object currently fills one role for one session, and of the two high-water marks that decide whether a later write may replace it.

The two are different questions and both are permanent:

  • LeaseEpoch answers MAY YOU WRITE. It is the epoch of the grant that last wrote this record, and a write naming a strictly lower one has provably lost the session. An equal one is admitted because one grant writes many times. It never falls, which is why this record is never deleted.
  • Sequence answers IS THIS NEWER. It is the journal position the target was captured at, and a write naming a strictly lower one would republish an object that a later capture has already superseded. It never falls either, and it is the one thing the epoch cannot supply: two writes under ONE grant are ordered only by their revision compare-and-swap, so a losing writer that retried would otherwise reinstate its older checkpoint over the newer one and every restore afterwards would silently lose the work in between. The catalog refuses a regressing LastJournalSeq for exactly this reason.

A nil Target is the cleared tombstone. It is one nil rather than an enumeration of zero values, so a LIVE TOMBSTONE IS UNREPRESENTABLE rather than excluded by a check somebody has to remember, and a cleared pointer carries both high-waters and nothing else.

UpdatedAt is the STORE's clock, not a caller's, for the reason ClearHostRegistrationRequest carries no timestamp: nothing in this record has an expiry, so no decision turns on this instant, and a caller-supplied one could only be wrong. It is the record's audit line and the summary's capture instant.

It is read when the request is VALIDATED, before this store issues any provider call — not when the write was accepted. A set reads it before admission, the witness binding, the read and the compare-and-swap; a clear reads it after its read and before its one swap, so the two paths do not even stamp from the same point in their own sequences. That is precisely because nothing decides on it: validating before any provider work is the property worth having, and paying a round trip to make an audit line a few milliseconds truer is not.

The accumulation is one small permanent row per role per session that has ever had one: never listed, never ranked, never due, and never read except by name. The registry's carry-forward contract about retention applies here word for word — the only safe reaper is one that removes the session's whole scope at once, because deleting this row alone destroys a fence while leaving the session writable.

func (SessionPointer) CheckpointSummary

func (p SessionPointer) CheckpointSummary() (CheckpointSummary, error)

CheckpointSummary projects a workspace checkpoint pointer into the catalog's summary shape, and it is the ONLY way to build one from durable state.

This is where "the catalog holds a summary while the pointer holds the truth" stops being prose. A Host that publishes a projection reads this pointer and projects it; it does not compose a second answer from whatever it happens to remember, and UpdateCatalogHostState's replace-everything semantics are therefore harmless — what it replaces is a copy.

A CLEARED pointer projects to the ZERO summary with no error, because the zero summary is precisely what CheckpointSummary documents as "no checkpoint has been committed". That is the whole propagation path for a clear: the authoritative record says there is none, and the copy the catalog carries says the same thing on the next projection write.

It refuses any other KIND. The catalog validates a summary's reference as an opaque ObjectID and no more — it cannot tell a workspace checkpoint from a runtime one — so a runtime-checkpoint pointer projected into the catalog's workspace-checkpoint summary would put an object no restore can use where one it can use belongs, and nothing downstream would notice.

CapturedAt is this record's UpdatedAt, which is neither the instant the Host finished writing the object nor the instant the pointer was committed: it is the store's clock as the pointer request was validated, which SessionPointer states exactly. It therefore sits somewhere between the capture and the commit. It is the only instant this record has, and it is a rendering field: no decision in this package or in the catalog turns on it.

type SessionPointerEntry

type SessionPointerEntry struct {
	Pointer  SessionPointer
	Revision uint64
}

SessionPointerEntry is a pointer together with the revision a later compare-and-swap names.

The provider's immutable acceptance order is deliberately not exposed, for the reason HostRegistrationEntry's is not: a session's position in a stream of pointer writes is not a fact any caller acts on.

type SessionPointerKind

type SessionPointerKind string

SessionPointerKind is the closed set of roles a session's current object can fill. It is the vocabulary the generic kernel below is parameterized by, and each member has exactly one public triple of methods.

ActiveContinuation is declared here and stored here and is otherwise RESERVED for the gate suspension/resume plan. This file stores a NAME; it does not define what a continuation contains, does not resume one, and no operation in this package reads one — which TestNothingElseInThisPackageReadsAPointer keeps true.

const (
	SessionPointerActiveContinuation  SessionPointerKind = "active-continuation"
	SessionPointerWorkspaceCheckpoint SessionPointerKind = "workspace-checkpoint"
	SessionPointerRuntimeCheckpoint   SessionPointerKind = "runtime-checkpoint"
)

type SetSessionPointerRequest

type SetSessionPointerRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID

	LeaseEpoch uint64
	Sequence   uint64

	Target sessionwire.ObjectReference
}

SetSessionPointerRequest names the object one role should now point at.

It carries a Target by VALUE, which is what makes "set" and "clear" two different operations rather than one operation with a nil argument: a caller cannot erase a session's checkpoint by forgetting to set a member, and the tombstone has exactly one writer.

It carries no KIND. The kind is spelled by the METHOD, so there is no way to name a role that has no method and no way to hand the wrong role a reference that the method's own object-kind rule would then have to catch by luck.

LeaseEpoch is the grant's epoch and is compared against the record's committed high-water mark. Sequence is the journal position the target was captured at and is compared against the record's committed sequence; both are high-water marks and neither ever falls.

There is no expected revision and no timestamp. A pointer is not a decision a caller makes about a record it has read — it is the current truth about which object fills a role — so the write is closed against the revision this store reads for itself, exactly as UpdateCatalogHostState and PutHostRegistration are, and the instant is the store's for the reason SessionPointer states.

type SettleDispositionCommandRequest added in v0.6.0

type SettleDispositionCommandRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
	CommandID sessionwire.CommandID

	ExpectedRevision uint64
	ResidencyEpoch   ResidencyEpoch
}

SettleDispositionCommandRequest asks the store to settle one command from durable evidence.

It names a command, the revision the caller decided on, and the residency the claimant holds — and nothing else. It CANNOT supply an outcome, an absence, or any proof structure: the store derives what it expects from its own record and obtains the evidence itself, which is the whole of why a settlement cannot be talked into a state the journal does not support.

ResidencyEpoch is settlement context, recorded in the outcome. It is not compared with any journal epoch, and it does not authorize the settlement — the evidence does. It may be a successor's, which is what lets the original holder AND a successor settle the same command by the same route.

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store is the durable session aggregate over one complete storage backend.

func Open

func Open(ctx context.Context, backend *storage.Composite, opts ...Option) (*Store, error)

Open constructs a Store over a complete storage composite whose Blobs primitive provides bounded reader shutdown. Before provider I/O, publishing a Store, or starting its lifecycle, it validates that capability; it then atomically establishes the immutable backend keyspace marker.

Example

ExampleOpen is the compiled twin of the composition example in README.md. It exists so that snippet cannot go stale: it is the whole shape of a composition root — pick a provider, hand its complete storage.Composite to Open, and close the Store — and if the signature or the flow changes, this stops compiling.

memstore is used because it is the in-process oracle Storage ships and it satisfies Open's bounded Blob reader lifecycle requirement. A product picks a durable provider here instead; nothing else in this function changes.

package main

import (
	"context"
	"fmt"
	"log"
	"time"

	sessionwire "github.com/looprig/core/sessionwire/v1"
	"github.com/looprig/sessionstore"
	"github.com/looprig/storage/memstore"
)

func main() {
	ctx := context.Background()

	store, err := sessionstore.Open(ctx, memstore.New())
	if err != nil {
		log.Fatal(err)
	}
	defer func() {
		if err := store.Close(ctx); err != nil {
			log.Fatal(err)
		}
	}()

	createdAt := time.Date(2026, 8, 30, 10, 0, 0, 0, time.UTC)
	if _, created, err := store.CreateCatalogEntry(ctx, sessionstore.CreateCatalogEntryRequest{
		TenantID:               "tenant-a",
		SessionID:              "session-a",
		AgentID:                "agent-a",
		RuntimeCompatibilityID: "runtime-v1",
		CreatedAt:              createdAt,
		LastActiveAt:           createdAt,
		State:                  sessionwire.SessionStateIdle,
		Residency:              sessionwire.SessionResidencyCold,
		DesiredPlacement:       sessionwire.HostPlacementPooled,
		IdempotencyKey:         "create-1",
	}); err != nil {
		log.Fatal(err)
	} else {
		fmt.Println("created:", created)
	}

	page, err := store.ListSessions(ctx, sessionstore.ListSessionsRequest{TenantID: "tenant-a", Limit: 10})
	if err != nil {
		log.Fatal(err)
	}
	for _, summary := range page.Sessions {
		fmt.Println("session:", summary.SessionID, summary.State)
	}
	fmt.Println("unreadable skipped:", page.UnreadableSkipped)

}
Output:
created: true
session: session-a idle
unreadable skipped: 0

func (*Store) AcquireReconciliationClaim

func (s *Store) AcquireReconciliationClaim(
	ctx context.Context,
	req AcquireReconciliationClaimRequest,
) (ReconciliationClaimEntry, error)

AcquireReconciliationClaim takes the session's claim, or extends the caller's own.

Three cases, and the middle one is the whole operation:

  • No record: the claim is created. A create that finds the identity already there is a lost race, reported as a conflict so the caller re-reads and meets the live claim on the ordinary path.
  • A live claim held by SOMEONE ELSE: refused with the horizon, and NOTHING IS WRITTEN. A losing replica that rewrote the row would restamp the winner's claim under its own name, which is the one way this record could take work away from the replica actually doing it.
  • Anything else — a lapsed claim, or the caller's own claim, live or not: taken, in one compare-and-swap. Extending one's own live claim and taking over a crashed replica's lapsed one are the same write, because the record does not distinguish them and nothing downstream needs to.

The clock is read ONCE, before the provider read, and both the bound and the stored claim instant come from that reading. A second reading taken after the read could be later than the instant the record was evaluated at, which would start the machine erring toward taking claims rather than leaving them.

func (*Store) AcquireResidency added in v0.4.0

func (s *Store) AcquireResidency(ctx context.Context, req AcquireResidencyRequest) (*ResidencyGrant, error)

AcquireResidency validates the actual immutable catalog binding and acquires a distinct residency lease. It never opens, reads or appends a journal. A protocol-only witness without a catalog winner is insufficient authority. The caller context bounds acquisition only; the returned grant lives until Release or Store shutdown. An error always returns a nil grant; see ResidencyAcquireCleanupError for a failed rollback's retry obligation.

func (*Store) AdmitCommand

func (s *Store) AdmitCommand(ctx context.Context, req AdmitCommandRequest) (InboxEntry, bool, error)

AdmitCommand makes one command durable and reports whether this call is the one that accepted it.

It is exactly one CreateOrdered call. The provider's Create is atomically idempotent by identity, so the duplicate case needs no read of its own: a duplicate arrives as the winner's canonical stored record with created false, carrying the winning runtime mapping and the immutable acceptance order.

What makes a duplicate a MISMATCH rather than a retry, and why:

  • Kind, Payload, and PayloadRef must match. Reusing one command id for a DIFFERENT command must fail closed; silently returning the first command's record would tell the caller its command was accepted when nothing of the kind happened.
  • The proposed RuntimeCommandID is deliberately excluded. Disagreeing about it is the normal, expected outcome of a race, and the winner's value is the answer.
  • AcceptedAt and ApplyDeadline are deliberately excluded. A retry carries a fresh clock reading — a caller that computes an absolute deadline from "now" produces a different one on every attempt — so comparing them would turn every real retry into a mismatch.
  • State, Claim, Result, and Rejection are deliberately excluded. By the time a retry arrives the command may already be claimed, applied, or rejected; that progress is not evidence that this retry differs, and a retry of a completed command must still receive its mapping.

func (*Store) AdmitDispositionCommand added in v0.5.0

func (s *Store) AdmitDispositionCommand(ctx context.Context, req AdmitDispositionCommandRequest) (DispositionInboxEntry, bool, error)

AdmitDispositionCommand performs one create-only ordered write after bounded authority checks. Unknown write outcomes return InboxErrorUnknown; retry the same identity to learn the durable winner. No error licenses dispatch. Object metadata is checked against one exact immutable index row, without reading its body. That index does not prove current existence after external deletion; GetObject must still verify the full stream when consuming it.

func (*Store) AdmitPublicCreate added in v0.5.0

func (s *Store) AdmitPublicCreate(ctx context.Context, req AdmitPublicCreateRequest) (DispositionInboxEntry, bool, error)

AdmitPublicCreate acknowledges only matching reservation, immutable catalog create identity and marked disposition inbox winner. It does not prepare missing catalogs or reconstruct missing payload bytes.

func (*Store) BeginApplyingCommand

func (s *Store) BeginApplyingCommand(ctx context.Context, req BeginApplyingCommandRequest) (InboxEntry, error)

BeginApplyingCommand moves a claimed command into applying, which is the statement that application is starting now rather than that capacity has been reserved. Only the holder of a live claim may make it: an epoch that is not the claim's has not claimed this command, and an expired claim is no longer a claim, so both are told the claim is lost and may claim again if the deadline still allows one.

There is no apply-deadline check, and its absence is the deadline race the spec settles in the claimer's favour: a writer holding a live claim may begin applying even past the deadline, which is precisely what stops a reconciler's clock from cancelling work that is about to commit.

func (*Store) BeginDispositionAttempt added in v0.6.0

func (s *Store) BeginDispositionAttempt(ctx context.Context, req BeginDispositionAttemptRequest) (DispositionInboxEntry, error)

BeginDispositionAttempt moves a claimed disposition command into applying and records the complete attempt.

Its refusals are ordered so that a caller meeting two at once is told the one that stays true: a settled command has no transitions left; a record that is not claimed has no attempt edge; a residency below the claim's is superseded permanently; a residency that is not the claim's has not claimed this command; and a lapsed claim is no claim at all.

There is no apply-deadline check and no new expiry, both deliberately. A writer holding a live claim may begin applying past the deadline — that is the deadline race settled in the dispatcher's favour — and an applying record is closed by EVIDENCE rather than by a timer, so inventing an expiry here would create a clock-shaped route to a conclusion the journal has not supported. A command whose evidence is unavailable stays observably unresolved instead.

func (*Store) ClaimCommand

func (s *Store) ClaimCommand(ctx context.Context, req ClaimCommandRequest) (InboxEntry, error)

ClaimCommand takes a short-lived claim on a command so one writer works on it at a time.

The order of its refusals is the order in which the answers become permanent, so a caller meeting two of them at once is told the one that will still be true after it retries:

  1. A terminal command is settled. Nothing about it can be claimed, and the answer is to read its outcome.
  2. An applying command is not claimable at any epoch. See this file's header: resuming one is continuation, not a claim.
  3. A superseded epoch can never succeed again under that epoch.
  4. The apply deadline has passed, so no NEW claim may start — permanently, for this command, for every caller. It is deliberately checked before the claim is examined, because "you are too late" stays true when the live claim that would otherwise be reported expires.
  5. A live claim at this epoch belongs to someone else and will expire.

A claim taken over an EXPIRED claim is an ordinary claim, not a special reclaim: the record's members carry the new claim exactly as the first one did, and the reclaim horizon a reader derives from them follows. There is no second due state to file and no operation-shaped due state anywhere in this file — see inboxDue, which is the only definition there is.

A CLAIM CANNOT BE RENEWED, and a caller that needs more time has exactly one move: enter applying before its claim lapses. Re-claiming under the same epoch is refused for as long as the claim is live (that is the equal-epoch rule) and admitted only once it has lapsed — by which time any other writer at that epoch or above may take it, and the deadline may have closed new claims entirely. Renewal is deliberately absent rather than forgotten: it would let one writer hold a command indefinitely, and the state that legitimately spans a long application is applying, which the deadline cannot cancel.

func (*Store) ClaimDispositionCommand added in v0.8.0

func (s *Store) ClaimDispositionCommand(ctx context.Context, req ClaimDispositionCommandRequest) (DispositionInboxEntry, bool, error)

ClaimDispositionCommand moves a pending disposition command into claimed under the caller's residency, and is the entry point the attempt edge's State == InboxStateClaimed precondition was written against. It does not loosen that precondition: the record it writes carries a claim whose residency is the caller's own, which is exactly what BeginDispositionAttempt's fence then requires.

The second result reports whether THIS CALL wrote the claim. False with a nil error is the idempotent replay described below.

The order of its refusals

It is the order in which the answers become permanent, so a caller meeting two at once is told the one that will still be true after a retry:

  1. A residency that is not a live-looking grant this store issued for this session is refused before anything is read — see the request type.
  2. A settled command has no transitions left.
  3. A command with a durably authorized ATTEMPT is not claimable at any residency. Applying is a fortress in this protocol as in the legacy one, and this single check is what says so: among non-terminal records an attempt exists exactly when the state is applying, so a second state comparison beside it would be an equivalent restatement rather than a second guard.
  4. A residency STRICTLY BELOW the record's high-water mark — its claim's residency, or zero when it has no claim — has been superseded permanently and is told so rather than sent to retry.
  5. An EXACT REPLAY of a live claim is the caller's own durable claim and is returned as it stands, with no write.
  6. The apply deadline has passed, so no NEW claim may start — permanently, for this command, for every caller.
  7. A LIVE claim at the caller's own residency naming a DIFFERENT expiry is a renewal, and is refused.

Four and five are in that order deliberately: a replay is not a new claim, and the claim it replays is already durable, so the deadline has nothing left to prevent. Three and four are interchangeable and are written in this order to keep the fence first, as settlement's is: the fence cannot refuse a replay, because a replay names the stored claim's own residency.

Idempotency, stated exactly

A replay is a RE-ISSUE OF THE SAME REQUEST — same residency, same expiry — against a record whose claim is STILL LIVE. It returns the stored entry with claimed=false and writes nothing, so a replay can never extend an expiry; that is also why a claim CANNOT BE RENEWED, which is legacy ClaimCommand's rule and its reasoning: renewal would let one writer hold a command indefinitely, and the state that legitimately spans a long application is applying.

The word LIVE is not decoration, and the third outcome is named here rather than left to be discovered. Once the claim has LAPSED, an exact replay names an expiry that is now in the past, so validateBoundedExpiry refuses the request before the record is read at all: the answer is InboxErrorInvalid on claim_expires_at — neither idempotent nor claim_held. It fails closed, and a caller whose claim has lapsed must choose a new expiry, which is an ordinary re-claim over a lapsed claim rather than a replay.

The idempotency is OVER THE REREAD AND NOT OVER THE LOST RESPONSE. A replay carrying the revision the caller originally decided on is a CONFLICT carrying the current revision, exactly as SettleDispositionCommand's idempotent arm sits behind its own revision comparison. The answer to a compare-and-swap whose outcome a caller did not learn is to reread, in this protocol as in every other path here.

What a successor may do

A residency STRICTLY ABOVE the record's mark may claim a command whose claim is still live. That is failover rather than a special reclaim: the record's members carry the new claim exactly as the first one did. A claim taken over a LAPSED claim is likewise an ordinary claim.

func (*Store) ClearActiveContinuationPointer

func (s *Store) ClearActiveContinuationPointer(
	ctx context.Context, req ClearSessionPointerRequest) (SessionPointerEntry, error)

func (*Store) ClearHostRegistration

func (s *Store) ClearHostRegistration(ctx context.Context, req ClearHostRegistrationRequest) (HostRegistrationEntry, error)

ClearHostRegistration releases one session's route under the registration's fencing epoch, writing the tombstone rather than deleting the record.

It is idempotent under one grant: a repeat returns the stored tombstone without writing. What carries that rule is the EQUALITY in the repeat condition, not the order it is written in — an epoch equal to the tombstone's has already passed the fence, so moving the repeat check above the fence changes nothing a caller can observe, and a mutation that moves it survives. It is written after the fence anyway, because the repeat check is not an ownership test and must never become the only thing standing between a superseded lease and a success: weaken it to "is this record released" and the fence above is what still refuses epoch 3 a tombstone written at 5.

A LATER grant releasing an already-released session is not a repeat, and this is the one place that distinction has teeth: the tombstone must be rewritten so the high-water mark rises to the later epoch. Treating it as a repeat would leave the fence at the older epoch, and every lease granted in between — all of which have provably lost the session — would still be able to write.

A session with no registration at all reports NotFound. Cleanup is idempotent with respect to ITS OWN tombstone, not with respect to nothing: creating a tombstone for a session no Host ever registered would mint a fencing high-water mark out of an unverified caller-supplied epoch.

func (*Store) ClearRuntimeCheckpointPointer

func (s *Store) ClearRuntimeCheckpointPointer(
	ctx context.Context, req ClearSessionPointerRequest) (SessionPointerEntry, error)

func (*Store) ClearWorkspaceCheckpointPointer

func (s *Store) ClearWorkspaceCheckpointPointer(
	ctx context.Context, req ClearSessionPointerRequest) (SessionPointerEntry, error)

func (*Store) Close

func (s *Store) Close(ctx context.Context) error

Close initiates shutdown exactly once. It cancels Store-owned work, waits for admitted background work, foreground operations, and returned readers, then closes an explicitly owned provider at most once with a fresh, lifecycle-owned timeout. Each caller's ctx bounds only its own wait. If a caller stops waiting, shutdown continues and a later call can observe the one stable final result.

func (*Store) CompleteCommand

func (s *Store) CompleteCommand(ctx context.Context, req CompleteCommandRequest) (InboxEntry, error)

CompleteCommand records that an applying command's effect committed.

It requires the caller to be the epoch the claim was taken under and does NOT require that claim to still be live, and the asymmetry with rejection is deliberate. Completing RECORDS SOMETHING THAT ALREADY HAPPENED: the result names a journal event that is already durable, which the caller could only have committed while it held the session lease. Refusing to record it because a claim TTL lapsed in the meantime would leave a command whose effect is visible in the journal sitting in applying, waiting for a recovery pass to discover what the writer standing right there already knew. Rejecting, by contrast, DECIDES something that has not happened, so it keeps the live-claim requirement.

A SUCCESSOR LEASE finishing an application it did not start is the other half of this, and it arrives with inbox_recovery.go. It is admitted on durable evidence and on nothing else: a strictly greater epoch may complete an applying command only when the journal carries a prefix correlated with the record's own two identities AND the public event that carried its effect, and only when the result it records is that effect. Everything the same-epoch path takes on the caller's word — that an effect exists, and which event it is — the successor has to have read out of the stream.

The apply deadline takes no part in it. Finishing an application that is already durable is CONTINUATION of work that started before the deadline, not a new claim, and refusing to record it would leave a command whose effect is visible to a client sitting unfinished forever. The no-new-claim-after- deadline rule is unaffected, because a claim is a different operation and still refuses.

The claim is preserved unchanged, including its epoch, so the record keeps naming the lease that APPLIED the command rather than the one that noticed.

func (*Store) ControlShards

func (s *Store) ControlShards() int

ControlShards reports the shard count this store's backend is committed to.

It is a READ of a persisted decision, not a setting. A sweeper needs it to know how many shards to visit, and it must come from the store rather than from the sweeper's own configuration: a sweeper that visited a different number would silently never look at some of them.

func (*Store) CreateCatalogEntry

func (s *Store) CreateCatalogEntry(ctx context.Context, req CreateCatalogEntryRequest) (CatalogEntry, bool, error)

CreateCatalogEntry binds the session's collision witnesses and creates its one authoritative ordered record. A duplicate identity returns the canonical stored record with created false and never overwrites it.

func (*Store) DrainHostTarget

func (s *Store) DrainHostTarget(ctx context.Context, req DrainHostTargetRequest) (HostTargetEntry, error)

DrainHostTarget withdraws one Host's capacity for one target under the row's generation high-water mark.

A drain is the graceful counterpart of the reconciler: it removes the row from the placement view and from the deadline view in one compare-and-swap, at the instant a Host decides to stop rather than at the instant its promise runs out. Both write the same withdrawn SHAPE — a record with no advertisement — so there is one withdrawn state rather than two, and both reach it through the one write that moves the value and both views together. They differ only in the generation they leave behind: a drain raises the mark to the incarnation that asked, while the reconciler preserves whatever the row already carried, because a sweep speaks for no incarnation.

It is idempotent under one incarnation: a repeat returns the stored row without writing. What carries that rule is the EQUALITY in the repeat condition, not the order it is written in — a generation equal to the stored one has already passed the fence — so it is written after the fence deliberately: the repeat check is not an ownership test and must never become the only thing standing between a superseded incarnation and a success.

A LATER incarnation draining an already-withdrawn row is not a repeat, and the row is rewritten so the high-water rises to it. Treating it as a repeat would leave the mark at the older generation and let every incarnation in between — all of them provably restarted away — write again.

A (target, host) pair that has never advertised reports NotFound. A drain is idempotent with respect to ITS OWN withdrawal, not with respect to nothing: writing a withdrawal for capacity that was never offered would mint a generation high-water out of an unverified request AND leave a permanent row standing for capacity that never existed, which is the accumulation this record is built to avoid.

It deliberately does not verify the target's collision witness, which PublishHostTarget binds. A drain creates no name — it can only ever compare-and-swap a row that already exists — and the row it finds is held to the requested target by its own bytes, which is a strictly stronger check than a digest comparison. Verifying here would restate a weaker form of a check already made and would answer "there is nothing to drain" with a keyspace failure.

func (*Store) FindCommandApplication

func (s *Store) FindCommandApplication(ctx context.Context, req FindCommandApplicationRequest) (CommandApplication, error)

FindCommandApplication reports what a session's journal proves about one command's application.

It reads the command's authoritative record FIRST and correlates against the identities stored there, never against identities a caller supplied. That is the point of the durable mapping: a caller that could name the runtime identity to correlate on could ask about a mapping that was never accepted, and the answer would be evidence about nothing.

It is offered publicly because a recovering Host has to DECIDE between finishing and rejecting, and because the result a finished application records is the correlated effect — which the caller has no other way to name. A journal fault is reported in the journal's own vocabulary: it is a fault of the stream rather than of the command record, and a caller separates them by type exactly as it already must.

func (*Store) GetActiveContinuationPointer

func (s *Store) GetActiveContinuationPointer(
	ctx context.Context, req GetSessionPointerRequest) (SessionPointerEntry, error)

func (*Store) GetCatalogEntry

func (s *Store) GetCatalogEntry(ctx context.Context, req GetCatalogEntryRequest) (CatalogEntry, error)

GetCatalogEntry reads one session's record directly by identity. It verifies the session's collision witnesses before any provider read, so a derived name is never trusted on its own.

func (*Store) GetCommand

func (s *Store) GetCommand(ctx context.Context, req GetCommandRequest) (InboxEntry, error)

GetCommand returns one command's authoritative record, its current revision, and its immutable acceptance order.

It is the read half of every compare-and-swap in this file: a caller that loses a race, or that meets a state it has no edge out of, learns what to do next by reading the record rather than by decoding the failure. A terminal command stays readable here forever — its terminal write leaves it out of the due view but not out of the store — which is what lets a caller report an outcome it did not itself commit.

func (*Store) GetDispositionCommand added in v0.5.0

func (s *Store) GetDispositionCommand(ctx context.Context, req GetDispositionCommandRequest) (DispositionInboxEntry, error)

GetDispositionCommand checks actual catalog authority and reads one exact inbox row. A protocol witness alone never authorizes a result. It verifies the catalog binding, not the public-create reservation. A returned PublicCreate marker alone cannot authorize future public-create dispatch; only successful AdmitPublicCreate verifies reservation, catalog and inbox together to acknowledge that create.

func (*Store) GetHostRegistration

func (s *Store) GetHostRegistration(ctx context.Context, req GetHostRegistrationRequest) (HostRegistrationEntry, error)

GetHostRegistration returns one session's route, and returns it only while it is a route.

A released registration reports Released and an expired one reports Expired, each without the tuple: a caller cannot route to a Host this store will not vouch for, because it is never handed the endpoint. Both are the "treat an expired entry as absent" rule of the routing design, stated so that the two causes remain distinguishable to an operator while being identical to a router. See RegistryErrorCode.

It verifies the session's collision witnesses before any provider read, so a derived name is never trusted on its own.

func (*Store) GetObject

func (s *Store) GetObject(ctx context.Context, req GetObjectRequest) (io.ReadCloser, error)

GetObject returns a lifecycle-held verified stream. A caller establishes integrity only by reading through terminal EOF; premature Close is an error. Open requires storage.BlobReaderLifecycle so concurrent Close bounds an active provider Read and Store shutdown can cancel outstanding streams before closing an owned provider. A shutdown-triggered reader Close error is latched on that reader; Store.Close orders the cleanup but does not aggregate an error from a reader the caller abandoned.

func (*Store) GetObjectMetadata added in v0.4.0

func (s *Store) GetObjectMetadata(ctx context.Context, req GetObjectMetadataRequest) (sessionwire.ObjectMetadata, error)

GetObjectMetadata returns immutable metadata recorded after PutObject verified the persisted blob. It does not verify current body presence or integrity and grants no authorization to consume it. GetObject still verifies through EOF.

Lookup performs at most two scope-witness reads and one exact metadata read; it never enumerates or reads blob bodies. Missing metadata is reported as ObjectErrorMetadataUnavailable, not proof that bytes are absent. Objects written before the index was introduced remain readable through GetObject with explicit metadata. A stale index may survive blob deletion, in which case GetObject preserves the provider's storage.BlobNotFoundError as a cause.

func (*Store) GetReconciliationClaim

func (s *Store) GetReconciliationClaim(
	ctx context.Context,
	req GetReconciliationClaimRequest,
) (ReconciliationClaimEntry, error)

GetReconciliationClaim returns one session's claim, and returns it only while it is a claim.

A lapsed claim reports Lapsed without the record, which is the same discipline GetHostRegistration applies to an expired route: a caller is never handed state this store will not vouch for, so it cannot act on a horizon that has already passed. The holder of a lapsed claim is not withheld to protect anything — it is withheld because it is not an answer to the question this operation asks.

It verifies the session's collision witnesses before any provider read, so a derived name is never trusted on its own.

func (*Store) GetRuntimeCheckpointPointer

func (s *Store) GetRuntimeCheckpointPointer(
	ctx context.Context, req GetSessionPointerRequest) (SessionPointerEntry, error)

func (*Store) GetWorkspaceCheckpointPointer

func (s *Store) GetWorkspaceCheckpointPointer(
	ctx context.Context, req GetSessionPointerRequest) (SessionPointerEntry, error)

func (*Store) ListCompatibleHosts

func (s *Store) ListCompatibleHosts(ctx context.Context, req ListCompatibleHostsRequest) (HostTargetPage, error)

ListCompatibleHosts returns one bounded page of the Hosts currently offering capacity for one target, most free capacity first.

The whole page is one ranked provider query. The target is the ranking scope, so the restriction and the capacity order are both inside the query and the limit applies to an already-restricted, already-ordered result. Nothing here enumerates a prefix, sorts a directory, or narrows a wider page to a target afterwards: those cost work proportional to the fleet rather than to the page, and a Factory calls this on every placement decision.

It deliberately does NOT verify the target's collision witness, which is where it differs from every write here. A write names a row and must prove the derived name before it creates one; a listing names no row, and a target nothing has ever advertised has no witness to prove, so requiring one would answer "no capacity" with a failure. Safety comes from below instead: every row the provider returns is held to the target it itself claims, so a scope two targets somehow shared would fail the page closed rather than offer one target's Hosts as the other's.

A LAPSED ROW IS NOT PUBLISHED, and the reason it is dropped here rather than excluded by the query is that no ranked query can express it: the ranked view is ordered by capacity and knows nothing about the clock. This is the same "an expired entry reads as absent" rule GetHostRegistration applies, applied per row — a caller is never handed an endpoint this store will not vouch for. It is emphatically not this package's answer to stale rows accumulating: the row is still ranked and still occupies a position in every later page, and ReconcileHostTargets is what removes it. The count says so out loud.

NO SINGLE ROW CAN FAIL A PAGE: every per-row refusal is counted and stepped over. HostTargetPage states why that is the strongest rule here rather than leniency. The consequence for this signature is what matters at the call site: a failure returned from here is always about the QUERY — a bad limit, a foreign cursor, a provider that could not answer — and never about one row.

func (*Store) ListDueCommands

func (s *Store) ListDueCommands(ctx context.Context, req ListDueCommandsRequest) (DueCommandPage, error)

ListDueCommands returns one bounded page of one shard's commands whose horizon has passed.

It is a READ. It claims nothing, expires nothing, and writes nothing: what a reconciler does about an outstanding command is the reconciler's business, and every write it then performs is a compare-and-swap against the revision this page reported.

ITS COST IS THE PAGE, AND THAT IS THE WHOLE POINT. The rows come from the ordered index's due view of one namespace, so a terminal command — which inboxDue files NOT DUE — is not in the view at all, a historical session contributes nothing, and the deployment's tenant count does not appear in the cost. Nothing here reads the catalog, and nothing enumerates a session's inbox.

IT DOES NOT VERIFY EACH ROW'S SESSION WITNESSES, and that is a departure from every NAMED read here worth stating rather than leaving to be noticed. Those paths verify because they DERIVE a record name from identities and must not trust it on its own. This one derives nothing: the provider supplies the row, and the row is held to its own bytes — its filing, its scope, its shard. A collision would put two sessions in one ordering scope, and a row would still report the identities its own bytes carry; what a reconciler then DOES with it goes through a named write, which verifies the witnesses and refuses. The alternative costs one KV read per distinct session per page for a check that decides nothing this page reports.

func (*Store) ListDueDispositionCommands added in v0.5.0

func (s *Store) ListDueDispositionCommands(ctx context.Context, req ListDueDispositionCommandsRequest) (DispositionDueCommandPage, error)

ListDueDispositionCommands issues one bounded index page and at most one exact catalog read (plus its scope witnesses) per examined row. It performs no global scan, blob reads or mutations. Only the provider's continuation advances the view; an empty Commands slice does not imply an exhausted page.

func (*Store) ListDueGates

func (s *Store) ListDueGates(ctx context.Context, req ListDueGatesRequest) (DueGatePage, error)

ListDueGates returns one bounded page of gates whose absolute deadline has passed, deployment-wide, each one validated against the durable open projection it names.

It is a READ. It takes no action, cancels nothing, suspends nothing, and schedules nothing: what a Host does about an expired gate is gate continuation, which this task deliberately does not implement. It exists because the ordering contract in this file's header creates exactly one crash remnant — an intent whose open event never committed — and something has to be the reader that validates it away rather than acting on it.

It has a continuation, and that is what stops it being starved

A remnant intent — one whose gate the session's durable record does not project as open — is REPORTED rather than acted on, and it is not retired here. It cannot be: OpenGate makes an intent durable before it commits the projection, so an intent with no matching open gate is indistinguishable, in its bytes, from a gate being opened right now. Retirement is a separate call that waits out a window no single open can outlive; see RetireGateDeadlineIntent and gateIntent.RecordedAt.

Without a resume position that would be permanent head-of-line blocking: the view is ordered by deadline ASCENDING, a remnant's deadline is in the past and never changes, and a Host that re-projects its open gates wholesale through UpdateCatalogHostState — a normal path, documented as such above — produces one remnant per gate it drops. Once Limit of them accumulate ahead of the live gates, every page from the head consists entirely of them.

NextCursor is the fix, and bounding the pass would not have been: a page budget bounds what one pass costs, but nothing about it moves the row that is blocking, so the blocked rows stay blocked. The continuation steps PAST a row that reported nothing, so the sweep reaches what is behind it on the next page. A caller that pages a shard to exhaustion sees every due row in it.

The page still reports Examined and Unreadable, because they answer a different question: whether a full page reported nothing, and whether rows were skipped because they could not be read at all. See DueGatePage.

func (*Store) ListSessionDispositionCommands added in v0.7.0

func (s *Store) ListSessionDispositionCommands(
	ctx context.Context, req ListSessionDispositionCommandsRequest,
) (SessionDispositionCommandPage, error)

ListSessionDispositionCommands returns one bounded page of one session's disposition commands in ascending immutable acceptance order, strictly after the caller's bound.

It is a READ. It claims nothing, settles nothing and writes nothing; it does not move the consumption cursor, which is a separate durable decision a caller makes with SaveDispositionCommandCursor after it has acted.

ITS COST IS THE PAGE PLUS ONE CATALOG READ. One ListOrdered against one (namespace, ordering scope) pair returns at most Limit rows, and the catalog is read ONCE for the whole page rather than once per row — which is the one place this listing is cheaper than ListDueDispositionCommands rather than merely different, and it is cheaper for a reason rather than by luck: a due sweep is handed rows from many sessions and must ask the binding question per row, while every row here belongs to the one session the caller named. Nothing here reads the journal, an object or any other session. The deployment's tenant count, the shard's population and the session's settled history above the bound do not appear in the cost. The session's settled history BELOW the bound does not either, which is the point of the bound.

IT VERIFIES THE SESSION'S WITNESSES AND ITS CATALOG BINDING, unlike ListDueDispositionCommands' per-row skipping, and the difference is the same one that decides every other named read in this package: a due sweep is HANDED its rows by the provider and derives no name, while this call DERIVES the ordering scope from caller-supplied identities and must not trust a derived name on its own. dispositionCatalog makes both checks — readCatalogEntry verifies the collision witnesses, and the binding must be ProtocolModeDisposition — before the provider is asked for a single row.

IT FAILS CLOSED ON A ROW IT CANNOT VOUCH FOR, which is the deliberate divergence from the due sweep, and it is worth stating as a decision rather than discovering as a difference. The due view counts an unreadable row and steps over it, because failing would switch reconciliation off for every tenant in the shard and because its continuation passes the row rather than meeting it again. Neither argument holds here. This is a CONSUMPTION stream: a consumer that was handed a page with a row quietly missing would act on what it received and then advance its durable cursor past the row, so the command would never be applied and nothing would ever look at it again. And the blast radius of failing is one session rather than one shard. So a row that does not decode, that disagrees with its filing, or that carries another session's identities or another binding fails the whole page, with the same typed error a named read of that command would return. A PROVIDER TOMBSTONE fails it too, and for the strongest reason of the set: nothing in this package deletes a disposition command row, so a tombstone in this stream is a record destroyed by something outside it, and a consumer stepping over one would step over a command whose own bytes it can no longer see.

WHAT FAILING CLOSED COSTS, stated plainly: one unreadable row stops that session's consumer at that row, permanently. There is no skip, no quarantine and no reporting channel — this package has neither a logger nor anywhere in this page to record a skip, which is the same limitation DispositionDueCommandPage states about locating an unreadable row. AND THERE IS NO REPAIR OPERATION EITHER: this package offers no way to rewrite a corrupt command row, so "until the row is repaired" would name a remedy that does not exist here. What an operator has is the failure's field, the session's identities, and the fact that the bound the page was read at narrows the row to the first one above it; the repair itself is a provider-level act outside this module.

func (*Store) ListSessions

func (s *Store) ListSessions(ctx context.Context, req ListSessionsRequest) (SessionPage, error)

ListSessions returns one bounded recent-first page of a tenant's sessions.

The whole page is one ranked provider query. The tenant is the ranking scope, so the restriction and the recency order are both inside the query and the limit applies to an already-restricted, already-ordered result. Nothing here enumerates a prefix, sorts a catalog, or filters a wider page afterwards: those all cost work proportional to a tenant's history rather than to the page, and a Factory calls this on every picker render.

This deliberately does NOT verify the tenant's collision witness, which is where it differs from GetCatalogEntry. A direct get names a session and must prove that session's binding before it trusts a derived name; a list names no session, and a tenant that has never created one has no binding to prove, so requiring one would answer "this tenant is empty" with a failure. Cross-tenant safety instead comes from below: every record the provider returns is held to the tenant it itself claims, so a scope two tenants somehow shared would fail the page closed rather than disclose a row.

func (*Store) LoadDispositionCommandCursor added in v0.7.0

func (s *Store) LoadDispositionCommandCursor(
	ctx context.Context, req LoadDispositionCommandCursorRequest,
) (DispositionCommandCursorEntry, error)

LoadDispositionCommandCursor returns one session's consumption cursor, or the zero entry when none has been recorded.

A MISSING CURSOR IS NOT AN ERROR, and that is the requirement rather than a convenience. "Nothing has been consumed" is a complete, true and actionable answer — it is precisely the starting position of a fresh consumer — so reporting it as a failure would make every caller translate a not-found code back into the zero it already means, and one caller would get it wrong.

IT IS NOT WIDER THAN THAT, in two directions a caller must know about.

First, a stored row this reader cannot decode is NOT an absent cursor; it is a fencing high-water mark that cannot be evaluated, and it is reported as the typed failure it is. The hazard is entirely in that direction: absence licenses SaveDispositionCommandCursor to create a fresh record at whatever epoch and order the caller named, so a reader that reported an undecodable row as absence would let any caller reset the fence.

Second, this is a NAMED READ of the disposition family and takes that family's authority check: it reads the catalog first, which verifies the session's collision witnesses and requires ProtocolModeDisposition, exactly as GetDispositionCommand does. A session with no catalog record, or one bound to another protocol, is REFUSED rather than answered "zero". That refusal is a *CatalogError and a caller must not translate it into "no commands"; the zero entry is the answer for a session that EXISTS and has no cursor.

func (*Store) OpenGate

func (s *Store) OpenGate(ctx context.Context, req OpenGateRequest) (CatalogEntry, error)

OpenGate records a gate's deadline and then projects it as publicly open.

Every rejection below precedes both writes, and the two writes are ordered: see this file's header for why the intent is durable first.

func (*Store) OpenJournal

func (s *Store) OpenJournal(ctx context.Context, req OpenJournalRequest) (*JournalWriter, error)

OpenJournal takes single-writer ownership of a session's journal.

It binds the session's collision witnesses, acquires the lease, reads the tip once, and commits the opening fence at that exact tip. Every failure after the lease is granted releases that grant before returning, so a caller that retries always does so under a fresh, strictly higher epoch.

The returned writer holds a Store admission until Close, so Store.Close waits for it; Store shutdown also closes an abandoned writer so it can never wedge that wait.

func (*Store) PreparePublicCreate added in v0.5.0

func (s *Store) PreparePublicCreate(ctx context.Context, req PreparePublicCreateRequest) (PublicCreatePreparation, error)

PreparePublicCreate uses a fixed sequence of exact operations. A failed or ambiguous write returns an error and no preparation; retry the same identity. A reservation that loses the catalog race remains reserved, never accepted.

func (*Store) PublishHostTarget

func (s *Store) PublishHostTarget(ctx context.Context, req PublishHostTargetRequest) (HostTargetEntry, error)

PublishHostTarget publishes or refreshes one Host's advertisement for one target.

The stored HostGeneration is a high-water mark, not a lock: a request naming a lower generation is refused outright and an equal one is admitted, because one incarnation heartbeats many times. The read-compare-write is closed by the revision compare-and-swap, so a request that observed a stale generation cannot land after a successor's write.

Unlike the registry's epoch, a first publish minting the high-water mark costs only the caller its own capacity. A Host naming an absurd generation fences out its own later incarnations for this one row, permanently — the mark never falls and a withdrawn row still carries it — but the row is one process's offer of capacity for one target, so the damage is a Host that cannot advertise. The registry's equivalent mistake is a session nobody can ever claim. That asymmetry is the whole practical difference between fencing capacity and fencing ownership.

func (*Store) PutCommandPayload added in v0.5.0

func (s *Store) PutCommandPayload(ctx context.Context, req PutCommandPayloadRequest) (sessionwire.ObjectMetadata, error)

PutCommandPayload verifies an existing disposition catalog and reuses the verified streaming upload/index path. Command payloads live in orchestration inbox storage; the binding independently selects journal/artifact storage. This does not enable arbitrary object or journal writes for disposition mode. Independent same-content uploads have distinct generations; losing uploads may remain orphaned. No backfill or garbage collection is implemented.

func (*Store) PutHostRegistration

func (s *Store) PutHostRegistration(ctx context.Context, req PutHostRegistrationRequest) (HostRegistrationEntry, error)

PutHostRegistration publishes one session's observed route under the registration's fencing epoch.

The stored LeaseEpoch is a high-water mark, not a lock: a request naming a lower epoch is refused outright and an equal one is admitted, because one lease grant heartbeats many times. The read-compare-write is closed by the revision compare-and-swap, so a request that observed a stale epoch cannot land after a successor's write.

It is also the only operation that MINTS a fence. ClearHostRegistration refuses to build one for a session no Host ever registered, because that would turn an unverified caller-supplied epoch into a high-water mark; a first registration necessarily does exactly that, and nothing here bounds the value. A first registration naming MaxUint64 therefore admits only later writers naming MaxUint64 and fences out every real lease for the life of the session, permanently — the high-water never falls and this record never expires out of existence. That is the correct fail-closed direction, and the asymmetry is deliberate rather than an oversight: whether a caller's epoch is a lease it actually holds is a question about the lease, which this record cannot answer and must not pretend to.

It reads through readHostRegistration and never through GetHostRegistration, and that separation is the single most load-bearing line in this file. The public reader reports an expired or released registration as no route at all; a writer that believed it would create a fresh record over a row it could not see, and creating a fresh record is exactly how a fencing high-water mark gets silently reset to whatever the superseded writer named.

func (*Store) PutObject

PutObject streams, verifies, persists, and re-verifies an immutable object before returning its metadata. The stages are: static validation, admission, minting an identity, writing the blob, re-reading it back, and create-only persistence of its scoped metadata index for GetObjectMetadata.

The declared SizeBytes and SHA256 are exact: the body is accepted only if it ends at that length with that digest, and neither the caller's metadata nor any reference is produced otherwise.

Orphan policy. The identity is minted before the write, and PutObject returns a reference only after the persisted bytes have been read back and verified and the immutable metadata index has committed. A failure after the blob commits can therefore leave a blob that no caller was ever told about — an orphan, whose verification may also have failed. That is deliberate: the alternative, deleting on a post-commit error, would issue a delete against a provider that has just proved unreliable, and the blob is content- and generation-addressed so it can never be mistaken for another object. Reclaiming orphans is the store operator's job, over the tenant/session blob prefix; this package's only enumeration path, listObjectReferences, is intentionally not exported, so no caller-facing GC exists yet.

func (*Store) ReadDispositionEvidence added in v0.9.0

func (s *Store) ReadDispositionEvidence(ctx context.Context, req DispositionEvidenceRequest) (DispositionEvidence, error)

ReadDispositionEvidence reports the single committed disposition record that names the request's attempt, read from the bound session's journal.

The scope is the request's OWN session, which the store derived from its immutable inbox record; nothing a settlement caller supplied reaches here. The walk is the privileged runtime replay, continued page by page to the tip the first page captured, so the answer is true of one consistent snapshot.

Four refusals are the contract, and each is a refusal rather than a quieter answer for a stated reason:

  • ABSENCE is refused. A journal holding no disposition for this attempt is not proof that the runtime did not apply the command; it is proof only that nothing said so yet. Returning empty evidence would hand the verifier a zero value, and the design of DispositionEvidenceReader is that a reader which does that settles nothing.
  • MORE THAN ONE record is refused. Two records naming one attempt are two answers and this reader has no rule for choosing. They are necessarily distinct: they sit at different sequences.
  • A CONFLICTING record — one that names this attempt but disagrees about the command, the durable runtime mapping or the kind — is refused as a conflict and never stepped over as though it were about something else. Stepping over it would report "no disposition" for a journal that plainly holds one.
  • A record whose own LeaseEpoch disagrees with the nearest preceding OPENING FENCE is refused, and the fence is what the author grant is taken from. This is required rather than defensive: harness bypasses this package's JournalWriter — it calls EncodeEnvelope and appends the bytes itself — so stampWriterOwnedFields never runs and the stored LeaseEpoch is a CLAIM BY THE WRITER. Believing it would let a record written under grant 9 settle as a recovery closure authored by grant 10, which is exactly the statement a closure is trusted for.

A journal fault — an undecodable frame, a stream that ends short of the tip it captured, an unavailable or cancelled provider — propagates in the journal's own vocabulary. It is a fault of the stream, never a finding about the command, and a caller separates the two by type.

func (*Store) ReadGates

func (s *Store) ReadGates(ctx context.Context, req ReadGatesRequest) (sessionwire.GatePage, error)

ReadGates returns one session's open public gates as core's bounded gate page, in the record's canonical (opened_seq, gate_id) order.

It is one direct record read. The gates are already canonical when the record decodes, so nothing here re-sorts them: the comparator lives in canonicalGates and a second one here is precisely what would later disagree with the stored order.

func (*Store) ReadPublicJournal

func (s *Store) ReadPublicJournal(ctx context.Context, req ReadPublicJournalRequest) (sessionwire.JournalPage, error)

ReadPublicJournal returns a bounded page of a session's public events.

Only a public event's stored canonical public body and its Core metadata are returned. Every other record — runtime control, ownership fence, application prefix — is withheld entirely: it contributes nothing to the page except an advance of CoveredThrough, the authenticated watermark that lets a client close a sequence gap without learning the kind or the bytes of what filled it. A public body held in an object is resolved through the same verified object path a caller would use; a private runtime object is never fetched.

func (*Store) ReadRuntimeJournal

func (s *Store) ReadRuntimeJournal(ctx context.Context, req ReadRuntimeJournalRequest) (RuntimePage, error)

ReadRuntimeJournal returns a bounded page of every record in a session's journal, public and private alike, exactly as stored. It is the privileged replay path; product-facing readers use ReadPublicJournal.

func (*Store) ReconcileHostTargets

func (s *Store) ReconcileHostTargets(
	ctx context.Context,
	req ReconcileHostTargetsRequest,
) (HostTargetReconcileResult, error)

ReconcileHostTargets withdraws the advertisements of Hosts that stopped heartbeating.

IT IS A SERVICE OPERATION, NOT A TENANT ONE. It names no tenant, no session and no target: it sweeps the whole directory's deadline view, which is exactly why a caller that is not the control plane must never be given it. It is also the ONLY thing in this package that removes a crashed Host's row from a placement page — the placement reader declines to publish a lapsed row but leaves it ranked — so a deployment that never calls this accumulates ranked capacity that no longer exists.

The due BOUND is fixed for one walk and the revalidation INSTANT is not, and the difference is deliberate rather than an oversight. The bound is fixed because the ordered index binds a due cursor to the exact bound that issued it, so a walk that recomputed it could not page at all; on a resumed call the bound therefore comes from the continuation while the clock reading is fresh.

They are allowed to differ because the bound decides only WHICH rows a page contains and the revalidation decides whether any of them may be withdrawn. A fresh reading is monotonically at or after the bound, which is the safe direction: withdrawal requires the row's own stored expiry to have lapsed at that reading, so a row judged due at the bound and heartbeated since is still refused, and a row that lapsed after the bound is simply not in the page.

A failure reading the due view returns the counts accrued so far beside the error rather than a zero result: a sweep that withdrew rows and then lost the provider did that work, and reporting nothing would make a caller's next decision — sweep again now, or wait — rest on a number it knows is false. It returns the walk's POSITION too, for the same reason it returns one on a budget exhaustion: without it a caller that lost the provider halfway pays the cost of every unreadable row ahead of it all over again.

Each row is revalidated against its OWN STORED EXPIRY before anything is written, and the compare-and-swap onto the revision the page reported is what makes that revalidation binding rather than advisory. The two together are the whole guard: a Host that heartbeated after the page was read either presents an unlapsed expiry — in which case the sweep leaves it alone — or has already advanced the revision, in which case the write loses. Trusting the page's due state alone would withdraw the capacity of a Host that is alive.

func (*Store) RejectCommand

func (s *Store) RejectCommand(ctx context.Context, req RejectCommandRequest) (InboxEntry, error)

RejectCommand settles a command with a durable typed reason.

It has two callers with different authority, and one rule that serves both:

  • The holder of a live claim may reject the command it is working on, from claimed or from applying. It has revalidated the command and found it cannot be applied, and that answer is as durable as an application.
  • A reconciler may settle a command NOBODY is working on: pending, or claimed under a claim that has lapsed. It needs no lease epoch, and if it names one it is still held to the record's high-water mark, because a caller that asserts an epoch is asserting a view of the session that may be stale.
  • A SUCCESSOR LEASE may settle an applying command its predecessor abandoned. That one is recovery rather than reconciliation and is described below.

An unexpired claim therefore wins the deadline race outright: while the claim is live the only caller who may reject is its holder, whatever the clock says. So does an applying record, which additionally cannot be reclaimed at all, so the two-step of superseding the claim and then rejecting is closed as well.

EVERY caller is additionally held to the journal, and that check is not the reconciler's alone: rejection is admitted only when the correlation establishes that NO EFFECT COMMITTED under this command. A rejection written over a durable effect is the exact overwrite the terminal states exist to prevent, and the claim holder standing on its own committed effect can commit it as easily as a late reconciler — so the rule is a property of the record's journal rather than of who is asking. Its cost is a walk of the session's stream on every rejection, paid deliberately: see inbox_recovery.go on why it is unconditional.

An APPLYING record whose claim has lapsed is the one settlement that needs an authority as well as evidence, and it needs BOTH:

  • a lease epoch strictly above the one that took the claim, which is what makes this the next lease holder's move rather than an anonymous reconciler's, and
  • a journal fence above that same epoch, which is what proves the applier can no longer commit the effect this rejection would orphan. The epoch the caller names cannot prove it; only the fence can.

The two arrive together in practice, because the fence is written by the successor's own OpenJournal, which is what turns the head-of-line hazard this file used to document into a bounded one: an expired applying record was settleable by nobody, forever, and is now settled when the session is next attached.

THE PERMANENT HAZARD WAS RELOCATED, NOT ELIMINATED, and a reader sizing that signal needs all three sources rather than the one this paragraph used to name:

  • A live claim that outlives the apply deadline occupies a due place it cannot be settled from. Bounded by MaxCommandClaimTTL, which exists for exactly this, and it is ordinary operation rather than a crash: every command claimed close to its deadline contributes.
  • An UNRESOLVED correlation in the conforming crash window — a prefix at the tip, its writer gone — is bounded by re-attachment: the successor's opening fence lands at prefix+1 and the correlation becomes abandoned.
  • An UNRESOLVED correlation from a WRITER-CONTRACT VIOLATION is bounded by nothing. If the record at prefix+1 is already something that is neither the effect nor a fence — a stacked prefix, an interleaved control record — it is durable and no later event changes it, so the command is unsettleable forever. That is the same permanent row this file used to hand every crashed applier, now confined to writers that broke the adjacency rule inbox_recovery.go states. It is smaller and it is not gone; a due-command reader must still expect rows that never clear.

Nothing is starved TODAY, because this package exposes no due command reader for anything to be starved out of; the hazard arrives with the reader. ListDueGates met the same shape and answered it by reporting what a page EXAMINED alongside what it returned, so a page that is full of rows it could not act on is distinguishable from a deployment with nothing to do.

func (*Store) RejectDispositionCommand added in v0.8.0

func (s *Store) RejectDispositionCommand(ctx context.Context, req RejectDispositionCommandRequest) (DispositionInboxEntry, bool, error)

RejectDispositionCommand settles a command that no dispatch was ever authorized for, which is the only rejection this protocol has a producer for.

The second result reports whether THIS CALL wrote the rejection.

It cannot become a post-attempt rejection, and that is the point

Every route by which it might is closed by ONE check on the record rather than by a check on the caller, so no residency, no clock and no revision can reach the other case:

  • An APPLYING record carries an attempt and is refused. Its outcome is the journal's to supply through SettleDispositionCommand, and a runtime error with no durable disposition leaves it applying until a successor writes not_applied.
  • A TERMINAL record that carries an attempt is refused as terminal. That includes a settled `rejected` — the not_applied tombstone — which is a rejection this call must never present as its own idempotent result: it was settled from evidence, carries an outcome, and means something else entirely.
  • A terminal record with NO attempt is this edge's own prior result, and is returned unchanged with rejected=false.

Authority

A residency is optional; see this file's header. What confines a caller that names none is the CLAIM RULE, which is a property of the record and applies identically at every residency: a LIVE claim admits only its own holder. So a reconciler may settle a pending command or one whose claim has lapsed, and a live claim wins the deadline race outright — while it holds, the only caller that may reject is its holder, whatever the clock says.

A SUCCESSOR IS ANSWERED DIFFERENTLY BY THE TWO EDGES, and the asymmetry is deliberate rather than an oversight. A residency above the record's mark may CLAIM a live claim away from its predecessor — that is failover — but may not REJECT the command under it: it is told claim_held and must take the claim first. Rejecting is a TERMINAL decision about work the holder may be in the middle of, so it belongs to whoever holds the claim; claiming first is the successor's route, and it makes the successor the holder before it decides anything terminal.

THE IDEMPOTENT ARM ABOVE SHORT-CIRCUITS BOTH FENCES, which is the opposite ordering from the claim edge and is stated because a reader will expect the claim edge's. A superseded residency replaying a pre-dispatch rejection is given the idempotent success, not InboxErrorEpoch. That is sound because the arm WRITES NOTHING — it is a read of a record that is already terminal, and telling a superseded caller "this was already rejected" is both true and final. The claim edge's replay arm sits after its fence instead because that edge can go on to write.

A rejection of a CLAIMED command keeps the claim. It is the durable record of who was working on the command when it was refused, and the codec admits an attemptless rejection carrying one precisely so it can be kept.

func (*Store) ReleaseReconciliationClaim

func (s *Store) ReleaseReconciliationClaim(
	ctx context.Context,
	req ReleaseReconciliationClaimRequest,
) (ReconciliationClaimEntry, error)

ReleaseReconciliationClaim gives the caller's own claim back, so the next replica need not wait out the TTL.

It writes a claim whose expiry EQUALS its claim instant, which has lapsed on arrival. It does not delete the row: this package writes no provider tombstones, and a lapsed claim is already indistinguishable from no claim to every reader.

A REPEAT IS A SUCCESS THAT WRITES NOTHING. A caller cannot tell a lost reply from a failure, so a second release is the ordinary case rather than a mistake, and answering it with an error would make every retry look like a claim that had expired mid-work. The repeat condition is "the record is the caller's and is not live", which a lapsed-by-timeout claim also satisfies — so a holder that overran its TTL is told its release succeeded. That loses a signal, and it is the right trade precisely because the claim licensed nothing: nothing the holder did was authorized by the claim, so nothing it did becomes wrong when the claim runs out.

A claim that is NOT the caller's is refused either way, and the two codes are different facts rather than one fact with two names: Held means another replica is working now, so wait; Lapsed means nobody is, so there is nothing to release and no reason to wait. Telling a caller "held" for a claim that had run out would make it back off for a horizon already in the past.

func (*Store) ResolveGate

func (s *Store) ResolveGate(ctx context.Context, req ResolveGateRequest) (CatalogEntry, error)

ResolveGate clears one gate from the open projection and then retires its deadline intent.

Retiring is a tombstone rather than an erasure: the intent's bytes remain readable for audit, its identity can never be reused to reopen the same gate, and a tombstone is unranked and not due, so it leaves the due pages by the provider's own contract rather than by a flag this package would have to maintain.

func (*Store) RetireGateDeadlineIntent

func (s *Store) RetireGateDeadlineIntent(ctx context.Context, req RetireGateDeadlineIntentRequest) error

RetireGateDeadlineIntent removes one remnant gate deadline intent.

A REMNANT IS THE ONLY THING IT REMOVES, and "remnant" is decided here, from durable state, rather than accepted from the caller. ListDueGates reports candidates; this operation independently re-reads the session's projection and the row, at its own clock reading, and refuses everything else.

WHAT EACH ABSENT ANSWER LICENSES, enumerated, because this is a path where a value that reads as absent removes work:

  • The INTENT ROW IS ABSENT: refused, NotFound. Absence is not "already retired" — this package never erases, so a retired intent has a durable spelling and it is a tombstone. An absent row means the caller is retiring something this store has never held, and answering success would tell a sweeper it had handled a row it never touched.
  • The INTENT ROW IS A TOMBSTONE: success, and nothing is written. This is the repeat case, and a caller cannot tell a lost reply from a failure, so a second retirement is ordinary rather than mistaken. The tombstone is this store's own record that the work is done.
  • The SESSION HAS NO DURABLE EXISTENCE — no catalog record, a tombstoned one, or an unbound session witness: retirement is PERMITTED. A session that does not durably exist cannot durably project an open gate, so every intent it carries is a remnant. This is exactly noSuchSession's set and deliberately not one entry wider: a collision, an unreadable record, or a provider fault says nothing about whether the gate is open, and each of those STOPS the operation instead.
  • The GATE IS NOT IN AN EXISTING RECORD'S OpenGates: retirement is permitted, subject to the age below. This is the ordinary remnant.
  • The GATE IS OPEN: refused as a conflict. Retiring a live gate's deadline is the one outcome this operation must never produce.

And the age: an intent younger than MinGateIntentRemnantAge is refused with TooSoon, because inside that window "remnant" and "in flight" are the same bytes. TooSoon is its own code rather than a conflict for a reason a caller acts on — it means retry later, whereas a conflict means re-read.

func (*Store) SaveDispositionCommandCursor added in v0.7.0

func (s *Store) SaveDispositionCommandCursor(
	ctx context.Context, req SaveDispositionCommandCursorRequest,
) (DispositionCommandCursorEntry, error)

SaveDispositionCommandCursor records how far a session's consumer has got, under the caller's residency epoch.

THE CONCURRENCY CONTRACT, stated in full because a consumer designs against it. Three rules, in the order they are applied:

  1. THE EPOCH FENCE RUNS FIRST, and it answers a question about the CALLER: may you write this session at all. A strictly lower epoch than the committed one is refused with InboxErrorEpoch carrying the committed mark. An equal one is admitted, because one grant saves many times.

  2. THE ORDER FENCE RUNS SECOND, and it answers a question about the caller's DATA: is the position you hold at least as far as the one stored. A strictly lower order is refused with InboxErrorOrder carrying the committed position. An equal one is admitted, so a save retried after an ambiguous outcome succeeds rather than reporting a regression.

    THE ORDER OF THE TWO IS LOAD-BEARING, AND THE CASE THAT DECIDES IT IS A CALLER BELOW *BOTH* MARKS. That is worth stating precisely, because the obvious candidate is the wrong one: a caller with a LOW EPOCH and a HIGH POSITION passes the order fence and is then refused by the epoch fence, so it receives InboxErrorEpoch under EITHER ordering and tells you nothing about which ran first. The caller whose answer actually changes is the one below both — a superseded lease that also holds a stale position. Epoch-first tells it "you have lost the session", which is terminal and correct; order-first would tell it "your position is stale", which invites it to fetch newer data and retry forever against a session it no longer owns. TestSaveCursorFencesTheEpochBeforeTheOrder's `earlier_epoch, earlier_order` row is that probe, and it is the ONLY row that changes answer when the two calls are swapped.

  3. THE WRITE IS A COMPARE-AND-SWAP on the revision this call just read. Two savers under the SAME epoch are therefore ordered by the provider, and the loser receives InboxErrorConflict carrying the actual revision rather than overwriting the winner. A LOST CREATE RACE IS THE SAME ANSWER: a create that finds the identity already present reports conflict, not success and not a corrupt-record failure, because the caller's correct response is identical — re-read and meet both fences.

So the contract is: MANY CONCURRENT SAVERS ARE SAFE, the stored position is non-decreasing under every interleaving, and THE TWO FENCES AND THE COMPARE-AND-SWAP HAVE EXACTLY THREE ANSWERS BETWEEN THEM — you have lost the session (InboxErrorEpoch), your position is stale (InboxErrorOrder), or you raced (InboxErrorConflict).

THAT IS A CLAIM ABOUT THE FENCES, NOT ABOUT THE CALL, and the difference is exactly what two earlier versions of this sentence got wrong. The first said "every refusal"; the second narrowed it to "every refusal of a well-formed request against a readable row", which is still too wide, because a row can decode perfectly and still be one this store refuses to vouch for. REACHING A FENCE AT ALL requires a well-formed request, a session this store will vouch for, and a stored row this store will vouch for, and each of those has refusals of its own. None of them is one of the three:

  • InboxErrorInvalid — the request itself: a zero epoch or a zero position.
  • *CatalogError — the session is absent, or is bound to another protocol.
  • InboxErrorMalformed, InboxErrorVersion, InboxErrorTooLarge — the stored row does not decode.
  • InboxErrorDeleted — the stored row is a provider tombstone.
  • InboxErrorIdentity — THE ARM THAT KEEPS BEING LEFT OUT OF THIS LIST. The stored row DECODES and is still refused: it carries another session's identities, or is filed under another stable key, ordering scope, rank or due state, or a write's reply is not the bytes the write handed the provider. It is reachable from a perfectly well-formed request against a perfectly readable row, which is why the narrower scope above did not save the sentence.
  • InboxErrorBackend, InboxErrorUnknown — the provider failed or was ambiguous.

A consumer's classification must cover all of these and MUST HAVE A DEFAULT ARM. This list is a residue, not a closure: it is what the code can produce today, enumerated so the three-answer claim above cannot be read as covering the whole call, and a reader must not treat the absence of a code from it as evidence that the code is unreachable.

What this is NOT is a lock: this call never waits, never retries for a caller, and never blocks a second writer.

WHAT A SUCCESSFUL SAVE PROVES is only what DispositionCommandCursor says it does. In particular it is not evidence that the saver held a live residency at the swap; the fence establishes an ordering against what is stored, and nothing in this package reads a live lease here.

func (*Store) SetActiveContinuationPointer

func (s *Store) SetActiveContinuationPointer(
	ctx context.Context, req SetSessionPointerRequest) (SessionPointerEntry, error)

func (*Store) SetRuntimeCheckpointPointer

func (s *Store) SetRuntimeCheckpointPointer(
	ctx context.Context, req SetSessionPointerRequest) (SessionPointerEntry, error)

func (*Store) SetWorkspaceCheckpointPointer

func (s *Store) SetWorkspaceCheckpointPointer(
	ctx context.Context, req SetSessionPointerRequest) (SessionPointerEntry, error)

func (*Store) SettleDispositionCommand added in v0.6.0

func (s *Store) SettleDispositionCommand(ctx context.Context, req SettleDispositionCommandRequest) (DispositionInboxEntry, bool, error)

SettleDispositionCommand settles one applying command from verified evidence.

The sequence is fixed and each step exists for a stated reason:

  1. Read the store's OWN authority — the immutable catalog binding and the session's protocol-mode fence, which installs its create-only witness when the session has none at all — and the current inbox record, and hold the caller to the revision it decided on. Losing that comparison returns the current revision, because the answer to a lost compare-and-swap is to reread the inbox and never to repeat execution.
  2. A record that is already terminal at that revision is an IDEMPOTENT result and is returned as it stands, with settled=false and with no evidence read: a terminal runtime disposition cannot change, so there is nothing to re-verify and nothing to write.
  3. A record with no durably authorized attempt has nothing to settle. The terminal outcome is keyed by an attempt, so evidence about a command that never had one would be evidence about nothing.
  4. Refuse a settling residency STRICTLY BELOW the claim's high-water mark, with InboxErrorEpoch on residency_epoch, and do it before the evidence read for the reason the legacy CompleteCommand gives at its own fence: a superseded caller must be told it is superseded, not sent to look for evidence under a lease that no longer exists.
  5. Obtain the evidence through the configured reader, using a request the store derived from its own record, and VERIFY it — before the write.
  6. Compare-and-swap that exact revision to terminal.

It does this for the original holder AND for a successor; the difference between them is in the evidence, not in the caller's assertion. Step 4 does not narrow that: a successor holds a residency ABOVE the claim's by lease monotonicity, so the only caller it turns away is one that was already superseded when the claim was taken and therefore never held this command. The fence is a high-water check on a value the record already carries; it is NOT a liveness check, and reads no lease. See DispositionOutcome's SettlingResidencyEpoch for exactly what the stored epoch is then worth.

It makes no claim that any external side effect happened exactly once.

func (*Store) UpdateCatalogDesiredState

func (s *Store) UpdateCatalogDesiredState(ctx context.Context, req UpdateCatalogDesiredStateRequest) (CatalogEntry, error)

UpdateCatalogDesiredState applies Factory-authored desired state.

IdempotencyKey is checked before the revision, and the order is the contract: a retry of an already-applied write carries an expected revision that its own success invalidated, so comparing the revision first would reject exactly the requests idempotency exists to absorb.

func (*Store) UpdateCatalogHostState

func (s *Store) UpdateCatalogHostState(ctx context.Context, req UpdateCatalogHostStateRequest) (CatalogEntry, error)

UpdateCatalogHostState applies Host-owned fields under the record's fencing epoch.

The stored LeaseEpoch is a high-water mark, not a lock: a request naming a lower epoch is refused outright, and an equal one is admitted because one grant legitimately writes many times. The read-compare-write is closed by the revision compare-and-swap below, so a request that observed a stale epoch cannot land after a successor's write — it loses the CAS and, on re-read, meets the successor's epoch.

type StoreClosedError

type StoreClosedError struct{}

StoreClosedError reports an attempt to admit work after shutdown started.

func (*StoreClosedError) Error

func (*StoreClosedError) Error() string

type UpdateCatalogDesiredStateRequest

type UpdateCatalogDesiredStateRequest struct {
	TenantID               sessionwire.TenantID
	SessionID              sessionwire.SessionID
	ExpectedRevision       uint64
	IdempotencyKey         string
	DesiredPlacement       sessionwire.HostPlacement
	RuntimeCompatibilityID string
	DesiredWorkload        DesiredWorkload
}

UpdateCatalogDesiredStateRequest writes Factory-authored desired state. It deliberately has no lease epoch member: desired state is guarded by revision compare-and-swap plus a retry-stable idempotency key, because Factory does not hold the Host's lease and must not be able to spell a claim on it.

IdempotencyKey names the INTENT, and exactly one key is retained. A request whose key equals the retained one is treated as a replay of that intent and returns the stored record unchanged with a nil error — including when the rest of the request differs. Reusing a key for a NEW intent therefore succeeds without applying anything, so a caller must mint a fresh key per distinct desired state rather than per retry batch.

type UpdateCatalogHostStateRequest

type UpdateCatalogHostStateRequest struct {
	TenantID       sessionwire.TenantID
	SessionID      sessionwire.SessionID
	LeaseEpoch     uint64
	State          sessionwire.SessionState
	Residency      sessionwire.SessionResidency
	LastActiveAt   time.Time
	LastJournalSeq uint64
	LastEventID    sessionwire.EventID
	Checkpoint     CheckpointSummary
	OpenGates      []sessionwire.GateProjection
}

UpdateCatalogHostStateRequest writes the fields owned by the Host holding the session's journal lease. LeaseEpoch is that grant's epoch and is compared against the record's committed high-water mark.

Every field here REPLACES its stored counterpart; nothing is merged. A write that omits Checkpoint zeroes the stored checkpoint summary, and a write that omits OpenGates clears the stored gate projections. That is deliberate and is why the catalog holds a *summary*: the authoritative, retained high-water checkpoint pointer is a separate epoch-fenced record, so clearing a summary here loses no durable state. Callers therefore send the complete current projection on every write rather than a delta.

LastJournalSeq is the one exception, and its asymmetry is intentional: the journal is append-only and a successor fence commits above its predecessor's tip, so a durable sequence never moves backwards and a regressing one is refused rather than stored.

Directories

Path Synopsis
internal
modfiles
Package modfiles enumerates Go source files owned by this module while excluding structural directories and nested repository or module boundaries.
Package modfiles enumerates Go source files owned by this module while excluding structural directories and nested repository or module boundaries.
pathutil
Package pathutil provides filesystem-path normalization for SessionStore composition helpers.
Package pathutil provides filesystem-path normalization for SessionStore composition helpers.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL