Skip to content

Run leases and effect-host fencing for unattended runs: owner, authoritative expiry, monotonic epoch, fence at commit and admission (#223) - #251

Merged
kikashy merged 6 commits into
mainfrom
run-leases-223
Oct 29, 2026
Merged

kikashy merged 6 commits into
mainfrom
run-leases-223

Conversation

@kikashy

@kikashy kikashy commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

Closes #223. Milestone v0.12.0. Implements ADR-0025 decision 1 on the package RunStore (and ChatStore) ports, in both languages.

What changed

Vocabulary (ADR-0025 and its new amendment). A lease of one store resource has an owner, an authoritative expiry (the store clock plus a requested duration of 1 ms to 24 h, UTC with exact millisecond precision) and an exact epoch, plus state (held / released; expired is decided by the store clock at each check, equal time is expired). The fence {resource, owner, epoch} is the evidence presented at every checkpoint commit and received by the effect host at every admission. ADR-0025 is already accepted; the amendment only fixes the names the decision left open (the fence record, expiry, state, the refusal reasons, leaseLifetime, effectFencing, the epoch encoding). Its review stays with the maintainer.

Epoch encoding. A canonical base-10 string from 1 to 999999999999999999 (18 digits: exact in a signed 64-bit integer and a SQL bigint, never a JSON number, so no reader rounds it). Acquiring past the largest epoch refuses epoch-exhausted; any sign, zero, leading zero, space, fraction, exponent, hex or numeric spelling is invalid.

Lease port. LeaseStore (TS storage/lease.ts, Go context/run_lease.go): acquireLease (checked against the resource's revision; held while an unexpired lease is held; epoch + 1), renewLease (current owner and epoch before expiry; an expired lease is never revived), releaseLease (keeps the epoch), readLease, checkFence. MemoryRunStore and MemoryChatStore implement it with leaseLifetime: 'process'; the callback and legacy chat adapters have no lease port.

Fencing at every commit. commitFenced / commitHistoryFenced check the fence in the same transaction as the revision, and again at publication. fencedRunStore(store, fence, effectHost) (Go FencedRunStore) is the view a durable run writes through, so every claim, result and checkpoint presents the fence. fencedChatStore does the same for chat updates (Go has no ChatStore).

Fencing at effect admission. The EffectHost port receives {version:1, fence, operation, idempotencyKey} and checks the fence itself before performing the effect. The writer routes every non-read host or runtime dispatch under a fenced view through it (storage/writer.ts invoke; Go invokeDurable). ReferenceEffectHost serializes admission, checks the fence against the store's authority, records the key, and refuses. With no effect host, a non-read effect refuses effect-host-unfenced before it runs.

Idempotency. The key is the SHA-256 of the canonical {run, operation, source, tool, requestDigest}: the acknowledged claim's identity, with no epoch. A takeover presents the same key; the host answers already-admitted; the resumed writer still refuses an unacknowledged claim as HostOutcomeUnknown. Nothing is replayed.

Truthful adapters. admitUnattended / unattendedRunStore (Go AdmitUnattended / UnattendedRunStore) refuse by name: store-unsupported, store-lifetime (the memory kinds), effect-host-unfenced.

Records (public surface). New schema contract/schemas/run-lease.json version 1 (root lease; $defs fence, operation, effectAdmission, refusal, capabilityRefusal, epoch, expiry), regenerated record shapes and Go contract types, readers in both languages, handwritten vectors contract/vectors/lease/{lease,fence,admission,refusal,capability}.json covering every member and enum value (held by a new Python probe), the largest epoch, overflow and non-integer refusals. Checkpoint and event formats are unchanged; a fenced run's stored records have the same shape as an unfenced run's.

Conformance kit. runStoreLeaseConformance(now => store) / CheckRunStoreLeaseConformance for a host store kind (desk #609).

Docs. docs/design/storage.md "Run leases and fencing (#223)" for the store-kind implementer and the Runner's reader; the ADR-0025 amendment; README pointers.

Tests (both languages)

Acceptance TypeScript (storage/lease.test.ts) Go (context/run_lease_test.go)
Competing drivers competing drivers: only the lease holder commits and dispatches TestLeaseCompetingDriversOnlyTheHolderCommitsAndDispatches
Takeover, stale and expired commits takeover: the expired and then stale driver commits nothing; the new owner resumes without replay; the store checks the fence again at publication TestLeaseTakeoverStaleAndExpiredCommitsRefuse; TestLeaseTheStoreChecksTheFenceAgainAtPublication
Actual effect-host refusal the effect host itself refuses a fence that expired after the claim committed TestLeaseEffectHostRefusesAFenceThatExpiredAfterTheClaim
Unknown in-flight outcome across restart, no duplicate dispatch restart with an unknown in-flight effect: the takeover never dispatches it again TestLeaseUnknownInFlightOutcomeSurvivesRestartWithoutDuplicateDispatch
Restore does not revive an expired lease restore does not revive an expired lease TestLeaseRestoreDoesNotReviveAnExpiredLease
Capability refusals refuses unattended admission by name…; a fenced run with no effect host refuses a non-read effect TestLeaseUnattendedAdmissionRefusesByName; TestLeaseFencedRunWithoutEffectHostRefusesAnEffect
Disabled-byte parity disabled-byte parity: a fenced run stores the same record shapes and versions TestLeaseDisabledByteParity
Shared vectors and epoch encoding both readers accept every lease, fence and admission…; encodes the epoch exactly TestLeaseSharedVectorsBothReaders; TestLeaseEpochExactBoundedEncoding
Lease port MemoryRunStore passes the reusable lease conformance; the conformance fails a reviving store; chat-scoped lease TestMemoryRunStoreLeaseConformance

New tests are at the store/context level; no adapter-level test was added (the ./adapter/ race budget).

Mutation rows (origin: agent#223, all caught; Go twins applied by hand in a scratch copy, all caught)

L1 a commit accepts a stale epoch · L2 no fence check at publication · L3 the view commits history unfenced · L4 the effect host skips the fence · L5 the writer skips the effect host · L6 renewal revives an expired lease · L7 a takeover reuses the epoch · L8 release forgets the epoch (reset after restart) · L9 the host admits an identity again (replay on takeover) · L10 the idempotency key carries the epoch · L11 a process-lifetime store admits unattended runs · L12 a chat update skips the fence (TS only: no Go ChatStore).

Parity

scripts/parity.sh against the Desk at desk_pin: every difference named. Only new_files changed (storage/lease.ts, storage/lease.test.ts); the touched storage files were already package-new files.

Self-checks

(a) boundary paths untouched. (b) every run-lease member and enum value is carried (Python omission probe). (c) both readers pass every lease vector. (d) 12 rows caught, 11 Go twins caught. (e) refusals are typed errors carrying the versioned refusal / capabilityRefusal record; malformed fences (empty owner, missing epoch, oversized owner) refuse invalid. No run event was added: the ADR adds no members to current published formats. (f) no checkpoint member added. (g) the storage section and the ADR amendment were checked against the code.

Not done, and why

  • No new run event for a lease refusal (the published event inventory stays frozen); the refusal surfaces as the typed error and its record.
  • Metadata and context-snapshot writes are not checkpoint commits and are not fenced.
  • The memory kinds hold leases for their process only; durability of epochs across a host store's own restarts and backups is the host kind's to prove (the conformance kit certifies the port).
  • Host integration (desk #609, runner Treat table reasoning as a discoverable default #147, gateway #316) is not in this repository.

Checks

Candidate: e195f18ed673b8ce0259ab73efd546de029db552 (branch run-leases-223, no uncommitted diff). Command: jpdev check --changed origin/main (every step selected: package, Go and contract paths changed), with the parity step against a shallow Desk checkout at desk_pin.

== 24 passed, 0 failed, 0 skipped, 0 not run ==
record-shapes, contract (444 JSON documents), local-paths, agent-docs, diff-check
python-tests        Ran 179 tests, OK
mutation-rows       519 rows checked, 0 problems; 5 retired
build, release-quality-engine (3 pass), packed-smoke (126 entrypoints)
reference           13 pages match their sources
unit-tests          Test Files 151 passed | 2 skipped; Tests 3832 passed | 32 skipped
certify-vercel / certify-codex / certify-deepagents   passed
differential        150 cases passed
go-module-path, gofmt, go-vet, go-generate, go-test, go-race (861 s), harness-check (202 legs, 94 agent checks)
parity              every difference is named

Mutation rows run in a scratch copy: node scripts/mutation-check.mjs --row "lease fence": 12 rows chosen, 12 caught. Go twins (scratch copy, go test -run Lease ./context/): L1–L11 caught. The first full run failed only reference (event-reference line numbers moved in storage/writer.ts); the second commit regenerates that page. CI owns the exact-head gates and the push-to-main mutation table.

🤖 Generated with Claude Code

kikashy and others added 2 commits October 29, 2026 03:52
A lease of one store resource has an owner, an authoritative expiry
decided by the store clock and an exact epoch: a canonical base-10
string from 1 to 999999999999999999 that never travels as a JSON number.
Acquire is revision-checked, renew needs the current owner and epoch
before expiry (an expired lease is never revived), and release keeps the
epoch, so every later acquisition is strictly greater.

The fence {resource, owner, epoch} is checked by the store in the same
transaction as every checkpoint commit (and again at publication), and
by the effect host itself at every non-read host or runtime admission.
The effect's idempotency key is the SHA-256 of {run, operation, source,
tool, requestDigest} with no epoch, so a takeover cannot turn an unknown
operation into a new action. Memory and callback kinds and unfenced
effect hosts refuse unattended admission by name.

New versioned records (contract/schemas/run-lease.json) with readers in
both languages, handwritten vectors in contract/vectors/lease/, the lease
conformance kit, the ADR-0025 amendment naming the open members, the
host-facing storage section and twelve mutation rows with Go twins.
Published checkpoint and event formats are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com>
@kikashy kikashy added this to the v0.12.0 milestone Oct 29, 2026
kikashy and others added 2 commits October 29, 2026 04:36
Carries #250 (dialect inventory paging). mutation-rows.json keeps main's agent#243 rows, then agent#223's; record shapes, the Go contract types and the event reference are regenerated by their generators.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com>
…rcing kits (#223)

The security review of a9d9e3b found one HIGH, three MEDIUM and one LOW.

- HIGH: the writer took its read flag from readingTool, which frames
  every source's answer as data, so a gateway, MCP or custom act was
  recorded as a read, never reached the effect host, ran under an expired
  lease and ran again after a takeover. durableReadingTool decides by
  effect: a source tool is a read only with effect class read, and an
  owner-declared act or propose effect is never a read. Source reads and
  the receipt quarantine are unchanged.
- MEDIUM: a leased run's chat-snapshot commits present its fence
  (commitContextSnapshotFenced), so a driver that lost its lease cannot
  release the turn its takeover restores.
- MEDIUM: both lease kits now refuse stores whose checkFence, renew,
  release, fenced history commit or fenced snapshot commit accept a stale
  epoch (also the same owner's earlier one), another owner, a released
  lease or a mismatched resource. A new effect-host conformance kit;
  unattended admission accepts only the reference host or a host
  certified by certifyEffectHost, never a declaration.
- MEDIUM: tests pin runtime-kind effects through the effect host and the
  host's resource-mismatch refusal.
- LOW: impossible expiries refuse as invalid in TypeScript, with vectors.

Six mutation rows (L13-L18) and five Go twins, all caught; storage docs
updated, including that the fenced view is passed unwrapped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com>
@kikashy

kikashy commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Verdict at 67e25ed: merge once the pending check job passes. Finding 1 (HIGH) is fixed. What remains is 0 HIGH, 2 MEDIUM and 5 LOW, all for the pre-release batch. See "Re-read at 67e25ed" at the end.
First read at a9d9e3b (kept below as history): do not merge until finding 1 is fixed in this PR. 1 HIGH, 3 MEDIUM, 4 LOW.

Review record: agent PR #251 (issue #223), run leases and effect-host fencing

Candidate and toolchains

  • Judgment-Pack/judgment-pack-agent PR Run leases and effect-host fencing for unattended runs: owner, authoritative expiry, monotonic epoch, fence at commit and admission (#223) #251, head a9d9e3b1a3e4163b9b5575730700d60af647564f (git rev-parse HEAD in a fresh clone). That head is the build 2665e0f, the reference regeneration e195f18, and one merge of main at 9c53071.
  • PR CI at this head: check, contract, dependency-audit, every row applies, rows plus sample, and DCO all pass. Merge state is CLEAN.
  • Node v22.23.1 (npm 10.9.8) and vitest 5.0.3, under the kit's resident-memory cap (6000 MB). The detached mutation runs used a process-tree cap of the same size.
  • Go 1.26.10 with GOFLAGS=-mod=readonly, GOCACHE in the room, ulimit -v 8000000, timeout, and the machine lock. Python 3 stdlib only.

A Claude Opus review over a Claude Opus build; not a cross-vendor claim. Clean room: I did not use the builder's workspace (I only measured its dependency folder's size, to plan disk use) and did not contact the builder. Nothing was posted, pushed or edited outside the room.

Method

  • Read issue Run leases and effect-host fencing for unattended executions #223, ADR-0025 and its Run leases and effect-host fencing for unattended executions #223 amendment, the PR body, and the whole diff: storage/lease.ts, go/context/run_lease.go, the store, writer, chat and conformance changes, docs/design/storage.md, the schema and the vectors.
  • Traced every writer dispatch kind, and where its read flag comes from, in both languages.
  • Wrote probes. They are not committed: <scratch>/work/review251.probe.test.ts (TypeScript, P1–P16) and <scratch>/work/review251_probe_test.go (Go, G1–G9). They go through the real entry points:
    • contextualSession / ContextualSession, with the session's wrapped host tools;
    • the writer's invoke / InvokeDurable;
    • MemoryRunStore;
    • both conformance kits.
  • Generated 113 boundary inputs (<scratch>/work/gen_boundary.py) and fed them to both readers, then diffed the decisions.
  • Commands:
    • TypeScript: from packages/harness, REVIEW_BOUNDARY=<scratch>/work/boundary.json REVIEW_BOUNDARY_TS=… REVIEW_REPORT=… rsscap.sh 6000 600 -- npx vitest run storage/review251.probe.test.ts, with the probe file copied in and removed afterwards.
    • Go: from go/, under the lock and caps, go test -count=1 -run TestReview -v ./context/, and go test -race -count=3 -run 'TestReviewConcurrentDriversHappensBefore|TestReviewLostAck|TestMemoryRunStoreLeaseConformance|TestLeaseCompeting' ./context/.
    • Baselines at the head: npx vitest run storage/lease.test.ts gave 14/14, and go test -count=1 -run Lease ./context/ gave ok.

Probes

"Plain" is a non-read host tool without a source (the PR tests' save_object). "Source act" is an ADR-0006 tool: gateway source, effect class act, approval policy allow, called as expenses__submit. The takeover re-issues the same call with the same arguments, as the PR's own tests do.

# Item Probe Result
P1 / G1 1 Non-read call under a fenced view, with a counting effect host Plain: 1 execution, 1 admission. Source act: 1 execution, 0 admissions, and the claim is stored with read:true. Same in both languages
P2 / G2 1 The store clock jumps 2 min right after the claim commit, before the dispatch. Then worker-b acquires Plain: the host refuses expired, 0 executions, the claim stays claimed. Source act: the expired driver performs the act (1 execution, no host refusal) while worker-b holds epoch 2
P3 / G3 4 Effect in flight, driver abandoned, clock +2 min, worker-b acquires, resumes, re-issues the call Plain: HostOutcomeUnknown, 1 physical dispatch. Source act: dispatched again, 2 physical dispatches. The rows are unknown/read=true and result/read=true
P4 1 Release, then re-acquire by the old owner Epoch 1 then 2. The old view's call, a direct commitFenced and a host admission with the old fence all refuse stale-epoch. 1 execution
P5 / G4 1 Delegated child binding (childDurability, Go child RunBinding) under the root's view Admissions carry the root fence (resource and operation.run are both the root run). No child lease exists. After takeover, the child's next effect is refused stale-epoch
P6 / G5 3 After takeover, the expired driver closes its StoredChatScope, then worker-b restores The stale commitContextSnapshot commits: the chat revision advances. The takeover's restore is refused ContextTurnBindingInvalid in both languages
P7 3 Metadata commit by an expired holder It lands. Worker-b's next fenced history commit still commits. Metadata (feedback, capture) is not read on resume
P8 / G7 5 Non-enforcing store kinds run through runStoreLeaseConformance / CheckRunStoreLeaseConformance These pass both kits: checkFence that checks expiry only (ignores epoch, owner and release); commitHistoryFenced that ignores the fence; renew that ignores the epoch; release that ignores epoch and owner. A durable declaration with per-instance epochs also passes the TS kit. Caught: a non-atomic acquire (lease.acquire.atomic)
P9 / G6 1 Randomized concurrent drivers doing acquire, renew, release, commit and admission. TS: 5×60 ops. Go: 8 goroutines × 400 ops, and -race ×3 0 commits or admissions by an epoch older than one already acquired. Epochs strictly increasing, unique and contiguous (up to 136 acquisitions). No data race
P10 2, 6 113 inputs through both readers: epochs 01, +1, 1, 1 , 1.0, 1e0, 0x1, 0, −1, 18 and 19 digits, int64 max, a JSON number, a full-width digit, 1\n; versions; members; duplicate keys; identifiers; expiries; admissions; next; compare 107 agree. Exact encoding holds: the maximum is accepted, the next refuses epoch-exhausted, and every other spelling refuses invalid. Comparison is numeric (9 < 10; 18 digits > 17 digits). Differences: TS throws RangeError for 4 impossible expiries (finding 5); Go accepts a lone-surrogate owner (finding 6); and by API, Go's NextLeaseEpoch("") is 1
P11 / G8 4 (a) Claim-commit acknowledgement lost. (b) Result-commit acknowledgement lost. (c) Effect admitted 1 ms before expiry, finishing after it (a) 0 dispatches; takeover gets HostOutcomeUnknown. (b) 1 dispatch; takeover gets the stored result. (c) 1 dispatch; takeover gets HostOutcomeUnknown. Never a replay (plain tool)
P12 1, 2 Memory store restart A new instance gives the same owner epoch 1 again, and the old fence is accepted. This is the declared process lifetime; admitUnattended refuses the memory kinds
P13 5 Effect host that declares enforced and performs without checking admitUnattended admits it, and a stale fence is performed. No effect-host conformance exists
P14 5 Which commit method the writer calls on a history store commitHistoryFenced 3 times, commitFenced 0 times
P15 1 A host wraps the fenced view in a plain delegating object Commits stay fenced, but the effect host is never called: the expired driver performs the effect
P16 / G9 1 Clock skew and caller-supplied times Expiry comes from the store clock (1 h skew). An expiry member in the acquire is refused invalid. Durations 0, −1, 1.5, NaN, "1000" and 86 400 001 are refused; 1 and 86 400 000 are accepted
R1 6 Records and the merge Historical record shapes are identical. Only run-lease.json is added to the current shapes; the TS and Go copies are identical. No existing schema or vector changed. The schema patterns equal the reader patterns. The merge's file set equals the build's. #242 (7 rows) and #243 (14 rows) are unchanged and their test files untouched. docs/reference/events.md changed only in line numbers

Probe commands

TypeScript runs npx vitest run storage/review251.probe.test.ts -t "<id>" from packages/harness. Go runs go test -count=1 -v -run '<name>' ./context/ from go/.

Probes TypeScript filter Go test
P1–P3 / G1–G3 -t "P1 ", -t "P2 ", -t "P3 " TestReviewEffectHostAdmissionByToolClass/G1-…, /G2-…, /G3-…
P4 -t P4 none
P5 / G4 -t P5 TestReviewDelegatedChildUnderTheRootLease
P6 / G5 -t P6 TestReviewStaleContextSnapshotAfterTakeover
P7 -t P7 none
P8 / G7 -t P8 TestReviewKitStrength
P9 / G6 -t P9 TestReviewConcurrentDriversHappensBefore, also with -race -count=3
P10 -t P10 TestReviewBoundaryDecisionsGo, then a diff of the two decision files
P11 / G8 -t P11 TestReviewLostAcknowledgementsAndAdmittedBeforeExpiry
P12–P16 / G9 -t P12 … -t P16 TestReviewDurationBounds

R1 compares git diff --name-only 9c53071 a9d9e3b with git diff --name-only 3e0662a e195f18. It also compares record-shapes.json (both copies) and mutation-rows.json as JSON at both revisions.

Mutants

Rows ran through mutation-check.mjs in its own scratch copy. Go twins and my own Go mutants ran in a scratch copy of go/ and contract/ with go test -run Lease ./context/.

Mutant TypeScript Go twin
L2: no fence check at publication caught (store checks again at publication) caught (TestLeaseTheStoreChecksTheFenceAgainAtPublication)
L4: the host skips the fence caught (competing drivers; expired after claim) caught (TestLeaseCompeting…, TestLeaseEffectHostRefuses…)
L7: a takeover reuses the epoch caught (conformance; takeover; restore) caught (conformance, takeover, restore)
L9: the host admits an identity again caught (unknown in-flight) caught (TestLeaseUnknownInFlight…)
M1: equal time is not expired (>= → >) caught (conformance, takeover, restore, chat) caught (conformance, takeover, restore)
M2: the host drops its resource-mismatch check survived (finding 4) survived
M3: the writer routes host effects only, not runtime survived (finding 4) survived
M4: the next epoch goes through a floating-point number caught (epoch vectors) caught (TestLeaseEpochExactBoundedEncoding)
M6: a view without an effect host falls open caught (no-host case) caught (TestLeaseFencedRunWithoutEffectHost…)
K1–K5: non-enforcing store kinds against the kit (P8 / G7) 4 survive, 1 caught 4 survive, 1 caught
  • Totals: all 4 rows caught, and all 4 Go twins caught. Of my mutants, 3 were caught and 2 survived, in both languages.
  • Why M2 and M3 count as survivors: mutation-check.mjs reported both as "caught: whole". The only failures were 15 tests that fail identically in an unmutated scratch copy, built the same way the script builds it (finding 8).
  • The Go verdicts are complete: only the lease test files build a fenced view or an effect host, so running -run Lease is the whole relevant suite.

Findings

# Finding Sev Evidence What to do
1 An ADR-0006 tool with a source and effect class act runs as a read under a fenced view. It never reaches the effect host, an expired driver can still perform it, and a takeover replays its unknown outcome. The cause: readingTool / ReadingTool return true for every tool with a source except a proposal (packages/harness/framing.ts:97, go/context/framing.go:103). That answer is about framing the result as data, but the session passes it as the writer's read (context/session.ts:1002, go/context/run_writer.go:1816). The writer then skips both the effect host (storage/writer.ts:765, run_writer.go:1409) and its unknown-claim refusal (storage/writer.ts:734, run_writer.go:1288). The classification predates #223, but it makes #223's claims false for gateway, MCP and custom act tools: "every non-read effect is admitted by the effect host" and "a takeover never replays it". Even a routed call would get a new random claim id, so the host's idempotency key could not dedupe the replay either HIGH P1/G1: 0 admissions. P2/G2: the expired driver performs the act after the claim. P3/G3: 2 physical dispatches of one act. The plain tool passes all three Derive the writer's read from the effect class or taxonomy (read / read-external only) and keep readingTool for framing. Add the source-act case to the lease tests in both languages: admission, expiry after the claim, and takeover
2 The declared exclusion "context-snapshot writes are unfenced" matters. A driver that lost its lease can close its stored chat scope (context/storedChat.ts:53 close(), which calls the view's unfenced commitContextSnapshot), and the write is accepted. restoreStoredChatScope reads the latest chat snapshot (storedChat.ts:74, go/context/stored_chat.go:259), so the takeover finds the run's turn released and cannot resume. Plan state, unknown-outcome and idempotency records live in the fenced checkpoint and are not affected. History staging is content-addressed, and metadata was harmless (P7) MEDIUM P6/G5 Check the view's fence before commitContextSnapshot, or make a stale writer's close refuse. Add a takeover test for it. Or at least document that a host must not close the scope after a lease refusal
3 The conformance kits do not certify what the writer and the effect host rely on. checkFence is checked only for a malformed fence and for expiry. commitHistoryFenced is never exercised. Renew and release are never tried with a stale fence. A store whose checkFence checks expiry only, or whose commitHistoryFenced ignores the fence, passes both kits. Yet the writer commits only through commitHistoryFenced on a history store, and the reference host admits through checkFence. There is no effect-host conformance at all, and admitUnattended trusts the leaseLifetime and effectFencing declarations MEDIUM P8/G7, P13, P14 Add to both kits: checkFence with a stale epoch, other owner and released lease; the same matrix through commitHistoryFenced; renew and release with a stale fence; optionally a reopen step for durable. Add an effect-host conformance: stale, expired, other-owner, resource-mismatch, already-admitted
4 Two safeguards are unpinned in both languages. Runtime-kind effects can be dropped from effect-host routing (M3), and the host's resource-mismatch check can be removed (M2), with every lease test still passing MEDIUM Mutants M2 and M3 Add a runtime action under a fenced view with an expired fence, refused at the host. Add an admission whose fence resource differs from operation.run
5 TS readRunLease throws an untyped RangeError instead of LeaseRefused('invalid') for impossible expiries (month 00 or 13, day 00, second 60), because validExpiry calls toISOString() on NaN. Go refuses them as invalid. Both still refuse LOW P10 Check Date.parse for NaN first, and add a vector
6 Go readers accept a lone-surrogate identifier (decoded to U+FFFD) that TS refuses. This predates the PR: it comes from the shared storage-id rule LOW P10 Refuse it in Go, or add a vector that states the rule
7 Effect admission depends on object identity (the leased WeakMap, Go leaseViewOf). If a host wraps the view, commits stay fenced but effects run without the host LOW P15 Fail closed, or state in storage.md that the view must be passed unwrapped
8 (Pre-existing tooling.) The whole-suite fallback of mutation-check.mjs runs in a scratch copy without corpus/, scripts/ and go/. 15 tests fail there unmutated (ENOENT), so any row its selection misses is reported "caught: whole" LOW An unmutated copy fails the same 15 tests; M2 and M3 were reported caught this way Copy what those tests read, or exclude them from the fallback

What was not established

  • Host store kinds (desk #609), gateway #316 and runner Treat table reasoning as a discoverable default #147. I did not establish whether epochs are durable across a host store's restarts and backups; the kit cannot test it.
  • Cross-process races. All probes are in-process (goroutines and interleaved promises) with injected clocks.
  • Adapter level. No engine loop (vercel, codex, deepagents, eino) was resumed under a lease. Takeover is modeled as in the PR's tests: re-issuing the same call through the session's wrapped host tool. Whether each engine re-issues an in-flight call on resume was not checked.
  • Finding 2's private-scope restore path, where a stale snapshot might be adopted rather than refused, was not probed.
  • Model and agent dispatches after lease loss are gated only by the fenced claim commit, by design; provider spend after expiry was not measured.
  • The full suites, certification and the whole-module race run are CI's (green at this head). I ran only the lease tests in both languages, my probes, and -race on the lease and concurrency tests.
  • Duplicate JSON members: both readers take the last value and agree with each other; whether ADR-0024 requires refusing them was not settled.

Re-read at 67e25ed

This is the one re-read the one-round rule allows. A Claude Opus review over a Claude Opus build; not a cross-vendor claim. Nothing was posted, pushed or edited outside the room.

Candidate

  • Head 67e25edf79aec691f4f087f2d719f9339e00805f. It is one commit on a9d9e3b ("Lease fix round"): 19 files, +1094 −47, no new merge of main. I read the whole delta.
  • CI at this head when re-read: contract, dependency-audit, every row applies, rows plus sample, and DCO pass. check was still pending.
  • Toolchains and caps are the same as at the first read.

Dispositions

# Disposition Evidence at 67e25ed
1 (HIGH) Fixed The writer's read flag now comes from durableReadingTool (TS storage/writer.ts, Go run_writer.go). A tool with a source is a read only when its effect class is read, and an act or propose taxonomy is never a read. In both languages a source act is now admitted once by the host. When the lease expires after the claim, the host refuses expired and nothing runs. After a takeover, the outcome is HostOutcomeUnknown with exactly 1 physical dispatch (P1–P3, G1–G3). Row L13 and its Go twin are caught by the new source-act tests (gateway, MCP and custom)
2 (MEDIUM) Fixed The view's commitContextSnapshot now goes through commitContextSnapshotFenced. A stale close() is refused stale-epoch, the latest chat revision stays the bound one, and the takeover restores and resumes (P6, G5). L14 and its twin are caught
3 (MEDIUM) Fixed in part; residuals R1 and R2 below Both kits now catch the four store kinds that passed at a9d9e3b, plus an unfenced snapshot (P8, G7). A host that declares enforced without checking is refused by admitUnattended (P13). L17 and its twin are caught
4 (MEDIUM) Fixed L15 (= M3) is caught by "a runtime-kind effect under a fenced view is admitted by the effect host". L16 (= M2) is caught by "the effect host refuses a fence for another run" and by the reference host's conformance. The Go twins are caught by TestLeasePinnedSafeguards/runtime-effect-through-the-host and /host-resource-mismatch
5 (LOW) Fixed No reader throws an untyped error on the 113 boundary inputs. 111 agree; the two left are finding 6 and Go's NextLeaseEpoch(""), an intended API difference
6 (LOW) Open, as before Go still accepts a lone-surrogate identifier
7 (LOW) Documented storage.md now says to pass the view unwrapped. The behavior is unchanged (P15)
8 (LOW) Open; tooling that predates this PR Out of this PR's scope

Re-read probes

These are the same probe files with P17–P21 and G10–G11 added. At this head all 36 TypeScript probes and all Go probes pass. G11 records its results without asserting.

# Scope Probe Result
P1–P3 / G1–G3 1 As at a9d9e3b: source act and plain tool See finding 1 above. The plain controls are unchanged
P17a / G10 2 Propose effect without a source (taxonomy propose, effect proposal) Admitted by the host and recorded read:false. After expiry, a new call is refused expired and nothing runs. An identical repeat returns the stored result
P17b / G10 2 Source propose (effect class propose) Held for the person's review. Never dispatched, never admitted
P18 / G4 2 A delegated child session calls a source act (TS); a child binding's effect (Go) Dispatched under the root fence (resource and operation.run are both the root run). No child lease. After takeover, refused stale-epoch
P19 2 Approval continuation: a service-identity act is asked, the paused state committed, the run resumed with the decision, then runApproved Current lease: it runs once, with 1 admission. Lease expired before runApproved: LeaseRefused: expired at the claim, 0 executions, 0 admissions. Go was checked by reading the code only: session.go hands the declared tool to ExecuteDurableHost
P20 / P20b / G10 2 Missing, unknown, case-variant (READ) and contradictory effect classes All are effects (fail closed). A declared unknown class refuses the whole session ("legacy effectClass contradicts owner taxonomy"). A source read or read-external stays a read
P8 / G7 3 Non-enforcing store kinds against the new kits Now caught: the four from the first read, an unfenced snapshot, and a non-atomic acquire. Still pass both kits: a commitHistoryFenced that checks epoch, owner and release but not expiry, and the same for commitContextSnapshotFenced (R1). The TS kit also passes a durable declaration with per-instance epochs (declared out of scope)
P21 / G11 3 Is certification evidence bound to the host it admits? The evidence does not transfer to another instance or a subclass. But it does not bind the admitted host either; the details are in R2
T242 / T243 4 #242 and #243 at this head TypeScript: receiptQuarantine, gatewayReceipt, toolSources, the five dialects files and lease, 9 files with 278 passing; 1 file and 3 tests skip without CI's fixtures. Go: -run 'Quarantine|Receipt|Dialect' in context, adapter/eino (12 GB cap) and contract all pass. All 7 #242 rows are caught by their selected tests. Only source act/propose moved, from read to effect; no source read became an effect, and nothing moved from effect to read

Re-read mutants

Every result below was decided by the selected tests, not by the whole-suite fallback.

Mutant TypeScript Go twin
L13: a source act is a read again caught (source-act tests: gateway, MCP, custom) caught (TestLeaseASourceActIsAnEffectNotARead/…)
L14: the chat snapshot commits unfenced caught (stale close refuses) caught (TestLeaseAStaleDriverCannotReleaseTheTurnItsTakeoverResumes)
L15 = M3: runtime effects bypass the host caught (pinned runtime case) caught (TestLeasePinnedSafeguards/runtime-effect-through-the-host)
L16 = M2: the host drops its resource check caught (pinned case and reference-host conformance) caught (TestLeasePinnedSafeguards/host-resource-mismatch, the kit test)
L17: admission trusts a declaration caught (kit tests) caught (TestLeaseKitsRefuseWhatDoesNotEnforceTheFence)
#242 Q1–Q6 and the salts row 7 of 7 caught not re-run

Findings that remain at 67e25ed

# Finding Sev Evidence What to do
R1 The store kits never test expiry on commitHistoryFenced or commitContextSnapshotFenced. A store that checks epoch, owner and release on those ports but not expiry passes both kits, and the writer commits only through commitHistoryFenced (P14). Such a store would let an expired holder keep committing claims and model results until a takeover, and could starve the takeover's revision-checked acquire. External effects stay fenced, because expiry is tested on checkFence MEDIUM P8 / G7 Add expired cases for both ports to both kits
R2 Effect-host certification does not bind what it certifies. (a) The kit never makes the authority fail, so a host that performs when the authority errors passes, is certified over an unavailable authority, and performs; ADR-0025 says unavailable ownership evidence closes dispatch. (b) The certified instance is a second product of the factory that the kit never ran, so a factory can return a non-enforcing host for the real authority; it is certified, admitted, and performs on a stale fence. (c) In TS, a certified host's dispatch can be replaced afterwards and the host stays admitted. (d) In Go, certifying a comparable value certifies every equal value. (e) Admission does not tie the host's authority to the run store, so a ReferenceEffectHost over a no-op authority, or over another store, is admitted MEDIUM P21 / G11, both languages except (c) TS only and (d) Go only Add an authority-error case (refuse, perform nothing) to both kits. Certify the exact instance the kit exercised, or run the kit on the returned host through a kit-controlled wrapper of the real authority. Have admitUnattended check that the reference host's authority is the store, or a certified enforcement service. Key Go's registry by pointer only
N1 New in the delta: Go's proven-host registry (provenHosts, a sync.Map) keeps every NewReferenceEffectHost and every certified host forever, together with its authority and its admitted-key map. After 1000 short-lived reference hosts and two GCs, all 1000 were still there. TS uses a WeakSet LOW G11 Use weak keys (weak.Pointer) or an unexported marker on the host instead of a global registry
N2 Found by reading the code; predates #223. The Go agent adapter decides its pre-effect checkpoint save by the framing rule (go/adapter/agent/invoke.go:793, ReadingTool), so a source act skips the save that a plain effect gets. The act itself still goes through the fenced claim and the host LOW Code read, not probed Use durableReadingTool there as well
6, 7, 8 As in the first read LOW

What the re-read did not establish

  • CI's check job, which was pending.
  • From the first read, still open: resuming a real engine loop under a lease, races across processes, and host store kinds.
  • The Go approval continuation was read, not probed.
  • The fixture-gated tests (the dialects runtime executable and the Gateway fixture), which skip locally.

github-actions Bot and others added 2 commits October 29, 2026 14:30
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com>
@kikashy
kikashy merged commit 82807a2 into main Oct 29, 2026
8 checks passed
@kikashy
kikashy deleted the run-leases-223 branch October 29, 2026 15:00
@github-actions

Copy link
Copy Markdown
Contributor

🎉 Thank you for your contribution to Judgment Pack!

If you’d like another task, choose a path that interests you:

kikashy added a commit that referenced this pull request Oct 29, 2026
Conflicts resolved: mutation-rows.json keeps main's agent#223 rows, then the
agent#252 rows; both record-shapes files regenerated with the generator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com>
kikashy added a commit that referenced this pull request Oct 29, 2026
…test expiry on the fenced commit paths (#253) (#257)

* Run leases: bind effect-host certification to the admitted instance; expiry on the fenced commit paths (#253)

The v0.12.0 pre-release batch from the security re-read of #251.

- R1: both lease kits now refuse a store whose commitHistoryFenced or
  commitContextSnapshotFenced accepts an expired fence, at equal time
  and after.
- R2: the effect-host kit makes its authority fail, and a host that
  performs then fails it. certifyEffectHost / CertifyEffectHost call
  the factory once, over an authority that answers from the kit until
  the checks pass and from the real authority after, and admit that
  exact instance: a frozen TypeScript wrapper that refuses once the
  instance's dispatch is not the one checked, a Go *CertifiedEffectHost
  whose identity is the evidence. The reference host's dispatch is
  sealed on the instance. Unattended admission accepts only these, and
  only when their authority is the run's own store.
- N1: Go keeps no registry of hosts; admitted hosts are collectable.
- N2: the Go agent adapter's pre-effect save follows DurableReadingTool.

Nine mutation rows (agent#253) and ten Go twins, all caught; L17
re-anchored. Storage docs updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com>

* Regenerate the reference pages for the moved lines (#253)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com>

* Run leases: admit exactly the dispatch certification ran; refuse a copied reference host (#253)

From the narrow re-check of #257. TypeScript certification reads the host's dispatch once, runs the kit through that function and admits only it, so a getter or a Proxy trap cannot show the kit one function and admission another; the reference host's authority, admitted set and queue are #private fields. In Go only the pointer NewReferenceEffectHost made is evidence (a self pointer), not a shallow copy that would share its key map without the lock. Rows C1-C3 added; L4, L9 and B4 re-anchored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com>

---------

Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com>
Co-authored-by: Claude Opus 5.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.

Run leases and effect-host fencing for unattended executions

1 participant