The event log and persistence layer¶
This expands Sections 2.2, 5, 6.8, 6.9, 12.2, 14, 15, 23, and 24 of the engineering plan, and Milestone 2. It does not replace them. Where this document adds a table, a column, or a rule, it is an addition of the kind Section 15 already sanctions when it says to create migrations for at least the tables it lists.
Recorded as ADR-0003, ADR-0004, and ADR-0031. ADR-0050 later extends the schema and append projection for authoritative conversation history and deletion without changing the Milestone 2 guarantees.
Why persistence is not a persistence problem¶
Three specifications written before this one treat the event log as authoritative and say so in almost the same words. Memory formation is "a governed projection over the episodic log." Session history is reconstructed from the log rather than from the previous run's checkpoint (Section 27.4). Working-state carry is "computed from the log, not copied from the previous checkpoint" (context engine).
That is three specs whose correctness is downstream of this one. If the log can lose a write, memory silently forgets. If a projection is not deterministic, re-derivation produces a different agent than the one the user corrected. If a sequence can be consumed out of order, a projection can skip an event forever and no test that reads the log directly will ever notice, because the log is fine — it is the reader's watermark that is wrong.
So the properties this layer owes the rest of the system are not "durability" and "performance." They are:
- Every committed event is eventually observed by every projection, exactly once. Not "written durably" — observed. A durable event that a projection skipped is, to memory, an event that never happened.
- A projection rebuilt from an empty state over the same log prefix equals the projection built incrementally. This is what makes re-derivation meaningful and what makes a projection safe to throw away.
- A checkpoint is an optimization, never a source of truth. Deleting every checkpoint must cost time, not information.
- A run executes at most once, even when the system believes it has crashed and is wrong about that.
The rest of this document is those four sentences made mechanical.
What the persistence layer must respect¶
Inherited constraints, all of which predate this document:
- Never hold a database transaction across external I/O (Section 12.2). The transaction shape is: persist intent, commit, do I/O, persist result, commit. This is why the append path below is designed around many short transactions rather than one correct long one.
- Events are append-only (Section 6.8). Append-only is a statement about bytes, not about rows: a stored payload is never rewritten, including by a migration. See "Versioning without rewriting" below.
- The per-session sequence is monotonic and unique (Section 6.8, Section 15
UNIQUE(session_id, sequence)), and is allocated inside the same short transaction that appends the event (Section 27.5). - Raw reasoning text is never persisted (Section 6.8, ADR-0006). Provider-opaque continuation lives in the checkpoint for the life of an active tool loop and nowhere else.
- Repository methods that read user-owned data require tenant and principal context (Section 6.2). Every query in this document is implicitly scoped; scoping is not optional and is not a ranking input.
- One active run per session by default (Section 27.5). This document shows that the default is load-bearing for projection correctness, and what has to change if it is ever relaxed.
The append path¶
One event, one short transaction¶
Appending an event is a single transaction containing exactly three statements and no I/O. The timestamp assignment below excludes ADR-0167's automatic maintenance.
BEGIN;
UPDATE sessions
SET next_event_sequence = next_event_sequence + 1,
updated_at = CASE WHEN $advances_session_activity THEN GREATEST(updated_at, $event_created_at) ELSE updated_at END
WHERE id = $session_id
RETURNING next_event_sequence - 1 AS sequence;
INSERT INTO events (session_id, run_id, sequence, event_type,
payload_schema_version, actor_type, actor_id,
payload, trace_id, created_at)
VALUES (...);
-- optional, same transaction: the state change this event describes
UPDATE runs SET status = $new, updated_at = now()
WHERE id = $run_id AND status = $expected AND lease_owner = $worker
AND lease_epoch = $epoch;
COMMIT;
Section 27.5 offers two mechanisms for sequence allocation, SELECT ... FOR
UPDATE on the session row or an atomic increment of next_event_sequence. This
document pins the atomic increment. Both take the same row lock, and both hold
it until the transaction ends: PostgreSQL releases a row lock at COMMIT or
ROLLBACK, never at the end of the statement that acquired it. The increment is
pinned for a different reason — it is a single atomic read-modify-write with no
application round-trip inside the lock, which is what makes the "no I/O" rule
above checkable by reading the transaction rather than by reasoning about it.
UNIQUE(session_id, sequence) remains the backstop, and a violation of it is a
defect to be fixed rather than a conflict to be retried.
That the lock is held to the end of the transaction is load-bearing rather than incidental: it is what serializes appends within a session, and "Gaps are normal; missing writes are not" below rests on it.
The state change that an event describes belongs in the same transaction as the
event. An event that says run.completed while the runs row still says
RUNNING is a lie the log tells forever.
For activity-bearing events (ADR-0167), the same statement advances
sessions.updated_at to the persisted event's timestamp. ADR-0050 makes that field the authoritative conversation-history
sort key, so activity ordering is a projection of committed events rather than
client selection or a later best-effort write. A transaction that rolls back
the event also rolls back the timestamp.
The guard by itself does not prevent that lie, and it is worth being exact about
why. The state change is a conditional UPDATE guarded by expected status and
lease ownership, so a writer that has lost the race matches zero rows — but a
zero-row UPDATE is not an error and aborts nothing. The sequence allocation
and the INSERT would still commit, and the log would carry precisely the
statement this section forbids. The writer therefore inspects the affected-row
count of the guarded UPDATE before COMMIT, and rolls the transaction back
when that count is zero, surrendering the sequence to the gap rule below. This
is the same "zero rows updated means stop, not retry" discipline that
lease_epoch fencing imposes everywhere else.
An append that carries no state change has no guarded UPDATE to inspect and is
not subject to the check. The diagnostic run.fenced is the case in point: a
fenced worker performs no transition, no lease release, and no checkpoint write,
and appends exactly one event, which must commit (runtime-loop.md:904-913).
Gaps are normal; missing writes are not¶
A transaction can consume a sequence and then roll back. The sequence is not
returned to the pool, so the log contains a gap. This is fine, and every
consumer must be built to tolerate it: a reader asks for events after a
watermark; it never waits for a specific next sequence to appear. A projection
that blocks until N+1 materializes will hang forever on the first rolled-back
append.
The dangerous case is the mirror image, and it is subtle enough to be worth stating as a scenario rather than a rule. It presumes that two appends to one session can commit out of allocation order; the defences below are the reason they cannot, and the first of them is easy to credit to the wrong mechanism.
Two transactions are appending to the same session. Transaction A takes sequence
5. Transaction B takes sequence 6. B commits at 10:00:00.100. A commits at
10:00:00.180. A projection polls at 10:00:00.150, sees sequence 6 and not 5,
processes 6, and stores watermark 6. Event 5 commits thirty milliseconds later
and is never observed by that projection again, because the projection only
ever asks for sequences greater than its watermark. The log is perfectly
correct. The UNIQUE constraint is satisfied. Memory has silently lost a fact,
and it will be lost identically on every rebuild, so the
rebuild-equals-incremental check passes too.
There are two defences and the system already has the stronger one:
- Serialized allocation. Every event writer allocates its sequence with the
UPDATE sessionsshown above, inside the same transaction that inserts the event. That statement takes the session row lock and holds it until the transaction ends, so a second writer blocks on it until the first commits or rolls back. Sequences therefore commit in allocation order and the interleaving above cannot arise. This is the primary defence, and the discipline it rests on is the one ADR-0003 already records: allocation happens inside the appending transaction, never ahead of it. - Snapshot-aware watermarking, if that discipline is ever broken. A
projection may only advance its watermark past sequences whose transactions
are visible to every future snapshot — in PostgreSQL, those with
xminbelowpg_snapshot_xmin(pg_current_snapshot()). Concretely, the projection reads events after its watermark and below that horizon, and leaves the rest for the next poll.
Two mistaken readings of defence 1 are worth closing off, because both are easy
to arrive at. The first is that the partial unique index provides it: UNIQUE
INDEX (session_id) WHERE status NOT IN (...) constrains the runs table to one
non-terminal run per session, which is a statement about runs and not about
appenders. The second is that a session therefore has only one appender. It does
not — the submit handler appends the user message from its own transaction,
alongside the run insert (http-api-and-streaming.md:935), while a worker may
be appending to the same session. That is safe, and it is safe because both
writers allocate the same way, not because either the index or the one-active-run
default forbids the concurrency.
The rule for implementers: the hazard returns the moment a writer takes a sequence outside the transaction that inserts its event — by reserving a number, performing I/O, and inserting afterwards, say. Such a writer does not hold the session row lock across its own append, commit order stops matching allocation order, and defence 2 becomes mandatory, changing every projection's cursor logic in the same commit. Hard gate 1 asserts defence 1, and asserts it within a single session and not only across sessions, because one session is where the two appenders meet. Section 27.5's one-active-run default remains in force for contention and for the run model, and if parallel branches are wanted later it already directs them to separate sessions or child runs.
Notification is a hint, never a delivery¶
ADR-0010 makes PostgreSQL LISTEN/NOTIFY the transport for worker wakeup and
for live stream delivery. NOTIFY is transactional — payloads are delivered at
commit and discarded on rollback — so it does not need an outbox table to avoid
announcing events that never happened.
It does need a rule, because it is at-most-once. A listener that is disconnected, still connecting, or slow enough to overflow its queue misses the notification permanently, and nothing retries it.
No consumer may depend on receiving a notification. Every consumer is a poller that a notification makes faster:
- The worker polls the claim query on an interval (250 ms interactive) and
NOTIFYcollapses that latency toward zero. If notifications stop entirely, throughput degrades to the poll interval and nothing is lost. - Projections advance from their watermark on a timer, notified or not.
- SSE clients reconnect with
Last-Event-IDand receive the persisted gap before the live stream resumes (Section 16), so a missed notification costs a reconnect, not a message.
The failure this rule prevents is the one where everything works in development and a production connection blip silently drops a turn's worth of memory formation, because the only thing that would have triggered it was a notification nobody received.
Versioning without rewriting¶
Section 6.8 requires payload_schema_version on the envelope and an explicit
upcasting step in the read path. Section 15's events table does not currently
carry the column; Milestone 2 requires it, so the migration that creates the
table includes it as SMALLINT NOT NULL.
The read path is a registry of pure upcasters keyed by (event_type,
from_version), chained from the stored version to the current one:
class Upcaster(Protocol):
event_type: str
from_version: int
to_version: int
def upcast(self, payload: dict[str, Any]) -> dict[str, Any]: ...
Four rules make this survivable:
- Stored payloads are never rewritten. There is no data migration for events, only upcasters. A migration that rewrites payloads destroys the ability to replay history as it actually was, which is the only reason to keep an append-only log in the first place.
- Upcasters are total and pure. No I/O, no clock, no lookups against current state. An upcaster that reads today's configuration produces a different history every time it runs.
- An upcaster may never invent a value. If a field did not exist at version
1, the upcast fills an explicit
Noneor a typedUnknownsentinel and every consumer handles it. Filling a plausible default is how a projection comes to assert something the user never said — and memory formation will happily turn that into a belief with full provenance pointing at an event that never contained it. - An unknown higher version is an error, not a best-effort decode. A reader running old code against a log written by new code must fail loudly. Silent partial decoding of a newer payload is indistinguishable from data loss.
Every historical version keeps a recorded fixture in the eval corpus, and the upcaster chain is exercised against all of them on every build. The gate is totality: every fixture at every version decodes to the current shape.
Projections¶
A projection is a derived, rebuildable read model with a watermark and no authority of its own. Three exist:
| Projection | Source | Consumer | Rebuild cost |
|---|---|---|---|
| Session history | session.*, user.message.*, assistant.message.*, tool.call.* |
Context builder at run seed (Section 27.4) | Per session, bounded by session length |
| Memory | Episodic events at session boundary (ADR-0018) | Retrieval, snapshot assembly (ADR-0019) | Global, expensive, opt-in per principal |
| Trajectory export | Whole runs (Section 31) | Eval fixtures, training data | Per run, cheap |
The properties every projection has¶
Deterministic. The same log prefix produces the same projection state, byte
for byte, on the same builder_version. This is not an aesthetic preference: it
is the precondition for ADR-0018's re-derivation, which replays rejections over
a rebuilt memory and expects the corrections to still hold. A projection that
consults the wall clock, iterates a set in hash order, or reads current
configuration is not rebuildable, and the agent's memory becomes a function of
when it was last rebuilt.
Watermarked. Each projection stores its position as (projection_name,
scope, watermark_sequence, builder_version, updated_at), written in the same
transaction as the projection state it justifies. If the state and the
watermark can diverge, a crash between them either double-applies or skips.
Rebuildable from zero. Rebuild is a first-class, tested operation, not a recovery script someone writes during an incident. CI rebuilds every projection over a synthetic log and asserts equality with the incrementally built state.
Never authoritative. A projection may be dropped and rebuilt at any time without user-visible loss beyond the rebuild's duration. Anything that cannot survive that is not a projection and belongs in the log.
Derived events and the rebuild loop¶
Memory formation both consumes the log and emits events onto it —
memory.formed, memory.superseded, memory.promoted. Naively this makes
rebuild non-idempotent: replaying the log re-runs formation, which appends a
second memory.formed for the same fact, which the next rebuild replays again.
Derived events therefore carry a deterministic derivation key: a hash of the
contributing source_event_ids, the rule that fired, and the builder_version.
Appends of derived events are conditional on that key being absent
(ON CONFLICT DO NOTHING against a unique index). A rebuild re-derives the same
keys and writes nothing new, so it converges instead of multiplying. This is the
same identity discipline ADR-0018 already adopted when it matched rejections by
content rather than by belief id, for the same reason: re-derivation mints new
ids, so ids cannot be the identity.
A consequence worth stating plainly: changing builder_version changes every
derivation key, so a rebuild after a formation-rule change appends a parallel
set of derived events rather than deduplicating against the old ones. That is
correct — they are different derivations — but it means a rule change is a
migration with a cost, and superseding the old derivations is part of shipping
it, not an afterthought.
The trajectory export¶
Section 31 adds a projection that turns finished runs into evaluation
fixtures and training data, and ADR-0016 records the decision. Between them
they fix four acceptance criteria and one exclusion list. Neither states a
format, a redaction procedure, or what "consent-gated" means mechanically,
and evaluation-harness.md has already built the
consuming half against the assumption that this half is redacted before a
converter ever sees it (evaluation-harness.md:1328). This is the
producing half, and it is the third projection in the table above.
The export is an artifact, not a query¶
The projection maintains, per finished run, only what is cheap to maintain
incrementally: the contributing sequence range, the builder_version that
would produce the document, and whether the run has reached a terminal
status. The document itself is materialized once, on demand, into the
artifact store, and every later reader reads the artifact.
Materializing into the artifact store rather than into a table is the whole
of the decision, and it is made for four properties the store already has
and a table would have to grow: content addressing by SHA-256, a key
derived from platform-generated values rather than composed from caller
input (sandbox-isolation.md:1114), an authorized read path that ADR-0028
already puts in front of both metadata and bytes, and expires_at with a
sweeper behind it. Every one of those is load-bearing for a governed
export. A second bytes-holding mechanism inside PostgreSQL would be a worse
version of a thing that already exists, and it would be the version whose
deletion path nobody tested.
ArtifactOrigin gains TRAJECTORY_EXPORT for this
(sandbox-isolation.md:1061). The origin matters because it is the one
whose contents are a function of an entire run rather than of a single act
inside it, and an operator reviewing what a run produced should not have to
infer that from a filename.
Its trust label is the floor of the run. trust is inherited and never
assigned by the producer, and an export flattens platform, user, and
external-untrusted content into one document, so the label the document
carries is the lowest label any contributing span carried. For any run that
called a tool that is EXTERNAL_UNTRUSTED, which is the correct and
slightly uncomfortable answer: an export is a file of recorded text, some
of it written by a system nobody here controls, and anything that reads one
back into a context window must treat it that way.
The format¶
One JSON document, versioned, with the conversation in the shape the
normalized protocol already uses. ADR-0016 says "ShareGPT / messages" and
this picks messages, because the internal protocol is already
messages-shaped and ShareGPT is then a rename of the role vocabulary — a
consumer's transformation, not a producer's obligation.
{
"schema_version": 1,
"export_id": "<uuid7>",
"run_id": "<uuid>",
"tenant_id": "<uuid>",
"agent_id": "research-assistant",
"agent_version": 4,
"outcome": "COMPLETED" | "FAILED" | "CANCELLED",
"failure": {"kind": "...", "at_step": 7} | null,
"recorded_on": "2026-07-28",
"builder_version": "trajectory@5",
"redaction": {
"ruleset_version": "...",
"replacements": {"provider_key": 1, "dsn_password": 2}
},
"messages": [ ... ],
"tools": [ {"name": "...", "schema_sha256": "..."} ]
}
outcome is what makes Section 31.3's fourth criterion — failed runs
captured and labeled distinctly — true by construction rather than by
convention, and failure carries the classification without carrying the
error text, which is the field most likely to have a path, a host, or a
query string in it. Its three values are RunStatus's terminal subset
(runtime-loop.md:276) and not a vocabulary of the export's own, so a
consumer filtering exported runs and a reader querying runs.status ask the
same question in the same words.
recorded_on is a date, not a timestamp, and there are no per-message
timestamps at all. Per-message timing is the highest-entropy correlatable
field an export could carry, no stated consumer needs it — the harness
discards timestamps at conversion (evaluation-harness.md:1305) and a
training corpus has no use for them — and a field that is dropped by every
consumer and re-identifies a user is a field that should not have been
written. tools records the name and schema hash of every tool the run
touched, which is what lets a converter tell a missing fixture from a tool
that has since changed shape.
What is left out, and why each¶
| Left out | Because |
|---|---|
| Reasoning, raw or summarized | ADR-0006; never stored, so never exported |
| Usage, cost, and prices | Not a property of the trajectory; commercially sensitive per tenant |
| Per-message timestamps | Correlatable, and dropped by every consumer |
| Internal identifiers | Event ids, sequences, checkpoint ids; a converter seeds its own |
| Provider metadata | request_id and friends are support correlation, not conversation |
| Checkpoints and queue events | Execution mechanics, not the trajectory |
| Artifact bytes | The export carries an ArtifactRef, never the content |
The reasoning row deserves a sentence because it is the one that looks like an omission and is not. ADR-0006 rejected persisting reasoning with redaction applied, on the grounds that reasoning paraphrases its input and redaction is pattern-based. That reasoning applies to the export with more force, not less: an export is the artifact most likely to leave the system, and a paraphrase is exactly what a pattern cannot catch.
Redaction is a pipeline that ends in a refusal¶
Three stages, in order, and the third is the one that makes the first two trustworthy.
- Structural exclusion. Everything in the table above is dropped by the builder, by construction. No pattern is involved and none could help; these fields are excluded because of what they are, not because of what they contain.
- Pattern replacement. The secret scanner's five rule families
(
bootstrap-and-composition.md:1275-1283) run over every message body, every tool argument, and every tool result, and a match is replaced with[redacted:<rule_name>]. The key-name families the log-redaction processor already uses (development-toolchain.md:153-155) run over structured tool arguments and results, where a value's key is better evidence than the value's shape. A tenant may add patterns; it may not remove one. - Verification. The scanner runs again, over the finished document. A hit here fails the export.
The third stage is not belt-and-braces, it is the contract. Redaction that
reports success is a claim, and a claim with no check behind it decays the
first time a message body acquires a shape stage two does not cover. So the
export fails closed: a verification hit raises ExportRedactionError,
writes no artifact, and reports the rule name and the message index. It
never reports the match, for the reason the scanner already gives — a
report that echoes the secret has moved the secret somewhere worse
(bootstrap-and-composition.md:1288-1290).
Failing rather than repairing is deliberate. A verification hit means stage two has a gap, and silently redacting the same string a second time hides the gap while shipping the artifact. The failure is a defect report with a run attached.
Two things this pipeline cannot do, stated here rather than discovered later. It cannot recognize content that is sensitive because of who it is about: a user describing a third party in prose produces no pattern, and no version of this will. And it cannot recognize a secret with no shape — a password that looks like a word is a word. Policy-restricted PII is therefore a per-tenant declared pattern set that rides in stage two, not a classifier, and the residual is a limit of the mechanism that belongs in whatever a tenant is told before consent is asked for.
Consent is stamped forward and withdrawn backward¶
Section 31.3 requires that export honor tenant scope and per-principal consent. No mechanism for either exists anywhere in the corpus, so this is it.
Two conditions, both required. The tenant must have export enabled, which is operator configuration and covers the deployment that wants none of this. The principal on the run's session must have granted export consent, which is a stored grant with a scope and two timestamps. Neither implies the other: a tenant enabling export does not consent on its users' behalf, and a principal's grant does nothing in a tenant where export is off.
The grant is evaluated when the run starts, and its answer is stamped
onto the run as export_consent. Export reads the stamp. Withdrawal is
evaluated at export time and again by the sweeper, over every run the
principal ever produced.
That asymmetry is the design, and it reduces to one sentence: a grant is a
statement about data the principal has not produced yet, and a withdrawal
is a statement about data they have. So a grant is prospective — it does
not reach back and authorize conversations the principal had before they
were asked, and cannot, because nobody consents meaningfully to the
contents of a conversation they have forgotten. A withdrawal is total. It
blocks export of every run, stamped or not, and it expires every export
artifact already produced from that principal's runs by setting
expires_at to now, after which the artifact sweeper deletes them on its
next pass.
Reusing expires_at rather than writing a deletion path is the point.
Withdrawal is rare, deletion-on-withdrawal is the operation most likely to
be written once and never exercised, and routing it through the sweeper
that already runs every day means the withdrawal path is tested by every
ordinary expiry.
A subagent run inherits its parent's stamp at creation, and an export never descends into child runs. Each run is exported separately or not at all. The alternative — a parent export that inlines its children — makes the redaction surface recursive, makes the consent question ambiguous when a child ran under a different principal, and produces a document whose size is unbounded in a system that otherwise bounds everything.
What consent does not solve: an export of a principal's run contains whatever other people said inside it, by way of tool results and quoted content. The pipeline redacts what has a shape and the export is tenant-scoped, and beyond that this is a governance boundary rather than an engineering one, named here so that nobody reads "consent-gated" as "complete".
Retention, and why promotion is the durable step¶
An export expires like any other artifact — thirty days by default, per the
artifact retention this corpus already sets (sandbox-isolation.md:1178).
It is not special-cased to live longer, and the reason is that the two
things Section 31.2 wants exports for do not actually want a long-lived
export.
An eval fixture wants a case, and agent eval promote already produces
one: a converted case is marked source: trajectory, carries the export
id, and does not enter the blocking suite until a person has read it and
written its assertions. That reviewed case lives in source control under
review, which is where a durable artifact belongs. A training corpus wants
a corpus, assembled deliberately, with its own governance and its own
retention, and it is a consumer of exports rather than a pile of them.
So promotion is the durable step and the export is the perishable one. That is the property that keeps the governance question small: at any moment the set of undeleted exports is roughly the last thirty days of deliberate export commands, not the entire history of the platform.
The commands and the endpoint¶
agent run export <run-id> write the redacted export
agent run export <run-id> --json the ArtifactRef on stdout
export becomes the fourth reserved word after agent run
(bootstrap-and-composition.md:1091), which is cheaper than a thirteenth
top-level command and follows the precedent agent eval's five
subcommands already set. It reuses the existing exit codes without
addition: a refused consent check exits 1, an unknown run exits 2, an
ExportRedactionError exits 1 with the rule name on stderr.
POST /v1/runs/{run_id}/export is the same application service, returns
the ArtifactRef, and is idempotent per run — a second call against a run
whose export exists and has not expired returns the existing reference
rather than rebuilding, because rebuilding under a changed
builder_version would silently hand a caller a different document under
the same run id.
agent eval promote <run-id> requires an export to exist and fails naming
this command when none does. That is the mechanical form of "the converter
consumes the redacted artifact and has no access to the raw log": the
converter cannot trigger production, so there is exactly one path through
which conversation content becomes a file, and exactly one place to audit.
Checkpoints¶
Section 6.9 already identifies the problem: a checkpoint is written after every model response and every tool call, and each stores the full conversation, which is superlinear in run length. It also states the fix. This pins it.
A checkpoint is a delta against a base. Full snapshots are written at run start, at every compaction boundary, and at terminal status. Every other checkpoint stores the changes since the previous checkpoint. Reconstruction walks back to the nearest full snapshot and applies deltas forward; the walk is bounded because compaction forces a full snapshot.
The conversation is stored as event references. A checkpoint holds
event_id references resolved through the session-history projection, not
inlined message bodies. Two exceptions stay inline because they cannot be
reconstructed from the log:
- Provider-opaque continuation (
ProviderContinuation,ProviderReasoningItem). Never in the log by rule (ADR-0006), so the checkpoint is the only place it can live. It is dropped at run boundaries and when a run is routed to a different provider (Section 27.4, ADR-0007). - Compacted summary text. It is authored by a model call, not derived from the log, so replaying the log would not reproduce it. It is content, and it is covered by the context engine's elision rules.
Losing checkpoints costs time, not information. A checkpoint can be lost
after the run has committed more: a crash between two transactions, a lease
that expires before the next write, or a deletion. Restoring the older
checkpoint alone does not recover that state, because the next checkpoint's
reference covers what the earlier attempt committed while the resumed attempt
redoes it. Production run b642e4d7 failed this way on 2026-10-07 with a
duplicate tool pair. So a resumed run adopts the session history committed
after its checkpoint's last_event_sequence before it does anything else
(ADR-0166):
- If nothing adopted is model output, the checkpoint's own batch re-enters the
pipeline at the step its
budget_staterecorded. That step is part of every invocation's idempotency key, so a call that already ran returns its recorded outcome rather than running again, and a result already in the log is not appended twice. - If an adopted turn follows the checkpoint's batch, that turn supersedes it. Only the newest turn's unanswered calls are dispatched, and the restored continuation, which belongs to an older turn, is dropped.
- A batch rebuilt from the model's turn, rather than read from the
checkpoint's pending calls, is fitted to the tool-call budget again, against
the count it was proposed under. A call past
max_tool_callsstill never runs, and only the calls that fit count as usage (runtime-loop.md, "Budget"). model.response.completedrecordstool_call_count_before_turn, an additive accounting watermark. Recovery uses it to count an answered fitted batch exactly once even after all checkpoints are lost. Historical events without it keep their previous accounting fallback.- A recovered turn with no result and no saved pending batch must pass the normal synthesis-reserve and repeated-call guards before dispatch. A batch already pending or returning results is not counted in those guards again.
- If the adopted history ends with the final reply, the run finalizes with it instead of asking the model again.
What is lost is state that only checkpoints hold. A lost checkpoint takes its
active ProviderContinuation with it, the inline exception above, so a turn
adopted from the log has none. Steps after the checkpoint also leave no loop
counters, tool evidence, or loaded skills, so the run repeats that work if it
needs it. This is a test, not a hope:
test_lost_checkpoints_cost_time_not_information deletes the last one through
five checkpoints of an interrupted two-tool run, resumes, and asserts the
uninterrupted run's terminal state, with each call executed once. It sits next
to the kill-the-worker test that Section 14.2 already requires.
Retention. After a run reaches a terminal status, prune to the final checkpoint plus the last full snapshot. Before that, prune deltas older than the most recent full snapshot. Checkpoint bytes per run is a tracked metric because it is the one that quietly grows until an incident.
The run queue¶
Claiming, with priority¶
Section 14.1 specifies FOR UPDATE SKIP LOCKED. The revision summary and
ADR-0010 additionally require priority ordering so asynchronous jobs cannot
head-of-line-block interactive turns; Section 14's body does not yet carry it.
The claim query:
UPDATE runs r
SET status = 'RUNNING',
lease_owner = $worker_id,
lease_epoch = r.lease_epoch + 1,
lease_expires_at= now() + $lease_duration,
attempts = r.attempts + 1,
updated_at = now()
FROM (
SELECT id
FROM runs
WHERE status = 'QUEUED'
AND (scheduled_for IS NULL OR scheduled_for <= now())
AND priority = ANY($eligible_classes)
ORDER BY priority ASC, created_at ASC
FOR UPDATE SKIP LOCKED
LIMIT 1
) AS c
WHERE r.id = c.id
RETURNING r.*;
Three priority classes, low number first:
| Class | Value | Contents | Latency budget |
|---|---|---|---|
| Interactive | 0 | A user is waiting on this turn | Seconds |
| Async | 10 | Scheduled and long-running work (Milestone 11) | Minutes |
| Maintenance | 20 | Consolidation, rebuilds, exports | Hours |
Strict priority starves the bottom of the queue, so capacity is reserved by
class rather than allocated by strict order: a worker pool is configured with
a minimum concurrency per class, and $eligible_classes for a given claim
reflects which of that worker's slots are free. A pool that reserves one slot
for maintenance guarantees consolidation progresses during a busy day, at the
cost of one slot of interactive capacity. Aging was the alternative and it is
worse here — it makes latency depend on queue history, which is exactly the
property that makes a starvation bug reproduce only in production.
The claim query needs (status, priority, created_at) as a partial index on
status = 'QUEUED'; Section 15's (status, created_at) does not serve the
ordering.
Leases, and the worker that is not dead¶
The lease protocol is Section 14.1's: claim, set expiry, refresh periodically, release, and reclaim after expiry. Refresh at one third of the lease duration, so two consecutive missed heartbeats do not lose the run.
The part that needs adding is what happens when the reclaim is wrong. A worker that is garbage-collecting, swapping, or partitioned from the database is not dead, and its lease expires anyway. The sweeper reclaims the run, a second worker starts executing it, and now two processes believe they own the same run. Lease expiry is a timeout, and a timeout is a guess.
The defence is a fencing token. lease_epoch increments on every claim, and
every write a worker makes is conditional on its own epoch:
An UPDATE affecting zero rows means the worker has been fenced. It stops
immediately, does not retry, does not append, and does not treat the zero-row
result as a transient failure — it is the only correct signal that another
worker owns this run now. The same predicate guards the event append's state
change, so a fenced worker cannot write an event either.
This does not prevent a fenced worker from having already begun a side-effecting tool call, which is why fencing is necessary but not sufficient, and why recovery leans on tool-level idempotency below.
Queue-level retry, and what is not a retry¶
Two different things get called retry and they have different rules:
- Step-level retry — a model call or tool call fails and is retried inside
the run, per Section 13's table, within the run deadline, with exponential
backoff and jitter. The run never leaves
RUNNING. - Run-level requeue — a run is returned to
QUEUEDbecause its lease expired and the sweeper reclaimed it.attemptsincrements on claim.
Only lease expiry requeues. A run that fails with a permanent classification
from Section 13 (ModelPermanentError, ToolPolicyDenied, AuthorizationError,
BudgetExceeded) transitions to FAILED immediately with a typed failure
payload; requeueing it would burn attempts against an error that cannot succeed.
max_attempts defaults to 3. A run exceeding it goes to FAILED with
failure.reason = "max_attempts_exceeded" and the accumulated per-attempt
failures retained. There is no separate dead-letter table: runs already has a
failure JSONB column and a terminal status, and a dead-letter queue whose
entries nothing reads is a table that only ever grows.
scheduled_for carries the backoff for a requeued run and doubles as the queue
eligibility primitive Milestone 11 needs, which is why it lands here rather than
being invented twice.
Recovery at a safe boundary¶
On reclaim, Section 14.2's procedure runs against the tool-invocation table (Section 8.4), which is the authority on what actually executed:
- Load the latest checkpoint, reconstruct through the delta chain, and adopt the session history committed after it (see "Losing checkpoints costs time, not information" above).
- Read
tool_invocationsfor the run at or after the checkpoint'slast_event_sequence. - For each invocation not in a terminal state, decide by
IdempotencyClass: read-only and idempotent tools are re-executed; conditionally idempotent tools are re-executed only if the storedidempotency_keycan be replayed against the external service; a non-idempotent tool left inRUNNINGis never automatically retried. - Mark ambiguous non-idempotent executions
UNCERTAIN, emittool.call.uncertain, and surface the run for review rather than guessing. - Resume at the first incomplete safe boundary.
UNCERTAIN is a terminal state for that invocation, not a transient one. The
model is told, in a structured tool result, that the call's outcome is unknown —
which is true, and which is information it can act on — rather than being told
the call failed, which is a claim the system cannot support and which invites a
duplicate write.
Schema additions¶
Section 15 requires migrations for at least the tables it lists. These additions are what Milestone 2's own Implement list and Section 16's contract already require, plus the columns this document's mechanisms need. No existing column is removed or retyped.
events
+ payload_schema_version SMALLINT NOT NULL
-- no new index: a projection scans `session_id = ? AND sequence > watermark`,
-- which Section 15's UNIQUE(session_id, sequence) already serves. An index on
-- (session_id, id) would not support that predicate, and commit order needs
-- no index of its own because allocation inside the appending transaction
-- makes commit order and sequence order the same order.
runs
+ priority SMALLINT NOT NULL DEFAULT 0
+ attempts SMALLINT NOT NULL DEFAULT 0
+ scheduled_for TIMESTAMPTZ NULL
+ lease_epoch INTEGER NOT NULL DEFAULT 0
+ export_consent BOOLEAN NOT NULL DEFAULT FALSE -- at run start
+ INDEX (status, priority, created_at) WHERE status = 'QUEUED'
+ UNIQUE INDEX (session_id) WHERE status NOT IN
('COMPLETED','FAILED','CANCELLED') -- 27.5 single active run
idempotency_keys -- M2 "Idempotency records"
key TEXT PRIMARY KEY -- Section 16 Idempotency-Key
tenant_id UUID NOT NULL
principal_id UUID NOT NULL
request_hash TEXT NOT NULL
run_id UUID NOT NULL
created_at TIMESTAMPTZ NOT NULL
expires_at TIMESTAMPTZ NOT NULL
projection_watermarks
projection_name TEXT NOT NULL
scope TEXT NOT NULL DEFAULT '' -- '' global, else session_id
watermark_seq BIGINT NOT NULL
builder_version TEXT NOT NULL
updated_at TIMESTAMPTZ NOT NULL
PRIMARY KEY (projection_name, scope)
derived_event_keys
derivation_key TEXT PRIMARY KEY -- source ids + rule + version
event_id BIGINT NOT NULL
created_at TIMESTAMPTZ NOT NULL
export_consent -- Section 31.3, per principal
tenant_id UUID NOT NULL
principal_id UUID NOT NULL
granted_at TIMESTAMPTZ NOT NULL
withdrawn_at TIMESTAMPTZ NULL
PRIMARY KEY (tenant_id, principal_id)
trajectory_exports -- one row per exported run
export_id UUID PRIMARY KEY
tenant_id UUID NOT NULL
principal_id UUID NOT NULL -- denormalized from session
run_id UUID NOT NULL
artifact_id UUID NOT NULL
builder_version TEXT NOT NULL
ruleset_version TEXT NOT NULL
created_at TIMESTAMPTZ NOT NULL
UNIQUE (run_id) -- the endpoint's idempotency
INDEX (tenant_id, principal_id) -- the withdrawal sweep
trajectory_exports carries principal_id even though it is reachable
through run_id and session_id, because consent withdrawal is a sweep
over one principal's exports and a two-join sweep on the rarest write path
is how a governance operation acquires a query plan nobody has looked at.
The UNIQUE (run_id) constraint is what makes the export endpoint
idempotent per run at the schema rather than at the service.
idempotency_keys stores request_hash so that a repeated Idempotency-Key
carrying a different body is a ConflictError rather than a silent return of
an unrelated run. Section 16 requires that a repeated key return the original
run; it does not say what a reused key with new content means, and returning
someone else's run because a client reused a key is worse than an error.
Post-Milestone 9 deletion records¶
ADR-0050 adds two small tables. They are not session projections and do not retain conversation content:
session_deletions
session_id UUID PRIMARY KEY
tenant_id TEXT NOT NULL
principal_id TEXT NOT NULL
deleted_at TIMESTAMPTZ NOT NULL
INDEX (tenant_id, principal_id, deleted_at)
session_deletion_artifacts
session_id UUID NOT NULL REFERENCES session_deletions ON DELETE CASCADE
artifact_id UUID NOT NULL
tenant_id TEXT NOT NULL
artifact JSONB NOT NULL
PRIMARY KEY (session_id, artifact_id)
Both tables have forced tenant row-level security. session_deletions is the
content-free ownership tombstone that makes a repeated delete idempotent.
session_deletion_artifacts is a transactional outbox containing the minimum
serialized artifact reference needed to remove bytes from external storage.
The delete request tries every queued reference after commit; the maintenance
worker retries remaining rows and deletes each row only after byte deletion
succeeds. Removing a tombstone cascades its pending queue, although normal
operation retains the tombstone.
Authoring migrations¶
The plan requires migrations in four places. Section 15 says "Create
Alembic migrations for at least these tables". Section 23 rule 16 says
"Add a migration for every schema change". Section 24 makes two of
them conditions of every milestone: migrations upgrade from a clean
database, and migrations upgrade from the previous revision. Section 25
lists "Run migrations" as a step and
development-toolchain.md gives it make
migrate. Between all of that there is a directory name, a Makefile
target, and no statement of what a migration looks like when someone
writes one.
That is a cheap gap to leave and an expensive one to close later, because the first six migrations set the conventions for the rest whether or not anyone decided them.
One head, always¶
The revision graph is linear. Merge revisions are not written, and
alembic merge is not used. Two branches that both descend from the
same revision are resolved by rebasing the later one onto the earlier
before it merges — that is, by changing its down_revision and
re-testing it — not by adding a third revision that joins them.
The reason is that two of the plan's own statements stop being
well-defined otherwise. alembic upgrade head in Section 25 and make
migrate both name a head; with two, the command is ambiguous and
resolves by luck. And "migrations upgrade from the previous
revision" has no referent in a graph where a revision has two
predecessors.
Linearity is a merge-time cost paid by whoever loses the race, which is
the correct place for it, and it is asserted by
gate.structure.migration_graph.
Revision identifiers carry no order, and neither do file names¶
A migration file is named <revision>_<slug>.py, where <revision> is
the hex identifier Alembic generates and <slug> is an imperative
phrase naming the change: a3f19c2b7d04_add_lease_epoch_to_runs.py.
Ordering lives in down_revision and nowhere else. Hand-numbered
prefixes — 0007_, 0008_ — are specifically not used, because two
revisions written the same week both become 0007 in two branches, and
the file listing then shows an order that the graph does not have. A
name that cannot express order cannot express the wrong order.
The slug names the change, not the milestone and not the ticket. A
reader of git log --name-only three years from now is looking for the
migration that added a column, and "m2 schema" does not help them.
Autogenerate drafts; a person writes¶
alembic revision --autogenerate is where a migration starts and never
what gets committed. Every generated revision is read and edited before
it is reviewed, because autogenerate does not see:
- Server defaults and the backfill an existing row needs.
- Partial and conditional indexes. This document requires three of them — the queued-run index, the single-active-run unique index, and the events commit-order index — and a generated migration will produce the unfiltered form of at least the first two.
- Enum value additions, which PostgreSQL applies with
ALTER TYPE ... ADD VALUEand which cannot run inside a transaction block. - Anything about data.
What keeps the edited file honest is not review but
gate.event.migration_clean: after upgrade head against an empty
database, an autogenerate run against the same metadata must produce an
empty diff. A hand-edit that drifts from the mapped tables fails that
gate rather than being discovered by the next person to autogenerate.
Structure and data are separate revisions¶
A revision either changes structure or moves rows. It does not do both.
Three consequences make this worth the extra file. A backfill over a
large table takes minutes or takes a lock, and a structural change
mixed into it cannot ship until the backfill finishes. A structural
change is reversible and a backfill usually is not, so a combined
revision's downgrade() is a partial truth. And the two want different
transaction shapes: structure in one transaction, a backfill in batches
that commit as they go.
The three-step pattern for a column that must end up NOT NULL is a
structural revision that adds it nullable, a data revision that fills
it, and a structural revision that sets the constraint. At Milestone 2
no table has production rows in it and all three could be one
statement; the pattern is written down now because the migration that
needs it is written by someone reading the ones that came before.
Downgrade is written, and it is not an operational promise¶
Every structural revision implements downgrade(). The reason is the
round-trip in gate.event.migration_clean rather than an intention to
run it: a downgrade that was never written is a downgrade that was
never checked, and the check is what catches a revision that creates an
object it does not name.
Rolling a deployed schema backwards is a restore from backup, not a
downgrade, and this document does not pretend otherwise. A data
revision's downgrade() raises NotImplementedError with a sentence
saying what would be lost.
Lock-taking DDL is split, and says so¶
CREATE INDEX CONCURRENTLY cannot run inside a transaction, and neither
can ALTER TYPE ... ADD VALUE. A revision containing either declares
itself non-transactional rather than relying on Alembic's default, and
contains nothing else, so that a failure leaves one incomplete object
and not a half-applied revision.
An index on a populated table is created concurrently. An index on a table created by the same revision is not, because there are no rows and no lock to avoid.
A migration may add to events; it may never rewrite one¶
The failure-modes table above names "History rewritten by
migration" and answers it with immutable payloads and upcasters. It is
repeated here because the person who would write that UPDATE is a
migration author reading migration conventions, and this is the page
they are on. A revision may add a column to events, add an index, or
create a table that reads from it. A revision that writes to
events.payload is a defect, whatever it is trying to fix.
The revision the code expects is a constant¶
ADR-0024 decision 6 fixes the behavior — the composition root never runs migrations, asserts the schema revision, and refuses to start on a mismatch — and leaves open where the expected value comes from. Two answers are available and only one of them is a check.
EXPECTED_REVISION is a module-level string constant in the
persistence adapter, updated by the same commit that adds the
migration. Startup phase 3 reads the single row of alembic_version and
compares. On mismatch it names both revisions and exits non-zero; it
does not migrate, and it does not start in a degraded mode.
The alternative is to read the head out of the migrations directory at
runtime with ScriptDirectory. It is rejected because it makes the
assertion vacuous in the one deployment that matters: the code and its
migrations always ship together, so the computed head always equals the
code's expectation, and the mismatch the assertion exists to catch —
new code against an un-migrated database — is exactly the case where
both sides come from the same image and agree.
A static check asserts that EXPECTED_REVISION equals the single head
of the revision graph, so the constant cannot drift from the
migrations in the repository. That check is part of
gate.structure.migration_graph; the runtime refusal is
gate.event.revision_pinned.
Where the plan's four statements land¶
| Statement | Source | Where it becomes checkable |
|---|---|---|
| Migrations exist for the tables | §15 | The tables are created by revisions, not by create_all |
| A migration for every schema change | §23 rule 16 | gate.event.migration_clean: a mapped table with no revision fails the empty-diff check |
| Upgrade from a clean database | §24 | gate.event.migration_clean |
| Upgrade from the previous revision | §24 | gate.event.migration_stepwise |
create_all deserves its own sentence, because it is the shortcut every
test suite reaches for. Schema in tests is created by running the
migrations, not by metadata.create_all. A suite that creates its
schema the fast way is a suite in which no migration is ever exercised
until deployment, and Section 24's two criteria become statements
nothing evaluates.
Ports and data model¶
Section 7's RunRepository and EventRepository are unchanged. Section 7 names
the checkpoint, session, tool-invocation, and usage repositories without typing
them; these are the additions this layer needs.
class NewEvent(BaseModel):
session_id: UUID
run_id: UUID | None
event_type: str
payload_schema_version: int
actor_type: str
actor_id: str | None
payload: dict[str, Any]
trace_id: str | None
derivation_key: str | None = None # set for derived events only
class CheckpointRepository(Protocol):
async def write(
self, run_id: UUID, checkpoint: RunCheckpoint, *, full: bool
) -> int: ...
async def latest(self, run_id: UUID) -> RunCheckpoint | None: ...
async def prune(self, run_id: UUID, *, terminal: bool) -> int: ...
class ProjectionCursor(BaseModel):
projection_name: str
scope: str
watermark_seq: int
builder_version: str
class Projection(Protocol):
name: str
builder_version: str
async def apply(
self, events: Sequence[EventEnvelope], cursor: ProjectionCursor
) -> None: ...
async def rebuild(self, scope: str) -> ProjectionCursor: ...
class RunQueue(Protocol):
async def enqueue(
self, run: Run, *, priority: int, scheduled_for: datetime | None
) -> None: ...
async def claim(
self, worker_id: str, eligible_classes: Sequence[int]
) -> tuple[Run, int] | None: ... # (run, lease_epoch)
async def heartbeat(
self, run_id: UUID, worker_id: str, lease_epoch: int
) -> bool: ... # False means fenced
async def release(
self,
run_id: UUID,
worker_id: str,
lease_epoch: int,
status: RunStatus,
) -> None: ...
async def reclaim_expired(self, limit: int) -> int: ...
heartbeat returning False rather than raising is deliberate: being fenced is
an expected outcome of a normal race, not an exceptional one, and the worker's
handling of it is to stop cleanly rather than to unwind through error paths that
might themselves try to write.
New event types, added to Section 6.8's list:
The ORM surface¶
Section 2.2 chooses SQLAlchemy's async interface and states the rule that
governs it: "never share one AsyncSession across concurrent tasks. Each
request, worker operation, or parallel tool invocation must receive its own
unit of work and database session." Section 4 names four modules under
adapters/persistence/. Section 7 names the repositories. Rules 1, 7, and
13 constrain what may cross out of them.
What none of that says is what a mapping looks like. That is decided once, by whoever writes the first repository, and then copied into every repository written after it — which is the argument for deciding it here.
The answer below is mostly forced. Two of the dependency rules, read together, eliminate every option but one, and this section is largely the work of showing that.
Row classes are not domain types¶
The tempting shape is to map Run, Session, and the event envelope
directly, so that one class is both the domain object and the row. Both
ways of doing that are ruled out already.
Declarative mapping of a domain type puts from sqlalchemy.orm import
... inside domain, and rule 1 confines domain to the standard library
and Pydantic. This is the version that fails loudly: the import walk that
is a Milestone 0 deliverable rejects it on the first run.
Imperative mapping — registry.map_imperatively(Run, runs_table) —
avoids the import, which is what makes it worth naming rather than
dismissing. It fails a different rule and fails it silently. Mapping
attaches instrumentation to the class: the mapped type gains identity-map
membership, lazy-load behavior on attribute access, and a relationship to
the session that loaded it. The domain object becomes the ORM object.
Rule 7 — "SQLAlchemy ORM objects must never be returned from
repositories" — is then unenforceable, because every repository return
value is one, and the static check ADR-0001 assigns to that rule resolves
signatures, so it has nothing left to reject at the exact moment the
violation is total. Rule 1's own note anticipates this by naming "ORM
modes" among the Pydantic-only behaviors to keep out of the domain.
The runtime agrees with the rules, as it happens: Pydantic's BaseModel
and SQLAlchemy's declarative base carry different metaclasses, so a class
that is both does not exist without writing a third metaclass to reconcile
them.
So row classes are separate types. One declarative class per table, in
adapters/persistence/sqlalchemy_models.py, and no instance of one is ever
returned, yielded, logged, or passed out of that package.
Translation is a function, not a feature¶
Each table gets two translation functions, and they are written by hand:
def to_domain(row: EventRow) -> EventEnvelope: ...
def values(event: NewEvent) -> dict[str, Any]: ...
They live in adapters/persistence/mappers.py, beside the row classes and
inside the same confinement — one module more than Section 4's tree lists,
added because the alternative is translation code scattered through the
repository bodies where nothing can find it.
The direction is asymmetric on purpose. Reading produces a domain object.
Writing produces a dict of column values, which the repository passes to
an insert() or an update(); it does not produce a row object for the
caller to hold, because a row object in a caller's hand is the thing this
whole section exists to prevent.
Three shortcuts are specifically not taken. model_validate with
from_attributes=True is the ORM mode rule 1 excludes, and it works by
attribute access, which on a mapped class is what triggers a lazy load.
A generic field-name-matching mapper turns a column rename into a missing
key at runtime instead of a type error at check time. And returning
Row or RowMapping and letting the caller subscript it puts the schema
in the caller.
The cost is real and worth stating plainly: roughly twenty tables, two
functions each, all of them boring, all of them needing a test. What it
buys is that the database schema and the wire contract move independently
— adding a column is not an API change until someone writes the line that
makes it one — and that the upcaster has somewhere to stand.
to_domain for an event is where payload_schema_version is read and the
upcaster chain runs, which is only possible because there is a function
there at all.
A repository is constructed with a session, not with a factory¶
ADR-0024 settles the module-scope half of rule 13: a factory is constructed in phase 3, a session never is. The call-scope half is this one, and it is the more consequential of the two.
If a repository holds the async_sessionmaker and opens a session per
method, then every method is its own transaction. Two writes that must
commit together cannot; the "persist intent, commit, do I/O, persist
result, commit" shape Section 12.2 requires cannot be expressed from
outside, because the caller has no way to say which of its calls belong to
the same commit; and the append path above — three statements, one
transaction — has no way to be three statements in one transaction.
So a repository is constructed with a live AsyncSession and holds no
factory. The caller that owns the unit of work opens the session and
constructs the repositories over it. unit_of_work.py holds one async
context manager that does exactly that: open a session from the factory,
expose the repositories built over it, commit on clean exit, roll back on
exception.
Repository methods do not commit. A method that commits internally makes every caller's transaction boundary a fiction, and it is invisible at the call site, which is the combination that makes it expensive later.
The benefit beyond correctness is that the transaction boundary becomes a
construction site — a literal async with with a body — rather than an
emergent property of which methods happened to commit. That is what makes
gate.structure.txn_hygiene decidable: the check has a syntactic region to
look inside, and the question "is there provider, tool, or sandbox I/O in
this region" has an answer.
Where a unit of work begins and ends¶
Three shapes cover the system, and naming them is cheaper than rediscovering them:
- An API request. One unit of work, committed before the response is written. Dispatch happens after it commits, never inside it — bootstrap-and-composition.md makes that absolute, and the inline dispatcher asserts no unit of work is open when it is called.
- A claim. The claim query is its own unit of work and commits before execution starts. It must: holding it open would hold a row lock across the entire turn, which is Section 12.2's prohibition in its most expensive form.
- A turn. Not one unit of work but a sequence of short ones — read and commit, call the provider, append and commit. Section 12.2 is the rule; the append path is what it looks like when followed.
Parallel tool invocations get one unit of work each rather than sharing
the turn's, which is Section 2.2's sentence about AsyncSession and
concurrent tasks arriving at the implementer.
What crosses the boundary¶
Rule 7 states the prohibition. Its positive form, which is what someone
writing a repository method actually needs, is: a repository method
returns a domain type, a Pydantic model declared in domain, a scalar, or
None.
That covers the case rule 7 does not obviously reach. A projection query
computes something no domain aggregate names — a per-session count, a lag
figure, three columns from a join. The answer is not a tuple and not a
dict; it is a small Pydantic read model declared in domain beside the
aggregates. Declaring it there costs a class and buys a name that the
api layer and the harness can both refer to.
Return annotations are concrete for the same reason. -> dict[str, Any]
and -> Any are not violations of rule 7 so much as evasions of the check
that enforces it, which resolves signatures and can only reject what a
signature names.
gate.structure.orm_confined makes the confinement mechanical: no module
outside adapters/persistence/ imports sqlalchemy, and no name defined
in sqlalchemy_models.py appears in a signature outside that package.
There is a second reason for all of this, beyond hygiene. Milestone 1 ships five in-memory repository adapters that Section 21 calls "production adapters run against the same contract suites as their PostgreSQL counterparts, not test doubles." A port whose return types are the domain's has two implementations. A port whose return types are rows has one, and the contract suite that was supposed to hold both honest becomes a test of the only adapter that can satisfy it.
Failure modes and defenses¶
| Failure | How it happens | Defense |
|---|---|---|
| Silent missing write | A projection advances its watermark past a sequence whose transaction has not committed yet | Every writer allocates its sequence inside the transaction that appends the event, so the session row lock serializes commits into allocation order; snapshot-aware watermarking if that discipline is ever broken |
| Event committed without its state change | The guarded UPDATE runs matches zero rows and the surrounding transaction commits anyway, leaving an event that describes a transition that did not happen |
The writer checks the affected-row count before COMMIT and rolls back on zero; an append carrying no state change, such as run.fenced, is the explicit exception |
| Projection stall on a gap | A reader waits for the next contiguous sequence after a rolled-back append | Readers ask for events after a watermark, never for a specific sequence |
| Split-brain worker | A stalled worker's lease expires; the sweeper hands the run to a second worker | lease_epoch fencing on every write; zero rows updated means stop, not retry |
| Duplicate turn | A client retries a submit that already succeeded | idempotency_keys with request_hash; repeat returns the original run, mismatch is a ConflictError |
| Notification loss read as event loss | A consumer treats LISTEN/NOTIFY as delivery |
Every consumer polls from a watermark; notification only collapses latency |
| Upcaster invents data | A missing field is filled with a plausible default | Sentinels only; consumers handle unknown explicitly; unknown higher version is a hard error |
| History rewritten by migration | A payload shape change is applied as an UPDATE over events |
Stored payloads immutable; change is expressed only as an upcaster |
| Derived-event multiplication | A rebuild re-emits memory.formed for facts already derived |
Deterministic derivation key, conditional append, convergent rebuild |
| Checkpoint growth | Full conversation inlined at every tool call | Deltas against periodic full snapshots; conversation as event references |
| Head-of-line blocking | A long async run occupies the workers a user is waiting on | Priority classes with capacity reserved per class |
| Non-idempotent double write | A tool left RUNNING by a crash is retried |
Idempotency class decides; ambiguous cases become UNCERTAIN and stop |
| Watermark/state divergence | Projection state committed separately from its cursor | Both written in one transaction |
| Export leaks a secret | A message body carries a shape the replacement stage does not cover | A verification scan over the finished document; a hit fails the export and writes nothing |
| Consent granted retroactively | A grant read at export time covers runs recorded before anyone asked | Consent is stamped on the run at start; export reads the stamp, never the table |
| Withdrawal leaves exports behind | Deletion-on-withdrawal is a bespoke path exercised once a year | Withdrawal sets expires_at to now and the daily artifact sweeper does the deleting |
Hard gates¶
Milestone 2 does not pass until every one of these holds, with three exceptions, each noted where it occurs. The migration-graph walk registers at Milestone 0, because the empty migration Milestone 0 already requires is a graph, and a walk that only starts once there are twelve revisions is a walk that has already missed the branch it exists to prevent. The two export gates register at Milestone 3, because Milestone 2 builds the projection's scaffold and Milestone 3 builds the export itself.
- Sequence integrity. A fuzz test appending concurrently, with injected
rollbacks, produces no duplicate
(session_id, sequence)and no event that any projection failed to observe. It appends concurrently within one session as well as across sessions — a submit-handler append racing a worker's — because a single session's sequence space is where the out-of-order-commit hazard lives, and appending only across sessions would exercise the serialization that matters nowhere. M2. - Projection determinism. For every projection, rebuild-from-zero over a
recorded log equals the incrementally built state, field for field, on the
same
builder_version. M2. - Upcaster totality. Every recorded historical fixture, at every version, decodes to the current shape. An unknown higher version raises. M2.
- Exactly-once execution. Two workers racing on one run: one executes, the other is fenced and writes nothing. Asserted with the sweeper's reclaim interval driven to zero. M2.
- Crash recovery. Terminate a worker after a checkpoint; the run resumes and reaches the same terminal state. Section 14.2 already requires this; it is restated here because the delta-chain reconstruction is new and it is what the test now exercises. M2.
- Checkpoint dispensability. Delete a run's non-terminal checkpoints,
resume, and reach the same terminal state. Registered as
gate.event.checkpoint_dispensable, whichruntime-loop.md#9 restates: this document owns it. M2. - Transaction hygiene. A static check plus a runtime assertion: no
transaction is open across an
awaitthat performs provider, tool, or sandbox I/O. Registered asgate.structure.txn_hygiene, whichruntime-loop.md#6 restates: this document owns it. M2. - One migration head. A static walk of the revision graph finds exactly
one head, no merge revision, and no revision whose
down_revisionis a tuple. The same walk asserts that the adapter'sEXPECTED_REVISIONconstant equals that head. Registered asgate.structure.migration_graph. M0. - Migrations upgrade from a clean database.
upgrade headagainst an empty database succeeds, and an autogenerate run against the resulting schema produces an empty diff. Section 24 states the criterion; this is what makes it decidable, and the empty diff is what keeps a hand-edited revision honest. Registered asgate.event.migration_clean. M2. - Migrations upgrade from the previous revision. For every revision,
upgradefrom its predecessor succeeds anddowngradeback to the predecessor succeeds, against a database that already holds the earlier schema. Section 24's second migration criterion. Data revisions are exempt from the downgrade half by declaration, not by omission. Registered asgate.event.migration_stepwise. M2. - The pinned revision is refused, not repaired. Started against a
database at the wrong revision, the process exits non-zero naming both
revisions, does not migrate, and does not serve a request first.
ADR-0024 decision 6 requires the behavior; this asserts it. Registered
as
gate.event.revision_pinned. M2. - The ORM stays in the adapter. No module outside
adapters/persistence/importssqlalchemy, and no name defined insqlalchemy_models.pyappears in a signature outside that package. This is dependency rule 7 in the form a static check can evaluate. Registered asgate.structure.orm_confined. M2. - The export is redacted, and refuses when it is not. A run seeded
with one instance of each of the scanner's five rule families, plus a
tenant-declared pattern, exports with every one replaced by its
placeholder and a clean verification scan over the finished document.
A second case disables the replacement stage and asserts that the
export raises
ExportRedactionError, writes no artifact, and names the rule that fired without printing what it matched. Registered asgate.event.export_redacted. M3. - Consent is stamped forward and withdrawn backward. Four states —
tenant disabled, principal never granted, granted then withdrawn, both
present — produce three refusals and one export. The withdrawal case
additionally asserts that an export produced before the withdrawal has
expires_atin the past and is gone after one sweeper pass, and that a grant made after a run does not make that run exportable. Registered asgate.event.export_consent. M3.
Tracked metrics¶
Claim latency p99 by priority class, lease reclaim rate (a rise means leases are too short or workers are stalling), projection lag in sequences and seconds, checkpoint bytes per run, and rebuild duration per projection.
Build sequence¶
- Schema and migrations. Section 15's tables plus the additions above,
created by revisions written to the conventions in "Authoring migrations"
rather than by
metadata.create_all. The round-trip gates belong here and not at the end:migration_cleanandmigration_stepwisehave something to assert as soon as the second revision exists, and a convention adopted at revision two costs nothing where the same convention retrofitted at revision twenty costs a rewrite of everything before it. Nothing consumespayload_schema_version,priority, orlease_epochyet; they are cheap now and are retrofits that break replay later. - Append path. Sequence allocation, envelope insert, conditional state change, all in one transaction. Fuzz for sequence integrity before anything depends on it.
- Upcaster registry. With the first two versions of one event type recorded, so the mechanism exists before it is needed under pressure.
- Checkpoints. Full snapshots first, then deltas, then reference-based conversation storage. Each step keeps the dispensability test passing.
- Queue. Claim, lease, heartbeat, fencing, sweeper. Then priority classes and reserved capacity.
- Session-history projection. The one Section 27.4 requires for run seeding; it makes cross-run continuity real and is the first consumer to exercise watermarks.
- Recovery. Tool-invocation-driven resume, idempotency classes,
UNCERTAIN. - Trajectory-export projection scaffold. Structure, watermark, and the
export_consentstamp onruns, which costs nothing at Milestone 2 and is a replay-breaking retrofit afterwards, because a run that started before the column existed has no honest value to backfill. The document builder, the redaction pipeline, the consent tables, and both export gates are Milestone 3. - Rebuild. Rebuild-from-zero for every projection, wired into CI as a gate rather than as a script.
Decisions¶
- The log's contract is observation, not durability. A committed event that a projection never observed is, to every consumer, an event that did not happen; the layer's gates are written against that.
- Sequence allocation is an atomic increment of
sessions.next_event_sequenceinside the appending transaction, withUNIQUE(session_id, sequence)as a backstop whose violation is a defect rather than a retryable conflict. - Sequence gaps are legal and readers tolerate them. Consumers read after a watermark and never wait for a specific next sequence.
- Allocating the sequence inside the appending transaction is load-bearing
for projection correctness, not only for contention. The session row lock
is held to
COMMIT, which serializes appends into allocation order even though a session has more than one appender. Allocating outside that transaction requires switching projections to snapshot-aware watermarking in the same change. LISTEN/NOTIFYis a latency optimization and never a delivery guarantee. Every consumer is a poller first.- Stored event payloads are immutable; schema evolution is expressed only as pure, total upcasters, and an upcaster may never invent a value.
- An unknown higher payload version is a hard error. Old code must not partially decode new events.
- Projections are deterministic, watermarked, rebuildable, and never authoritative, with state and watermark written in one transaction.
- Derived events carry a deterministic derivation key and append conditionally, so rebuilds converge instead of multiplying.
- Checkpoints are deltas against periodic full snapshots, with the conversation stored as event references; only provider-opaque continuation and compacted summary text stay inline.
- Losing checkpoints costs time, not information, and that is a test.
- The claim query orders by priority then age, across three classes, with capacity reserved per class rather than strict priority, so maintenance work cannot starve.
- Lease expiry is a guess, so every worker write is fenced by
lease_epoch; a zero-row update means stop, not retry. - Only lease expiry requeues a run. Permanent Section 13 classifications
fail immediately;
max_attemptsis 3;runs.failureis the dead letter. scheduled_forcarries both retry backoff and Milestone 11 scheduling, so the primitive is built once.- Ambiguous non-idempotent tool executions become
UNCERTAINand are reported to the model as unknown-outcome rather than as failed. - Row classes are separate declarative types confined to the persistence adapter. This is forced rather than chosen: declarative mapping of a domain type violates dependency rule 1 by import, and imperative mapping violates rule 7 in a way no static check can see.
- Translation between rows and domain types is two hand-written
functions per table, never a generic mapper and never Pydantic's
from_attributes, which is the ORM mode rule 1's note excludes. - A repository is constructed with a live session, never with a factory, and repository methods do not commit. The unit of work owns the boundary, which is what makes that boundary a construction site a static check can look inside.
- A repository returns a domain type, a
domainread model, a scalar, orNone, under a concrete return annotation, because the check enforcing rule 7 resolves signatures and cannot reject what a signature does not name. - The revision graph is linear. No merge revisions, no
alembic merge; a branch is resolved by rebasingdown_revision. Two of the plan's own statements —upgrade headand "upgrade from the previous revision" — are not well-defined otherwise. - Structure and data are separate revisions, with
downgrade()written for structural revisions and raisingNotImplementedErrorfor data revisions. - Autogenerate drafts and a person edits, kept honest by an empty-diff round trip rather than by review, because the four things autogenerate misses are the four that matter here.
- The expected schema revision is a constant in the adapter, not the head computed from the migrations directory at runtime — a computed head always agrees with the code that ships beside it, which makes the assertion vacuous in exactly the case it exists to catch.
- Test schema is created by running the migrations, never by
metadata.create_all, or Section 24's two migration criteria become statements nothing evaluates. - A trajectory export is an artifact, not a table, so it inherits
content addressing, a derived key, an authorized read path, and
expires_atwith a sweeper behind it, rather than growing a second and less tested version of each inside PostgreSQL. - The export carries no per-message timestamps and a date rather than a timestamp at the top, because timing is the most correlatable thing an export could carry and no stated consumer keeps it.
- Redaction fails closed. A verification scan runs over the finished document, and a hit raises rather than redacting a second time, because a second pass hides the gap in the first and ships the artifact anyway.
- Consent is stamped forward and withdrawn backward. The grant is evaluated at run start and stamped on the run; a withdrawal blocks every run, stamped or not, and expires every artifact already produced.
- Withdrawal deletes through
expires_atand the existing sweeper, never through a bespoke deletion path, so the rarest governance operation runs on the most exercised code in the system. - An export never descends into child runs. Each run exports separately, which keeps the redaction surface flat, keeps the consent question single-principal, and keeps the document bounded.
- Promotion is the durable step and the export is perishable, so the undeleted set is a thirty-day window rather than a history of every conversation the platform has held.
- A persisted event advances its session's activity timestamp in the append transaction. History ordering therefore cannot claim activity that did not commit and cannot miss activity because a later projection failed.
- Authoritative session deletion uses a content-free tombstone and an artifact-deletion outbox. The database graph disappears atomically while external byte deletion remains retryable across process failure.
Open questions¶
None blocking Milestone 2. Four recorded for Andy's review, with the interim decision noted in questions for review:
- Whether single-active-run-per-session should be promoted from Section 27.5's "default" to an invariant, given that projection correctness now depends on it. The interim position keeps Section 27.5's wording and documents the coupling.
- Whether the trajectory-export projection belongs in Milestone 2 or Milestone 3. Section 21's Implement list says Milestone 2; Section 21.1's sequencing table says Milestone 3. The interim split is scaffold in 2, export in 3.
- Whether export consent is one grant or two, split between evaluation fixtures and training data, which Section 31.2 lists as different uses with plausibly different answers. The interim position is one grant, because Section 31.3 says consent in the singular and a scope vocabulary invented here would collide with the principal scopes Milestone 4 owns.
- Whether an export should be rebuildable under a new
builder_versionwhile the previous artifact still exists. The interim position is no: a second call returns the existing reference, because a changed builder would otherwise hand a caller a materially different document under the same run id and nothing in the document says which builder made it — exceptbuilder_version, which is exactly the field a caller who has already stored the first one will not re-read.
Crash allowance and ordinary continuations (ADR-0118)¶
ADR-0118 clarifies
queue-level retry: attempts remains the total claim count, while the new
nonnegative lease_expirations column counts only expired worker executions.
Input, approval and child-run resumptions neither consume nor reset this crash
allowance. This supersedes using total claims for max_attempts and retry backoff.
The claim predicate requires lease_expirations < max_attempts. The locked
lease reaper increments that counter for an expired running execution only.
With the unchanged default of three, the first two expirations requeue with
one- and two-second backoff; the third fails with max_attempts_exceeded.
The bounded reaper also terminalizes already exhausted queued rows, emitting
run.failed in the same transaction. The QUEUED -> FAILED edge exists for
that case, so a claim filter cannot strand a run indefinitely. Waiting runs
remain excluded; fencing, deadlines, budgets and effect recovery are unchanged.
The additive migration backfills from durable maintenance run.requeued and
lease-exhaustion run.failed events, never from total claims. It preserves
actual crash history and never reopens terminal runs. Existing queued
continuations with unused crash allowance become claimable with their saved
answers, checkpoints, tool identities and approval requirements intact.
Browser recovery call receipts (ADR-0163)¶
A run.checkpointed event with trigger: browser_recovery optionally extends
its existing payload with runtime_tool_call, the complete queued read or
owner-question call. Write that receipt in the same transaction as the checkpoint
that marks it pending. Conversation projection reads the call from this receipt
and its result from the ordinary tool event, preserving pairs in later runs and
cold replay. Legacy checkpoint payloads are unchanged; no model response is
invented, no required legacy field changes and no event type is added. Recovery
receipts permit only browser.observe and conversation.ask_user.
ADR-0164 adds browser_auth_resume checkpoint receipts for a fresh
browser.navigate/browser.observe and browser_workflow receipts for one
ordinary navigate/observe/act invocation. Receipt admission requires a runtime
actor; all dispatched calls retain ordinary result events and authorization.
The platform event browser.authentication.resumed records question/profile/
ceremony identifiers and the bounded continuation content. It replaces neither
an owner message nor authority. The matching tool.call.completed, working-state
update, full checkpoint and run requeue commit atomically with it, using the
question-derived idempotency key. Replay preserves exactly one call/result pair.
Automatic memory maintenance and conversation activity¶
ADR-0167 refines the append path's activity projection for the owner-requested
Recent-chat repair. A memory.decayed or memory.retired event whose actor is
memory and whose run identifier is null still allocates its sequence and
persists in the same transaction, but leaves sessions.updated_at unchanged.
Both adapters apply this exception; all other events retain the monotonic
timestamp update. The repair migration restores affected active sessions to
the latest remaining event or creation time, preserves later independent
writes and closed sessions, and retains every audit event.