Skip to content

feat(core)!: the store assigns seq and ts - #154

Merged
sagi5060 merged 15 commits into
devfrom
feat/store-assigns-seq
Aug 8, 2026
Merged

feat(core)!: the store assigns seq and ts#154
sagi5060 merged 15 commits into
devfrom
feat/store-assigns-seq

Conversation

@sagi5060

@sagi5060 sagi5060 commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Implements ADR-D11 as amended by #153.

Closes #127.

#121 is no longer closed here. Its snapshot sweep was split out to #157, because #121's own
Notes require it: the sweep "must land as its own PR with the justification stated, never bundled
with a schema change."
b65b7d4 is the first commit on this branch, so the split was free — and
#157 also carries the "Done when" box this PR left unwritten (a test that regenerates into a tmp
dir with one extra payload and asserts exactly one file moves). Merge #157 first; this branch's
diff then collapses on its own.

Why

seq is both the ordering authority and the loss check, and the second only works if the
sequence is dense. The Runtime held the counter and allocation was separated from the write by
an await, so a refused append burned a number permanently:

in the log       : [(0, 'run.started'), (1, 'text.delta'), (3, 'run.failed')]
check_contiguous : [2]        ← a gap no refetch can ever fill

That contradicted runtime/service.py's own promise — "a consumer that spots a seq gap can
always refetch it."
A seq allocated and persisted in one step cannot be allocated and not
persisted. test_a_store_that_refuses_a_report_costs_the_report_not_the_run now asserts
check_contiguous(...) == [] on exactly that run; it is the single assertion this PR exists for.

What landed

  • EventStorePort, 8 methods to 7. append(log_key, payloads, ctx, origin) -> list[Event];
    claim_start(..., origin, stale_after: timedelta) -> (SessionClaim, Event | None);
    claim_resume(..., resumed: RunResumed, ctx, origin) -> Event | None; last_seq removed.
    SessionClaim.overridden carries each abandoned run's last Event rather than its id.
  • All four stores assign inside their own indivisible write: memory's no-await _stamp,
    SQLite's BEGIN IMMEDIATE on the append path (which it did not take before), Postgres's
    advisory lock, Redis's WATCH/MULTI/EXEC on the per-run ZSET.
  • The Runtime loses its counter and its seven (spec, ctx, seq) signatures.
  • The whole test suite converted, plus ADR-D11 §6's concurrency case and §7's doc consequences.

Shape, per the amended ADR

append stays ctx-derived. The one write that addresses a foreign run —
_close_abandoned — gets what it needs from SessionClaim.overridden. No override parameter,
no new port method, and claim_start keeps one job.

Clocks: injected callable for memory and redis, backend clock for sqlite and postgres — an
in-process callable gives N workers N disagreeing clocks, which is what stale_after cannot
tolerate.

Judgment ledger

Choice Why
Runtime(clock=...) and build_runtime(clock=...) kept but documented as inert Nothing above the store stamps a ts any more, so both keywords are accepted and forwarded to nothing. Removing a public keyword is a breaking API change and belongs in its own PR, not smuggled into a test conversion. Recorded as an open row in the index's known-deltas so it is not lost. Recommend a follow-up that removes it.
clock=lambda: TS stripped from ~30 test call sites Not a formatting sweep: those lines read as freezing time while freezing nothing, which §8's determinism law makes worse than not freezing at all. The two cases that genuinely assert a timestamp inject the clock into the store instead, which is where the seam now lives.
Staleness in the contract suite is a window, not a backdated ts The store owns the clock, so no case can backdate an event. NOTHING_IS_STALE = timedelta(hours=1) / EVERYTHING_IS_STALE = timedelta(0); timedelta(0) is stale in the right direction on all four backends because equality fails the > comparison. Only the two cases needing one run stale beside a live one arrange a real age gap with a sleep — arranging age, never asserting timing.
_Held, a hand-moved clock on the store, replaces _abandoned(run_id, ts) Keeps the takeover tests deterministic with no sleeps: the clock is held in the past while the abandoned run's opening is written, then returned to TS. Closer to what the store actually sees than a hand-stamped envelope was.
The multiprocess claim/end comparisons gained a 1 ms tolerance ts comes from SQLite's clock now, and strftime('%f') truncates to whole milliseconds — measured in this repo: a stamp reads up to 0.999 ms earlier than the instant it was taken. The two "the claim arrived before the run ended" assertions therefore carried a built-in millisecond of bias against themselves and failed on a genuine race about one trial in a hundred (it flaked once during this work). They now compare against the latest instant the event can have been written at.
LateStore's gate moved from last_seq into claim_resume resume() no longer reads the log before claiming, so there was no call left upstream of the claim to gate on. Gating the claim's own entry is strictly stronger — the gate cannot be outrun by a claim it is the first statement of — so the assert self._gate.exists() guard that protected the old ordering is deleted rather than moved.
_CannotClose refuses append for ctx.run_id == "r-0" rather than last_seq The takeover's closing write now goes through append with the abandoned run's context, which is the one thing distinguishing it from this turn's own appends. Same failure, same point in the takeover.

Behavior change the ADR implies but never states

A premature takeover no longer kills the run it stepped over. That run used to hold a spent
seq, so its next write was refused and it failed loudly with a StoreError. It holds no number
now, so its writes are assigned the seqs after the takeover's run.failed and go through: the
run plays to completion and its log ends [run.started, text.delta, run.failed, run.completed].

The damage is still bounded and still detectable, but by check_terminal reporting two terminal
events rather than by an exception reaching the engine. The log stays dense and no seq answers
to two events — both structural now instead of enforced. Pinned by
test_a_run_that_writes_again_after_being_taken_over_lands_behind_its_terminal_event. This is
the one thing in the PR a reviewer has to consciously accept rather than merely check.

Tests deleted rather than converted

Each tests something no caller can now express. Named in full in the commit messages.

  • last_seq — three contract cases, the last_seq params in three port-boundary
    parametrizations, and one probe inside a tenant-isolation case (which keeps its point through
    read_run). The method is off the port.
  • Spent-seq refusalstest_one_seq_per_run_is_refused_a_second_time,
    test_a_batch_holding_its_own_duplicate_seq_is_refused_whole,
    test_one_seq_per_run_holds_on_the_claim_paths_too, and
    concurrency_worker._refuse_a_spent_seq. No caller supplies a seq, so the duplicate is
    unconstructible rather than unenforced. What they protected is replaced by ADR-D11 §6's
    concurrency case
    (20 tasks appending to one run at once must come back [0..19] with no
    duplicates, on every parametrized backend), and by the crossrun peers now checking per write,
    while the peer is still writing, that the number they were handed is their own run's next one.
    Measured against a store with one await between reading the run's last seq and extending the
    log: it hands 0 to all twenty, and every other case in the file still passes.
  • test_a_claim_carrying_a_stale_seq_loses_even_though_the_run_is_waiting_again — the
    stale-seq half of claim_resume's contract existed because the caller stamped before
    claiming. Status is the whole condition now.
  • Foreign-tenant refusalstest_an_event_stamped_for_another_tenant_is_refused (memory and
    SQLite) and the pytest.raises(ValueError, match=<tenant>) halves of two contract cases. The
    tenant comes from the RunContext that chose the bucket. Every isolation half stays.

This supersedes one line of 1e4df66's commit message, which listed the spent-seq refusal
among the invariants that stay in the crossrun trial. It does not stay; it becomes unreachable.

Doc consequences (ADR-D11 §7)

coding-standards §6, runtime/service.py's module and _drain docstrings, composition.py's
clock keyword, and the architecture doc's envelope-stamping split (dated amendment). The
index's known-delta rows covering all five flip to Applied 2026-08-08; one new row records the inert
clock keyword. CHANGELOG carries both user-visible facts — no more permanent gaps, and the port
change is breaking for anyone who implemented it.

Golden snapshots were regenerated once, in b65b7d4, and have not moved since.

Gate

make check green in the foreground with both live backends up:

ruff check      All checks passed!
ty check        All checks passed!
lint-imports    Contracts: 12 kept, 0 broken.
pytest          978 passed, 42 skipped, 1 warning in 102.26s

All 42 skips are contract-case shape skips (only a suspended case can be resumed,
this run finished) — under CI's ceiling of 44, with Redis and Postgres exercised for real.

Not in this PR

#94 (recording the resume value) is a payload change, and coding-standards §7 requires schema
changes in their own PR. It also needs a ruling: store in full, or preview+hash.

🤖 Generated with Claude Code

Review findings, addressed

Reviewed against ADR-D11, #121 and #127. Two blocking, four advisory — all fixed.

Finding Fix
Redis _CLAIM_ATTEMPTS (20) equalled _CONCURRENT_APPENDS (20). The WATCH/MULTI/EXEC loop is lock-step, so N contenders need exactly N rounds: measured N=20 → 0 failures in 350 trials, N=21 → 60/60 failures. One more contender turns the case permanently red, not flaky. Raised to 64, with the lock-step fact written into the comment so the next reader does not re-tighten it. Verified against the live container: 60 contenders now pass.
Closes #121 with an unwritten "Done when" box, and 20 snapshot files changed with no stated justification. Split to #157 — see the top of this description.
The takeover behavior change understated: the resurrected run's next write can be non-terminal. Documented below, and pinned by test_a_run_resurrected_into_an_interrupt_takes_its_session_back.
Runtime(clock=...) written to self._clock and never read — a caller freezing it gets no error and silently asserts against wall time. Raises DeprecationWarning when passed explicitly; the dead attribute and the now-unused _now are gone. Removal filed as #158, not left as a known-deltas row.
AGE_GAP left a 250 ms machine-dependent window — the class of assertion #127 exists to delete. _window_between(older, newer) derives stale_after from the stamps the store wrote. A stall between the two writes now widens the window instead of eating into it.
SQLite took BEGIN IMMEDIATE even for an empty payload list, where postgres and redis early-return. Early-returns too.

The non-terminal resurrection, stated

The paragraph above describes the takeover variant where the stepped-over run completes. The
worse shape is a non-terminal lifecycle event, and it is now pinned:

r-1 log     : ['run.started', 'text.delta', 'run.failed', 'run.interrupted']
r-1 status  : waiting_human          ← takes its session back
pending     : contains 'r-1'         ← resumable again, after being declared dead
check_terminal: terminal event 'run.failed' at index 2 of 4, not last

The run does not merely land behind its own run.failed — it becomes WAITING_HUMAN and
reclaims the session a turn was told was free. This is arguably correct (the run really is alive
and really is waiting), which is why the takeover stays advisory rather than fatal, and
check_terminal still gives it away. It is pinned because it is the shape a reader of ADR-D11
would not predict: the ADR says a spent seq can no longer refuse a write, and says nothing
about a refused run reclaiming the session it was evicted from.

Verification

make check in the foreground, then the whole suite again against live agentdeck-test-redis
and agentdeck-test-pg: ruff clean, ty clean, import-linter 12 kept / 0 broken,
979 passed / 42 skipped with both backends live.

sagi5060 and others added 13 commits August 8, 2026 12:29
tests/core/conftest.py built every example event's seq from its position in
PAYLOADS, so the snapshots pinned a number that had nothing to do with what
they exist to pin. Adding a kind was therefore order-sensitive: appending was
only safe if nobody else appended, and #112 and #116 each appended two payloads
at once, so both shipped snapshots claiming seq 17 and 18 and whichever merged
second had to regenerate files whose only diff was a seq bump.

Every example now carries seq 0. These snapshots pin one thing — how each
payload kind serializes — and a per-kind position in that tuple was never part
of it. Nothing depended on the numbers being distinct: every seq-sensitive test
stamps its own through make_event, and _run re-enumerates from zero.

Snapshots regenerated deliberately via `make golden`. 20 of 21 files change and
every diff is the seq line alone, verified — run.started was already 0. This is
not a schema change; no payload, field or serialized shape moved.

Closes #121

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ot the timing

The trial ended by counting how many of its 10 runs had the two processes'
appends alternate in the file, and asserting the count was non-zero. That is a
fact about the machine, not a promise of the store: peers released from a
barrier onto a loaded or single-usable-core runner can serialise across every
trial. It went red on CI for exactly that reason, on a branch whose diff could
not reach the code under test, and it does not reproduce locally even pinned
with taskset -c 0 — the signature of a timing assertion rather than a defect.

Same treatment #113 gave the resume race's `raced` counter in this file: the
count is printed, not asserted. That leaves the file fully converted; a sweep
for the remaining pattern ("never overlapped", "did not race", assert raced /
interleaved / overlap) across tests/ finds nothing else.

Every per-trial assertion is untouched and still carries the invariant: each
run's kinds in order, each run contiguous from 0 and closed by exactly one
terminal event, one event per (run_id, seq), and the worker's own spent-seq
refusal failing the trial while the peer is still writing.

Verified: passes normally (4/10 interleaved), passes under `taskset -c 0`
(5/10), and goes red on a planted duplicate (run_id, seq) — "a seq answers to
two events".

ADR-D11 §6's concurrency case lands with the port change, where the API it
tests exists; writing it here against a signature that does not yet exist would
be noise rather than evidence.

Closes #127

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ADR-D11 §5, as amended by #153. Callers hand over payloads and get finished
events back; the store assigns seq and ts inside whatever makes its write
indivisible. A number allocated and persisted together cannot be allocated and
not persisted, which is what makes seq dense enough to be the loss check it is
documented to be.

  append(log_key, payloads, ctx, origin)                    -> list[Event]
  claim_start(log_key, opening, ctx, origin, stale_after)    -> (SessionClaim, Event | None)
  claim_resume(log_key, run_id, resumed, ctx, origin)        -> Event | None
  last_seq                                                    removed

Port goes 8 methods to 7. read, read_run, list_runs and run_status are
untouched.

append stays ctx-derived, with no override parameter: a caller that could
address any run could file an event under a run it is not playing. The one
write belonging to a different run — the terminal event a takeover stamps for
a run it stepped over — passes a ctx built for that run from the event
SessionClaim now hands back. SessionClaim.overridden carries each abandoned
run's last event rather than its id, which every store already holds, having
compared that event's ts to decide the run was stale.

stale_before: datetime becomes stale_after: timedelta. The caller no longer
owns the clock the comparison is made in, so it cannot compute a cutoff in it.

claim_resume keeps its status condition verbatim and loses the stale-seq half,
which existed only because the caller stamped before claiming. Its docstring
now says what that check never covered and still does not: nothing names which
interrupt a resume answers. That is #94's, in a schema PR.

Deliberately red: this changes the ABC, so all four adapters and the Runtime
stop satisfying it until their own commits land. Committed alone so the
contract is reviewable on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
First adapter under the new port, and the reference the other three get
diffed against.

_stamp is the whole mechanism: read the run's last seq, build the events,
extend the log — plain dict work with no suspension point anywhere in
between. Both claims go through it rather than through append, because an
await between reading the last seq and extending is all it would take for two
tasks to be handed the same number. append's own asyncio.sleep(0) (issue #87's
fidelity yield) stays after the mutation, where it opens no window.

_refuse_a_taken_seq is deleted. It was the durable stores' unique index
rendered in a dict, and it defended against a caller supplying a seq that was
already spent. No caller supplies a seq now, so two events at one seq is
unconstructible rather than merely refused.

claim_start returns the abandoned runs' last events rather than their ids —
it was already holding each one to compare its ts, and now hands it back
instead of discarding it. It also does its own now - stale_after, since the
clock moved here.

claim_resume keeps the status condition and drops the seq check. Its
run_id/tenant guards collapse into one ctx check: the envelope is built from
ctx, so an event for the wrong run or tenant can no longer be handed in.

Measured on this store:

  claim on an idle session -> seq 0, ts set, origin stamped
  two more payloads        -> seqs [1, 2]
  second turn, live session-> refused, held_by=r1, no event
  20 concurrent appends    -> seqs [0..19], no duplicates   <- ADR-D11 §6
  double resume            -> first returns seq 2, second returns None

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
append now takes BEGIN IMMEDIATE, which it never did. Reading MAX(seq) inside
the deferred transaction it used to run and then inserting upgrades read→write
mid-transaction, and SQLite answers that with SQLITE_BUSY_SNAPSHOT — which does
not honour busy_timeout (#84), so a peer committing in between would be an
error rather than a wait. Taking the write lock first makes the read and the
insert one step, which is the decision. ADR-D11 §6's row said this transaction
already existed; it did not, and #153 corrected the ADR to say so.

_stamp_and_insert is the shared assign-build-insert, callable only with the
lock held; all three write paths go through it.

ts comes from SQLite, not this process, so N workers on one file compare one
clock. Read as strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now') rather than
CURRENT_TIMESTAMP, which is whole seconds and would give every event in a busy
second the same ts — visible coarsening on the wire for nothing. One ts per
call: a batch is a single indivisible write, so it happened at one instant.

The UNIQUE (tenant, log_key, run_id, seq) index stays, and its comment now
holds in the way the ADR describes — it stopped being the guard against a
caller reusing a seq (there is no such caller) and became the proof that
assignment is correct.

Same five behaviours as the memory store, measured on this one:

  claim on an idle session -> seq 0, ts set, origin stamped
  two more payloads        -> seqs [1, 2]
  second turn, live session-> refused, held_by=r1, no event
  20 concurrent appends    -> seqs [0..19], no duplicates
  double resume            -> first returns seq 2, second returns None

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same shape as sqlite, over the per-log advisory lock this store already took
on every write. That lock existed for the paging guarantee — BIGSERIAL is
assigned at insert and published at commit, so an unlocked append can be given
a later id than an in-flight claim and still commit first — and it is now also
what makes reading this run's last seq and inserting the next one indivisible.

ts is clock_timestamp(), not now(). now() is the transaction's start time, so
every event in a batch would carry the timestamp of the statement that opened
it — and on the claim path, the statement that set lock_timeout. Reading
Postgres's clock rather than this process's is the point: N workers sharing one
database compare one clock, which is what stale_after needs to mean anything
(ADR-D11 §4).

Verified by ruff and ty here; the behaviour is proven by the shared contract
suite against a real server in CI, which is where this store's guarantees have
always been measured (#75).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The next seq comes off the run's existing ZSET index — read under WATCH, so a
peer spending the same number between the read and EXEC aborts this write and
_watched retries on what the peer actually wrote. ADR-D11 §6 originally
prescribed INCR on that key, which would have returned WRONGTYPE on every
append; #153 corrected the table and this implements the corrected row.

_refuse_a_taken_seq is deleted. It existed to catch a caller reusing a seq,
and there is no such caller now; the WATCH that used to protect the check is
what protects the assignment instead. Its StoreError-on-duplicate arm goes with
it — a peer racing for the same number now loses an EXEC and retries, which is
a race somebody lost rather than the corruption that error reported.

claim_resume no longer watches the seq key itself: _stamp does it, and the
status key remains this claim's own condition.

The clock is an injected callable, not Redis's TIME. Per ADR-D11 §4 the SQL
stores read the backend's clock because their atomicity rests on a transaction
they are already inside; here it does not — the WATCH is the mechanism — and a
TIME round trip on the hot path would buy nothing.

Measured against a real Redis 7 (the running agentdeck-test-redis, db 15):

  claim on an idle session -> seq 0, ts set, origin stamped
  two more payloads        -> seqs [1, 2]
  second turn, live session-> refused, held_by=r1, no event
  20 concurrent appends    -> seqs [0..19], no duplicates
  double resume            -> first returns seq 2, second returns None

The same five, run against a real Postgres 16, pass too — so the previous
commit's "proven in CI" is now "measured here as well".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
count() is gone from run() and from the resume claim, and the Iterator[int]
that was threaded through seven signatures goes with it. _stamp is deleted;
_record is now append-and-fan-out, and the store returns the finished event.

Three call sites the ADR originally called "seq arithmetic that stops
existing" needed three different answers, which is what #149 found and #153
corrected:

- the arithmetic one (the resume claim's max(seq) recovery) simply goes; the
  store assigns from the run's own log, so seq still continues across a
  process restart with no counter here to recover.
- the cancellation-path probe asked last_seq(...) >= 0 to find out whether the
  claim committed before the client disconnected. It now asks run_status(...)
  is not PENDING, which answers the same question: PENDING means no lifecycle
  event, indistinguishable from a run the store never saw, and every run
  records run.started first.
- _close_abandoned read the run's tail for the envelope fields its closing
  event had to inherit. It now receives that event from SessionClaim, which
  the store had already read to decide the run was stale.

_close_abandoned is where the shape earns itself. It writes into a *foreign*
run, and rather than an override parameter it builds that run's own
RunContext — replace(ctx, run_id=..., session_id=...) — and passes the
abandoned run's origin. append stays ctx-derived, so no caller can file an
event under a run it is not playing.

ty passes across the package. The test suite does not: 190 tests across 12
files construct stamped events and call the old signatures, the 823-line store
contract suite most of all. Those are the next commits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he seqs it assigned

Every case here used to hand the store a pre-stamped `Event` built by a `_event(seq, ...)`
helper, which is now unconstructible: `append`, `claim_start` and `claim_resume` take
payloads and derive the whole envelope from the `RunContext`. So the helper is gone and its
two jobs split — `_ctx(tenant, run_id, log_key)` says who the write belongs to, and the
payload constructors say what happened. A case that writes for a second run passes a context
built for it, not a `run_id=` override on an event nobody constructs.

`stale_before: datetime` became `stale_after: timedelta`, and the store owns the clock, so a
case can no longer backdate an event. Most of them do not need to: `NOTHING_IS_STALE` and
`EVERYTHING_IS_STALE` are a window nothing in the test can fall outside or inside, and
`timedelta(0)` is stale in the correct direction on all four backends because equality fails
the `>` comparison. The two cases that need one run stale *beside* a live one arrange a real
age gap with a sleep — arranging age, not asserting timing.

New, and the case the whole port change rests on: 20 tasks appending to one run at once must
come back numbered 0..19 with no duplicates, on every parametrized backend. Both halves are
asserted, the seqs the store handed each caller and the seqs the log reads back, because only
the first catches a store that returns a number it never wrote. Measured against a store with
one `await` between reading the run's last seq and extending the log: it hands 0 to all
twenty, and every other case in this file still passes.

Deleted rather than converted, because each one tests something no caller can now express:

- `test_last_seq_is_negative_one_for_a_run_with_no_events`,
  `test_last_seq_tracks_the_highest_seq_appended_for_that_run`,
  `test_last_seq_is_scoped_to_one_run_not_the_whole_log`, and the `last_seq` params in
  `_SQLITE_CALLS` and `test_the_focused_queries_never_answer_from_another_tenants_log` —
  the method is off the port. The tenant-isolation case keeps its point through `read_run`.
- `test_one_seq_per_run_is_refused_a_second_time`,
  `test_a_batch_holding_its_own_duplicate_seq_is_refused_whole`,
  `test_one_seq_per_run_holds_on_the_claim_paths_too` — a caller has no `seq` to spend twice,
  so the refusal is unconstructible rather than merely unenforced. What they protected is now
  the concurrency case above.
- `test_a_claim_carrying_a_stale_seq_loses_even_though_the_run_is_waiting_again` — the
  stale-seq half of `claim_resume`'s contract existed because the caller stamped before
  claiming. Status is the whole condition now.
- the `pytest.raises(ValueError, match=<tenant>)` halves of
  `test_claim_start_never_sees_another_tenants_open_run` and
  `test_claim_resume_never_reaches_into_another_tenants_waiting_run` — the tenant comes from
  the context, so there is no foreign one to refuse. Both keep their isolation half, and the
  claim_start case now asserts each tenant's log holds only its own events.

Renamed: `test_a_claim_must_carry_an_event_for_the_run_it_names` reads
`..._must_be_made_in_the_context_of_the_run_it_names`, since the mismatch it still catches is
between `run_id` and `ctx.run_id`. `test_one_seq_per_run_does_not_stop_two_runs_sharing_a_seq_in_one_log`
reads `test_every_run_in_one_log_counts_its_own_seq_from_zero` — same promise, now stated as
what the store does rather than what it declines to refuse. Two cases added beside it for the
numbering a caller can no longer check by construction: a batch is numbered in the order it was
handed over, and a second batch carries on from where the first stopped.

`NeverYields` follows the port to seven methods.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same conversion as the contract suite, in the four files that keep the evidence one backend
alone can be wrong about. Each `_event(seq, ...)` builder is gone; where a case needed several
events it asks for several payloads in one append and reads the seqs back, which is now the
only way to know what it was given.

Both claim races are re-asserted through the return values rather than a bool: `claim_resume`
hands back the event it wrote or `None`, and `claim_start` a `(SessionClaim, Event | None)`
pair. The two session-claim races gained a line for the second half of that pair — exactly one
peer is handed an event — because a store could name one holder correctly and still return the
loser an event it never wrote.

Deleted:

- `test_an_event_stamped_for_another_tenant_is_refused`, from the memory and SQLite suites
  both. The tenant comes from the `RunContext` that chose the bucket, so there is no foreign
  tenant left to stamp and nothing to refuse. Tenant isolation keeps its own case in each file
  (two tenants under one session id read only their own events).
- the `last_seq` params from the redis and postgres `_CALLS` boundary parametrizations, the
  method being off the port. Both lists still name every remaining method, which is what makes
  a method added later without the wrapper a missing case rather than a silent leak.
- `test_a_colon_in_a_tenant_id_cannot_reach_into_another_tenants_log` asked `last_seq` for one
  of its four "the intruder sees nothing" probes; it asks `read_run` instead, so the case keeps
  all four.

Postgres's stale-run case now asserts what `SessionClaim.overridden` actually carries — the
abandoned run's own last event, compared against the event the seeding append handed back,
rather than its id. That is the field `Runtime._close_abandoned` builds the closing event's
envelope from, so an id would have passed while telling the caller nothing it needs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…seam moved to the store

`check_contiguous(...) == [2]` becomes `== []`. That one line is what ADR-D11 exists for: a
store that refuses a `status.reported` used to cost the run a `seq` as well as the report,
because the number was taken before the write and stayed spent when the write failed. It is
allocated inside the write now, so a refused append takes nothing, and a hole in a log can only
mean an event was genuinely lost — which is what `service.py`'s own refetch promise always
claimed. The comment inverts with it: a known consequence becomes something that can no longer
happen.

`Runtime(clock=...)` is inert. Every event's `ts` comes from the store, so the ~30 test call
sites passing `clock=lambda: TS` were freezing nothing while reading as if they were, which is
worse than not freezing at all. The kwarg is stripped from all of them, and the two cases that
genuinely assert a timestamp now inject the clock where it lives:
`MemoryEventStore(clock=lambda: TS)`. `test_the_envelope_timestamp_comes_from_the_injected_clock`
is renamed `..._comes_from_the_stores_clock` and says which seam that is. The contract suite's
`store` fixture takes the frozen clock for the same reason. The parameter itself is left on
`Runtime.__init__` and `build_runtime`, documented as no longer stamping anything — removing a
public keyword is a breaking change and not this PR's to take.

Staleness is arranged through the store's clock too. `_abandoned` used to hand-stamp a
`run.started` at `TS - 10min`; nothing can hand-stamp a `ts` now, so `_Held` is a clock a test
moves by hand and `_leave_open` writes the abandoned run's opening with it held in the past,
then returns it to `TS`. Same determinism, no sleeps, and it is closer to what the store
actually sees. `_Ticking` moves from the Runtime to the store unchanged.

Two test doubles were overriding methods that no longer exist in that shape, which would have
made them silently stop intercepting:

- `_SlowClaim.claim_start` took `(log_key, event, ctx, stale_before)` and gated on
  `event.run_id`; it now takes the port's five arguments, gates on `ctx.run_id` and returns the
  `(SessionClaim, Event | None)` pair.
- `_CannotClose` refused `last_seq`, which is off the port. The takeover's closing write goes
  through `append` with the *abandoned* run's context, so it refuses that instead — the same
  failure at the same point, told apart from this turn's own appends by the one thing that
  distinguishes them.
- `_RefusesReports.append` filtered `event.kind` over pre-stamped events; it filters
  `payload.kind` over payloads. Without this the store would have refused nothing and the
  `== []` above would have passed vacuously — the caplog assertion is what proves it still bites.

**A behavior change ADR-D11 implies but never states**, so it is recorded here rather than
buried: `test_a_run_that_writes_again_after_being_taken_over_fails_instead_of_reusing_a_seq` is
now `..._lands_behind_its_terminal_event`. A run whose session was taken over prematurely used
to die on its next write — it held a spent `seq` and the store refused it. It holds no number
any more, so its writes are assigned the seqs *after* the takeover's `run.failed` and go
through: the run plays to completion and the log ends `[run.started, text.delta, run.failed,
run.completed]`. The damage is still bounded and still detectable, but by `check_terminal`
reporting two terminal events rather than by a `StoreError` reaching the engine. The log stays
dense and no `seq` answers to two events, both of which are structural now instead of enforced.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ds them

`_race_crossrun` self-stamped `enumerate(script)` and then called `_refuse_a_spent_seq`, which
required the log to refuse this run's `seq` 0 a second time. A peer holds no `seq` any more —
the store reads the run's last one under the file's write lock and assigns the next — so the
duplicate that assertion demanded is unconstructible rather than merely unenforced, and the
whole arm is deleted along with the `_stamped` builder behind it.

**This supersedes one line of `1e4df66`'s message**, which listed the spent-seq refusal among
the invariants that stay. It does not stay; it becomes unreachable.

What replaces it is the other side of the same promise, checked where it is cheapest to catch:
each peer asserts per write, while the other is still writing into the same file, that the
events it got back are one event at its own run's next number. A store that handed two writers
one number fails inside the worker, which the trial reads out as a peer that exited non-zero.
The post-hoc invariants the trial already checked are untouched — per-run contiguity from 0, one
terminal event per run, one event per `(run_id, seq)` over the settled file.

Three test doubles followed the port: `ClaimTimingStore.claim_resume`, `ClaimStartTimingStore`
and `StallingStore` in the concurrency worker, and `StallingStore` in the crash worker. Each was
overriding a signature that no longer exists, so each would have stopped intercepting; both
`claim_start` overrides now branch on the returned event rather than on `held_by`, which is the
same question asked of the half of the pair that carries the answer.

One measurement fix the port change forced. `ts` comes from SQLite's own clock now (ADR-D11 §4:
N workers on one file must compare one clock, not N), and `strftime('%f')` truncates to whole
milliseconds — measured here, a stamp reads up to 0.999 ms earlier than the instant it was taken.
The two "the claim arrived before the run ended" assertions compare a `time.time_ns()` reading
against that stamp, so they carried a built-in millisecond of bias against themselves and failed
on a race that genuinely happened, about one trial in a hundred. They now compare against the
latest instant the event can have been written at. Observed over four runs of the suite after the
change: 20/20 resume claims overlapping, 10/10 session claims overlapping, 3/10 crossrun trials
genuinely interleaved — the contention is real and reported, not assumed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ADR-D11 §7's consequence list, landed. Every one of these was a sentence that stayed true only
until the port change, and each is now false where it stands:

- **coding-standards §6** stated "the Runtime is the **only** assigner of `seq`, one counter per
  run, recovered from `max(seq)` on resume", carrying an inline note that it stood until the port
  change and went with it. This is that landing, so the law now reads as the store's.
- **`runtime/service.py`'s module docstring** described the per-event order as beginning with
  "stamp the envelope" and said `seq` and `ts` are stamped there. It says what actually happens
  and, more usefully, why the refetch promise two lines above it is now true rather than nearly
  true.
- **`_drain`'s docstring** ended "the `seq` that report consumed stays spent, so the log shows a
  gap … not this arm's to close". Deleted: a refused append takes no number.
- **the design doc's envelope-stamping split** gets a dated amendment rather than a rewrite —
  what it claims about ordering, loss detection and the engine's position is unchanged, and the
  amendment says which half moved and why the claims become true. The `seq` comment in the
  `Event` sketch names the store.
- **`composition.py`** documents `clock` as reaching nothing that stamps an event, and points at
  the seam that replaced it: a store built with a clock, or, for the two SQL stores, the
  backend's own — which is the point, since N workers with N clocks cannot agree on a
  `stale_after` comparison. `Runtime.__init__` says the same.

The index's known-delta rows for all five flip to **Applied 2026-08-08**, and a new row records
what this PR deliberately did not do: `clock` is inert on both `Runtime` and `build_runtime`, and
removing a public keyword is a breaking change owed its own PR rather than a docstring's.

CHANGELOG carries the two user-visible facts: a log no longer keeps a gap after a dropped report
or a transient append failure, and `EventStorePort` is a breaking change for anyone who
implemented it — eight methods to seven, `append` taking payloads and returning events, both
claims returning what they wrote, `last_seq` gone, `SessionClaim.overridden` carrying events, and
`stale_before: datetime` becoming `stale_after: timedelta`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
sagi5060 and others added 2 commits August 8, 2026 19:28
Review findings on #154.

`_CLAIM_ATTEMPTS` sat at 20, exactly `_CONCURRENT_APPENDS`. The WATCH/MULTI/EXEC loop
is lock-step — one winner per round dirties every other watcher — so N contenders need
exactly N rounds. Measured: N=20 passed 350/350, N=21 failed 60/60. Zero margin, and the
failure mode is a permanently red gate, not a flake. 64 now; 60 contenders pass where 21
used to fail.

`Runtime(clock=...)` was written to `self._clock` and never read. Silently ignoring a
frozen clock is how a caller starts asserting against wall time believing it held time
still — the failure this PR had to strip from ~30 call sites. It warns now; removal is
its own PR (#158), since dropping a public keyword is breaking.

The staleness window is derived from the stamps the store wrote rather than from a fixed
250 ms, so a stall between the two writes widens the window instead of eating it.

`_append` on SQLite no longer takes BEGIN IMMEDIATE for an empty payload list, as
postgres and redis already did not.

Pinned: a run resurrected into `run.interrupted` rather than a terminal event takes its
session back and is listed as pending again — the shape ADR-D11 implies but never states.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#157 squash-merged the snapshot sweep to dev with `examples_from` on top; this branch
still carried `b65b7d4`'s version of the same lines, so the two conflicted. Dev's is a
strict superset — taking it verbatim leaves both sides identical and nothing to resolve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sagi5060
sagi5060 merged commit 0f80595 into dev Aug 8, 2026
1 check passed
sagi5060 added a commit that referenced this pull request Aug 8, 2026
…ntext

Two changes that only make sense together, because the second is what
makes the first reachable by an application at all.

AgentDeck runs agents; it does not model users, organizations or
permissions. Requiring `tenant` and `principal` on every RunContext made
every caller invent values it did not have — a local agent with no tenant
still passed "local", a backend workflow with a workspace but no acting
user still passed "system". Placeholders invented to satisfy an API are
not identity, and carrying them made `tenant` look essential when the
real requirement was only that runs can be kept apart.

`namespace` is that requirement and nothing more: an opaque isolation key
AgentDeck never parses and attaches no meaning to. An application may key
it by workspace, project or business; `None` is a first-class mode, not a
placeholder. `principal` is deleted rather than renamed — nothing ever
read it, and mapping it to Langfuse's user_id rendered a security
principal as the product's user.

A caller no longer builds a context either. RunContext carries the gate,
the reporter and the id the claim protocol addresses runs by; getting one
wrong is a silent no-op, not a type error, since a resume with a fresh
run_id names a run the log has never heard of. So:

    await runtime.run("booking-agent", input, session_id="wa:972...", namespace="business:123")

`run_id` is minted unless supplied, and rides on every event, so callers
read it off the stream — `TurnResult` now does exactly that.

Six fields fail one test: does AgentDeck's own machinery read this, or
only write it down? `trace_id` was a second uuid minted beside `run_id`,
never supplied from outside. `budget` was recorded as if it constrained a
run and enforced by nothing, which is worse than absent. `triggered_by`
and `parent_run_id` were read only by telemetry, and nothing originates a
parent — there are no sub-runs. `deadline` and `idempotency_key` had zero
reads anywhere. Each returns with the mechanism that enforces it.
Removing the first three empties RunContextSnapshot, so it and Budget go,
and run.started carries the ask itself.

Envelope v2, declared: removing required fields is a `v` bump, so the
goldens move deliberately and the v1-reader measurement is parked with
its reason — that reader cannot parse a v2 event by construction.

Judgment ledger:
- Stores encode `None` as the empty key via `RunContext.namespace_key`,
  defined once in core so four stores cannot disagree; `__post_init__`
  refuses an explicit "" so the empty key means one thing.
- SQL stores keep NOT NULL and store the normalized key, because
  `WHERE namespace = NULL` never matches.
- `EnginePort` keeps taking a RunContext. Ports are internal and an engine
  needs the gate and reporter; only the caller-facing API changed.
- The four fake-identity constant pairs are deleted, retiring the
  "local"/"demo" divergence that put one session id in two logs depending
  on the route.
- Because #154 moved stamping into the stores, an event cannot carry a
  foreign namespace — the cross-namespace write guards are unnecessary
  here and were not carried over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sagi5060
sagi5060 deleted the feat/store-assigns-seq branch August 10, 2026 07:27
sagi5060 added a commit that referenced this pull request Aug 11, 2026
… and build_runtime (#208)

* fix(runtime,composition): remove the inert clock keyword from Runtime and build_runtime

ADR-D11 moved timestamp assignment into the store, so `clock` has decided
nothing above the store since #154, which made it accepted-but-inert and
added a DeprecationWarning. This deletes the keyword from both signatures
(and the warning with it) rather than continuing to carry a trap that reads
as "time is held" while deciding nothing. `build_runtime`'s `if clock is
None` fork collapses to one `Runtime(...)` call. Holding time still works at
the seam that owns it: `MemoryEventStore(clock=...)`, `RedisEventStore(clock=...)`.

Breaking change: a caller still passing `clock=` now gets a TypeError instead
of a silent no-op, covered by a new regression test.

Closes #158.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(changelog): add the migration-notes line for the clock keyword removal

The issue asked for a line in the migration notes pointing at the store-side
keyword, not just the Changed entry explaining why it's gone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tests/multiprocess: the crossrun test still asserts overlap instead of the store's promise

1 participant