Infrastructure

Writing a session store

The ownership rule every SessionLifecycle store must hold, and the shared battery that checks it — runSessionLifecycleConformance, 14 cases, three deliberately distinct non-pass outcomes, and no way to make a case quietly disappear. For anyone implementing the port over Postgres, DynamoDB, SQL Server or anything else.

A defect that lives in a PORT is invisible to every adapter's own tests at once. Each store was tested against its own doubles, so each suite agreed with the same wrong idea of what the port meant. Four stores, four green suites, one live bug.

This page is for the person implementing SessionLifecycle over something this package does not ship — Postgres, SQL Server, DynamoDB, your own service. It has two halves, and they are one subject: the ownership rule a store has to hold, and the battery that checks whether it does.

Why the battery exists

In August 2026 a live trial against a real backend reproduced a split-brain ownership bug. Two writers signed the same fresh session concurrently. The write-once rule kept the first writer in the owner index and stored the second writer's entire conversation — and a stored conversation carries its own identity. The first writer listed the session, opened it, and read the second writer's conversation.

It was then found, by inspection, in every store implementing the ownership index: the in-memory one, the file-backed one, and both service-backed ones. All four had tests. None of those tests could see it, because every store was tested against its own doubles, so a flaw in the PORT's semantics was invisible to all of them simultaneously.

A per-adapter test can only ever check that an adapter matches the author's reading of the contract. When the reading is what is wrong, four suites just agree with each other. So the laws moved out of the four suites and into one battery, written against the port and run against every store — including stores nobody here has written. It would have failed four adapters on the same day.

Running it

One call, one report:

import {
  ,
  ,
  ,
} from 'agentfootprint/hosting';
import type { SessionStoreHarness } from 'agentfootprint/hosting';

const : SessionStoreHarness = {
  : 'memorySessions',
  : () => (),
  // Replace one session's stored bytes with something unreadable, behind the
  // store's back. There is no portable way to do this, so it is your job.
  : (, ) => void .(, { : 'an envelope' } as never),
};

const  = await ();
if (!.) throw new (());

SessionStoreHarness is the whole input — { name, createStore, disposeStore?, corrupt?, declared? }. SessionLifecycleReport is the whole output: the store name, one SessionLifecycleOutcome per case, the four counts (passed, notApplicable, declared, failed) and ok, which is true when nothing failed. formatConformanceReport(report) renders it as one line per case — the thing to put in a failure message or a log:

SessionLifecycle conformance — memorySessions: 12 passed, 0 declared, 1 n/a, 0 FAILED
  ok        absent-session-hydrates-undefined
  ok        persist-hydrate-round-trip
  ok        unreadable-is-not-absent
  n/a       forget-removes-the-conversation — no forget() on this store
  ok        ownership-is-derived-from-the-envelope
  ok        ownership-fills-in-on-a-later-signed-turn
  ok        ownership-survives-a-leaner-turn
  ok        ownership-is-not-taken-by-a-different-signer
  ok        contested-write-leaves-no-split-brain
  ok        owner-of-is-undefined-for-missing-and-unowned
  ok        list-by-user-pages-with-a-stable-tie-break
  ok        awkward-session-ids-round-trip
  ok        optional-members-are-feature-detected

`ok` means nothing failed — not that everything passed

A store with a stated limitation is conformant with limits, which is a different claim from unqualified conformance, and the report prints both. Read the counts, not just the boolean.

One it() per case

A battery that fails as one blob tells you a store is broken and not which promise it broke. sessionLifecycleConformance is an ordinary iterable array of SessionLifecycleCase, so any framework gets one assertion per case:

for (const  of ) {
  (., async () => {
    const  = await (, );
    (.)..('failed');
  });
}

runSessionLifecycleCase(testCase, harness) builds a store, runs one case, disposes the store, and hands back that case's SessionLifecycleOutcome. runSessionLifecycleConformance is exactly a loop over it.

Nothing in the battery imports a test framework. A case throws to fail — the one convention every runner in every language already agrees on — so it works under vitest, jest, node:test, or a plain script with no runner at all. Each SessionLifecycleCase also carries a law: the promise it holds, in one sentence, printed beside a failure so the output reads as a broken promise rather than as a broken assertion.

The harness is a factory, not a store

createStore() is called once per case, and disposeStore(store) after it, including after a case that failed.

That shape is not a style preference. Most of the battery needs a store with nothing in it — a listing case that saw another case's rows would be asserting on somebody else's fixtures — and a store that has been closed, or whose file was removed, cannot be reset in place. One store per case, disposed after it, is the only shape that holds for a Map, a file, a transaction and a managed service at once. createStore() may be sync or async, because some stores open a file and some await a connection, and a battery that demanded one shape would exclude half the stores it exists to check.

fieldfor
namewhat the store is called in the report
createStore()a fresh, empty store. Sync or async
disposeStore(store)release what createStore acquired. Called even when the case failed — a store left open by a failing case is a handle leak that surfaces three cases later as a confusing second failure
corrupt(store, sessionId)replace one session's stored bytes with something that is not a readable envelope, behind the store's back. A poke at the table, the file or the document — so it cannot be portable, and it is the harness's job
declaredcases this store cannot satisfy, by name, each with the reason

ConformanceKit is what a case is handed beside the store: id(suffix) for a session id nothing else in the run uses, envelope(text, principal?, savedAt?) for a valid stored conversation optionally signed by somebody, and harness itself for the cases that need one of its hooks. You only touch it if you write a case of your own.

Three ways a case does not pass, and they mean different things

The runner owns exactly three decisions, and all three are about the ways a case can not run. SessionLifecycleOutcome is a discriminated union on status:

statuswhat it meanscarries
'passed'the store holds the law
'not-applicable'the case is about an optional port member this store does not implement. Feature detection, which is the port's own rule applied to its own battery — a key/value store owes nobody a secondary indexmissing, a SessionStoreMember
'declared'the store implements the member and still cannot satisfy the case, and said so by name with a reasonreason, and stillFails
'failed'the store broke the law — or the case needed a harness hook nobody supplied and nobody declarederror

Three properties are worth stating plainly, because they are what a reader is deciding whether to trust.

A declared case still RUNS. The declaration suppresses the failure, not the execution. If the case turns out to pass, the outcome comes back with stillFails: false and the formatter prints [STALE: it passes now]. A suppression nobody revisits is how a fixed defect keeps its exemption and a real one inherits it later.

"Needed a hook nobody gave me" is a FAILURE, not a skip. Drop the corrupt hook from the example above and the report says so, with the fix in the message:

SessionLifecycle conformance — memorySessions (no corrupt hook): 11 passed, 0 declared, 1 n/a, 1 FAILED
  ok        persist-hydrate-round-trip
  FAILED    unreadable-is-not-absent
            law: A stored conversation this runtime cannot read is never answered as absent.
            [conformance] 'unreadable-is-not-absent' needs harness.corrupt() and this harness has
            none — so the case did not run, and a case that did not run must never look like one
            that passed. Supply the hook, or DECLARE this case by name with the reason your store
            cannot be corrupted from outside:
              declared: { 'unreadable-is-not-absent': 'why not' }

An undeclared skip is a pass with the evidence removed — the same shape as the defect the whole suite exists to catch.

There is deliberately no way to make a case quietly disappear, and that rule is itself a test: the in-tree suite asserts that a harness with no corrupt hook and no declaration comes back 'failed', so the property cannot be regressed without a red build.

SessionLifecycleCaseName is a closed union of the 14 names, which is what makes declared safe. A declaration keyed by a free-form string would keep suppressing nothing at all after a rename — the same shape as the bug this suite exists to catch.

The 14 cases

casewhat it proves
absent-session-hydrates-undefinedonly a session that was never written hydrates as undefined. Anything else and the composer starts a conversation on top of a value it did not understand
persist-hydrate-round-tripformat, savedAt, what was said and who signed it all survive; and a second persist replaces the first, because every turn after the first is a second persist
unreadable-is-not-absentbytes that are present and unreadable are refused or handed back — never answered as undefined, which would be a fresh start over a conversation that exists. Needs corrupt
forget-removes-the-conversationdeletion deletes, the owner index included, and forgetting twice is not an error. Needs a forget member
ownership-is-derived-from-the-envelopethe owner comes from the stored conversation's own principal, never from a field on the request. Plants a decoy owner / userId on the envelope and checks the store ignores both
ownership-fills-in-on-a-later-signed-turna conversation that ran anonymously and is then signed for gains that owner — and an anonymous one is not given an invented one
ownership-survives-a-leaner-turna later turn carrying no identity is accepted, is stored, does not erase the owner, and does not drop the session out of that owner's listing
ownership-is-not-taken-by-a-different-signera foreign signer is refused whole — index and stored conversation unchanged — and the refusal names no principal, no owner and no conversation text
contested-write-leaves-no-split-braintwo concurrent writers on one fresh session: at least one succeeds, the index and the stored conversation never name different people, and the conversation that survived is one writer's whole conversation rather than a document nobody wrote
owner-of-is-undefined-for-missing-and-unowned"no such session" and "nobody signed for it" are one answer, or a caller has an oracle for which session ids are real
list-by-user-pages-with-a-stable-tie-breakevery owned row exactly once, nobody else's ever, a cursor only when more rows exist, newest first — with five sessions sharing one savedAt, so the tie-break carries the paging rather than the timestamp
awkward-session-ids-round-tripan id is an opaque string — slashes, spaces, .., unicode, quotes, 1000 characters — and whatever mapping a store uses to make one legal for its backend is injective, so a/b and a%2Fb stay two conversations
retention-says-who-deletes-and-deletes-only-the-oldretention() answers with a deletedBy a caller can branch on. Where the store itself deletes, a sweep forgets everything strictly older than the cutoff, keeps the session written exactly on it, takes the owner index and the listing with it, is idempotent, and honours a limit with more. Where the backend deletes, the answer names the field it acts on and the one step that turns it on. Needs a retention member
optional-members-are-feature-detectedonWake, listByUser, ownerOf and retention are present as functions or absent, never something a caller has to guess about; a store that can listByUser can also ownerOf, or a door hands somebody a list and then refuses to open any of it; and a store that says it does its own deleting really hands back a sweep

Nine of the fourteen are about an optional member and report 'not-applicable' when the store does not have it: one needs forget, one needs listByUser, one needs retention, and the six ownership cases need ownerOf. SessionStoreMember is that set — 'listByUser' | 'ownerOf' | 'forget' | 'retention'. The remaining five hold for every store, because they are about the two members the port actually requires.

The one declaration that exists today

Across all six in-tree harnesses, exactly one:

declared: {
  'ownership-fills-in-on-a-later-signed-turn':
    'the service pins userId at create and refuses to change it, so a conversation that ' +
    'ran anonymously cannot be moved into somebody’s index on a later turn',
}

That is agentEngineSessions. Its backing service requires a userId when a session is created and refuses to change it afterwards, so a conversation that ran anonymously on turn one stays under the anonymous placeholder in the service-side index forever. Its real owner is recorded — in the envelope, where persist reads it to refuse a foreign signer — but the index that ownerOf and listByUser answer from cannot be moved. Such a session appears in nobody's list until it is written under a new id.

That is a ceiling of the backing service, not a defect and not a gap in the battery. It is written down so it can be argued with, and the case still runs against it every build.

A missing runtime is not a store failing a law

sqliteSessions needs node:sqlite, which ships from Node 22.5; this package still supports Node 20. On a runtime without it the store cannot be constructed at all, so there is no store to ask, and declared would be the wrong tool — a declaration answers a question about the port. The battery is therefore skipped whole for that harness, and visibly, so a runner reports it as skipped rather than as quietly absent. The distinction is the same one the three non-pass outcomes are built on: a case that did not run must never look like one that passed.

The rule the battery is checking — session ownership

An owner is a fact about the conversation, established by the first turn that signs for it. resolveSessionOwner(sessionId, storedOwner, incomingOwner) is the one implementation of that rule, exported from agentfootprint/hosting and called by every shipped store:

storedincomingoutcome
nobodynobodystill nobody — an anonymous conversation
nobodysomebodyfilled in — the first turn that signs, owns
somebodynobodykept — a leaner turn erases nothing
somebodythe samekept — the ordinary turn
somebodydifferentrefusedSessionOwnershipConflictError
// Inside your persist, INSIDE whatever makes read-then-write atomic for you.
const  = (, .(), ());

// Only now — a refusal above must leave the stored conversation alone.
.(, );
if ( !== ) .(, );

Empty strings read as "nobody" on both sides, matching envelopeOwner, so a store that keeps '' for an unowned row cannot accidentally turn it into a principal that conflicts with everybody.

It is a function rather than a base class because stores share no ancestry and should not start now: one is a Map, one is a SQL upsert, one is a distributed transaction, one is a managed service. What they share is a decision, so what they share is a decision function.

Atomicity is yours, and cannot be this function's

A read-then-decide is only as safe as the transaction it happens inside, and only your store knows what it has. resolveSessionOwner owns the answer; where it is safe to ask is your store's own promise — and contested-write-leaves-no-split-brain is the case that checks you kept it.

What SessionOwnershipConflictError means

A persist was about to leave the ownership index naming one person and the stored conversation naming another. It is refused whole: neither the index nor the envelope changes. A store that catches this and writes the payload anyway has re-created the exact defect — the refusal exists to stop the conversation being replaced, not merely to protect an index column.

The error carries code: 'ERR_SESSION_OWNERSHIP_CONFLICT' and the sessionId, and nothing else: not the owner, not the caller, not a line of either conversation. An error is read by whoever provoked it, and a refusal that names the person who owns a session is an oracle for who is signed in. The session id is there because it is the caller's own string, which is the one thing the message teaches nobody anything by repeating.

When it does NOT fire

A leaner turn is accepted. A turn carrying no identity claims nobody, so it contradicts nobody; the port blesses that write in as many words, and the established owner stands. Refusing it would fail a flow the library's own documentation describes as intended, in order to protect against a claim that was never made. On top of that the composer cannot even produce the divergent state: identity is inherited through continueFrom, so a continued conversation carries the identity it started with.

Behaviour change: a persist that used to succeed now refuses

Stated plainly, because a deployer has to read it. A second signer writing onto somebody else's owned session used to store its conversation; it now throws.

It cannot fire at a verifying door — standingAgent with identity: { verify } refuses a foreign turn before the store is reached at all. It can fire at a header-trust door with no verifier, which is exactly the configuration that produced the split brain. Note what that costs even now: at such a door the model has already been given the prior conversation by the time the write refuses. The refusal stops the store being corrupted; a verifier is what stops a stranger reading the conversation in the first place.

Which of the two says who owns a session

Two things can answer "whose session is this", and it is worth knowing which answers where.

  • The index (ownerOf) is the authority for listing and for the session-history ops. { op: 'session-transcript' } asks it, and nothing re-derives an answer behind it.
  • An ordinary turn does not ask it. It re-derives from the envelope it has just hydrated — envelopeOwner(stored), the same derivation, one round-trip cheaper — which is what keeps a store with no index protected exactly as well as one with a fast one.

Those two are safe to treat as one answer only because they can no longer name different people, and the refusal above is what buys that. Before it, they could, and that was the bug.

They can still differ in one direction, and this is a real asymmetry rather than a bug to be surprised by: an index that names somebody over a conversation that names nobody, which is what a leaner turn leaves behind. That state only ever refuses — the owner can still list and read the session, and a turn on it is refused at any verifying door, for everybody, because the envelope names nobody to match against.

Retention — how a conversation stops existing (9.42.0)

Every deployment that keeps conversations eventually has to answer "for how long?", and until 9.42.0 this port had nothing to say. forget lived on the concrete store types rather than on the port, no store could express a policy, and the honest consequence was a library whose default was forever.

retention() is the answer, and it is optional and feature-detected, exactly like listByUser and ownerOf. The port's rule has not moved:

Anything a real store also wants — a TTL, a scan, a delete — is that store's own API, not a demand this port makes of every store that will ever implement it.

An optional member sits inside that rule. A store that implements nothing new is unaffected, and a caller that asks for retention it does not have is refused by name rather than told nothing is wrong.

Two arms, because there are two honest implementations

import { sessionRetention } from 'agentfootprint/hosting';

const policy = sessionRetention(sessions, 'the nightly retention job');

if (policy.deletedBy === 'this-store') {
  let more = true;
  while (more) ({ more } = await policy.forgetOlderThan(Date.now() - THIRTY_DAYS));
} else {
  // Nothing to run. The backend expires them, on a policy you configure once.
  console.log(policy.active ? 'expiry is armed' : policy.enableWith);
}

SessionRetention is a union discriminated on deletedBy, so one feature check is followed by one branch the compiler makes you write:

armdeletedByshapefor
SessionSweep'this-store'forgetOlderThan(before, options?)SessionSweepResultstores that hold their own bytes — a map, a table, a file. Your cron decides when
SessionExpiryPolicy'the-backend'active, expiresOn, enableWithstores whose service already expires rows on a policy an operator configured. There is deliberately nothing to call

A single verb would have been the wrong shape. sweep(before) alone is not implementable by a managed store without a query plus one billed delete per row, duplicating a job the service does for free — and a store that answered 0 deleted from a backend that deletes plenty would be lying by omission. A policy object alone deletes nothing. So the member answers both, and deletedBy says which you are holding.

The shape deliberately not chosen is a stated expiry at persist time — persist(id, envelope, { expiresAt }). It reads well and it is wrong twice: it puts a demand on the one method every store must implement, and it cannot be feature-detected, because typeof store.persist === 'function' is true whether or not the third argument is read. A store that ignored it would keep every conversation forever while its caller believed retention was configured. An optional member that is absent refuses; an optional argument that is ignored does not.

The sweep's law

forgetOlderThan(before) forgets sessions whose stored savedAt is strictly before before, in epoch milliseconds — so a conversation written exactly on the cutoff survives, and passing the same cutoff twice is stable. The clock is the envelope's own savedAt, the same value the listing sorts on, never the store's wall clock: the same cutoff over the same rows forgets the same conversations, in a test and at 3am. It takes the owner index with it, or a listing points at conversations nobody can open.

SessionSweepOptions.limit bounds the batch (DEFAULT_SWEEP_LIMIT, 1000, when absent) and SessionSweepResult is { forgotten, more }more is what a caller loops on. The bound exists because the first sweep after retention is switched on is the biggest one that store will ever do, and it is the one most likely to hold a write lock long enough for every request behind it to notice.

Nothing sweeps on a timer of its own. A library that started an interval would own your process's lifetime, and a deletion nobody asked for at a time nobody chose is the one deletion you cannot explain afterwards. Retention is also not a wire op: no door in this package deletes conversations on request.

Which store does which

storearmwhat expires it
memorySessions()sweepyou call it; a restart forgets everything anyway, but this is where a retention job gets written
sqliteSessions({ file })sweepone BEGIN IMMEDIATE transaction, oldest first, so a bounded sweep drains a backlog from the far end
firestoreSessions()backenda native TTL policy on expiresAt — see Google Cloud
agentEngineSessions()backendthe service's own ttl, pinned at create, floor 24 hours
agentCoreSessions({ store: 'session-storage' })sweepit owns the JSON file, so it can trim it
agentCoreSessions({ store: 'memory' })noneit appends events to a service and has no delete on its surface. sessionRetention() refuses by name rather than reporting a sweep that would delete nothing

That last row is the point of making the member optional. Five of six can answer; the sixth says so out loud.

The refusal

SessionRetentionUnavailableError (ERR_SESSION_RETENTION_UNAVAILABLE) names the job you were doing, what is missing, that this is a limitation of the store rather than an empty result, and where to get a store that can. It carries no identity material — retention is about a store, and a refusal that named a user would be an oracle for who is signed in.

The reason sessionRetention(store) exists at all, rather than store.retention?.(), is that ?.() answers undefined — and a cleanup job whose call returned without complaining is indistinguishable from one that is working. The difference surfaces the day somebody asks how long conversations are kept, and by then the answer is all of them, since the beginning.

The identity a store reads is not the identity memory keys on

Both are called identity and they are different things. The principal on a stored conversation is what this page's ownership rule is derived from. The { tenant, principal, conversationId } tuple that addresses memory rows and artifacts is a separate encoding with its own injectivity requirement — see the identity namespace.

Symbols

SymbolDoorWhat it is
runSessionLifecycleConformance(harness)agentfootprint/hostingRun the whole battery against one store → Promise<SessionLifecycleReport>
runSessionLifecycleCase(testCase, harness)agentfootprint/hostingRun one case, building and disposing the store around it → Promise<SessionLifecycleOutcome>
sessionLifecycleConformanceagentfootprint/hostingThe battery: readonly SessionLifecycleCase[], 14 of them, ordered so a store fails it most usefully (reading before writing, writing before ownership, ownership before the listing built on it)
formatConformanceReport(report)agentfootprint/hostingThe report as a string — one line per case, with the reason a case did not simply pass
SessionStoreHarnessagentfootprint/hostingHow the battery reaches your store: { name, createStore, disposeStore?, corrupt?, declared? }
SessionLifecycleReportagentfootprint/hosting{ store, outcomes, passed, notApplicable, declared, failed, ok }
SessionLifecycleOutcomeagentfootprint/hostingOne case's result — a union on status, carrying missing, reason + stillFails, or error
SessionLifecycleCaseagentfootprint/hostingOne case: name, law, optional members / harnessNeeds, and run(store, kit)
SessionLifecycleCaseNameagentfootprint/hostingThe closed union of the 14 case names — what declared is keyed by
SessionStoreMemberagentfootprint/hosting'listByUser' | 'ownerOf' | 'forget' | 'retention' — the members a case can be not-applicable for
ConformanceKitagentfootprint/hostingWhat a case is handed beside the store: id(), envelope(), harness
sessionRetention(sessions, purpose?)agentfootprint/hostingThe one feature detection for retention()SessionRetention, or refuses by name
SessionRetentionagentfootprint/hostingSessionSweep | SessionExpiryPolicy, discriminated on deletedBy
SessionSweepagentfootprint/hosting{ deletedBy: 'this-store', forgetOlderThan(before, options?) }
SessionExpiryPolicyagentfootprint/hosting{ deletedBy: 'the-backend', active, expiresOn, enableWith }
SessionSweepOptionsagentfootprint/hosting{ limit? } — the batch bound, DEFAULT_SWEEP_LIMIT when absent
SessionSweepResultagentfootprint/hosting{ forgotten, more } — how many went, and whether older ones remain
DEFAULT_SWEEP_LIMITagentfootprint/hosting1000 — how many one sweep forgets when the caller names no limit
SessionRetentionUnavailableErroragentfootprint/hostingERR_SESSION_RETENTION_UNAVAILABLE — this store cannot expire anything, and says so rather than doing nothing
resolveSessionOwner(sessionId, stored, incoming)agentfootprint/hostingThe one owner-transition rule → the owner to record, or throws
SessionOwnershipConflictErroragentfootprint/hostingERR_SESSION_OWNERSHIP_CONFLICT — the two would have named different people

Status

PieceDoorStatus
resolveSessionOwner + SessionOwnershipConflictErroragentfootprint/hostingShipped (9.37.0). Called by every in-tree store that keeps an owner index; the split-brain state it prevents was reproduced live before the fix
sessionLifecycleConformance — the 14 casesagentfootprint/hostingShipped (9.37.0; the retention case 9.42.0). Run against six in-tree harnesses every build — the fifth store, agentCoreSessions, was enrolled in 9.42.0 in both its modes after an audit found it had appeared zero times and its absence was not declared anywhere — one it() per case per store, entirely offline
runSessionLifecycleConformance / runSessionLifecycleCase / formatConformanceReportagentfootprint/hostingShipped (9.37.0) — the out-of-tree entry points
retention() + sessionRetention + SessionRetentionUnavailableErroragentfootprint/hostingShipped (9.42.0). Optional and feature-detected; five of the six in-tree stores implement it, the sixth refuses by name. Tested, not field-validated — no store's retention has been watched deleting anything in production by this repository
The no-silent-skip ruleShipped and executable: a harness missing a needed hook with no declaration is asserted to come back 'failed'
Conformance for the two host portsagentfootprint/hostingSeparate suites, described on Hosting & runtime

Next

On this page