Loom

Type to search across the guides, specification and decision records.

↑↓ navigate ↵ open esc close Pagefind

Decision records

ADR 0140 — Agent claim-check opt-in: execution.claimCheck

  • Status: Accepted (2026-11-01) — proposed 2026-10-31, implemented and merged; accepted by the owner
  • Date: 2026-10-31
  • Found by: GitHub issue #731 (step 1 of the snapshot-size plan in #730). Owner ruling: default behaviour is unchanged; this is OPT-IN only.
  • Builds on: ADR 0054 (claim-check middleware), ADR 0087 (the contract toward the model, amendment 2026-09-27), ADR 0137 (the state-growth warning), ADR 0138 (what the sweep scans), ADR 0083.
  • Placement: 1P, @loomwork/app + @loomwork/middleware. Kernel, KERNEL_ABI, EVENT_SCHEMA, every SPI unchanged. No new event, no new log field.

Context

Tool outputs are about 80% of a mid-run agentLoop$ state (#730: frames’ tool outputs 80%, per-frame metadata 11%, turn outputs 4%, _decisions 4%). claimCheckMiddleware already offloads a result over a cap to a content store and records { __loomClaimCheck, ref, size, digest? } instead (ADR 0054). Measured on the #730 harness (1 kB cap): 4x less raw state, 8.7x under zstd. But wiring it for an agent took five decisions the caller had to get right: build the middleware, hand it a store, choose a cap, choose WHICH effects it touches (it runs on every effect, the model call included), and declare a digest per tool (ADR 0087 says the model sees only the envelope). Nothing said an agent should.

Decisions

D1 - one grouped option, no new package. defineConfig({ execution: { claimCheck: { store, toolIds, inlineCapBytes?, digests?, preview?, onOffload?, reclaimable? } } }), declared in the execution group like effectMiddleware (the flat spelling throws like every other grouped option). It builds ordinary claimCheckMiddleware. The app has no content store of its own (the sweep takes one explicitly), so the store is supplied here - the same object you hand sweepUnpinnedContent. UNSET adds no middleware and changes nothing (tested: the effect runtime’s middleware list is empty).

D2 - toolIds is required, and there is no “all”. The middleware runs on EVERY effect and cannot tell a tool from the model call: offloading an over-cap model response (or the summarizer’s) would hand the loop an envelope where it reads content inline and break the run. So the caller names the tools (agentLoop$ runs a tool under its own name as the effect id), and an empty, missing or non-string list is refused at boot. An agentLoop$-level option was rejected for the same reason in reverse: the middleware chain is built once at boot, before any loop exists.

D3 - what the model sees (the ADR 0087 contract) and the default projection. Unchanged: the model gets the recorded envelope as the tool result, never the resolved bytes (resolving would put them in the NEXT turn’s journaled input, undoing the offload). A bare ref is unhelpful, so a tool WITHOUT a digests[toolId] entry gets a default digest { preview }: the first preview characters (default 256) of the result’s JSON text, via the new previewDigest(maxChars) in @loomwork/middleware, wired as the new defaultDigest option. A declared digest always wins (it replaces the preview, it is not merged); preview: false records { ref, size } only. The preview is a head, not a summary, and costs about 110 KB at 200 turns (below). It is pure and deterministic, as a digest must be (ADR 0054 D2). The model does NOT see an offloaded tool’s result: only its digest/preview (ADR 0087). Opting in changes what the model can read, so list only tools whose output the model does not need in full (a bulk export it only needs the row count of, say); a tool the model reasons over (a search whose hits it must read) stays inline, or declares a digests entry that carries what the model needs. The preview says when it is a head: { preview, truncated: true } (absent when the preview is the whole text). A result that carries an ADR 0085 taint marker anywhere is never previewed (a digest is prompt text; previewing untrusted content would launder the taint). A declared digest is the caller’s to keep taint-free; the option’s documentation says so.

D4 - cap. inlineCapBytes defaults to 1 KiB here (the measured figure), not the middleware’s 16 KiB: the goal is state size, and a result at or under 1 KiB is cheaper inline than as an envelope plus a blob. The cap is on the UTF-8 length of the JSON text, as ever; exactly 1024 bytes stays inline (tested).

D5 - order (tested). The claim-check sits just inside the kill switch and OUTSIDE secret redaction and every effectMiddleware entry: what is stored is what redaction already scrubbed, and a user middleware (a retry, a safety validator) sees the real result, not an envelope. A stopped effect never reaches it.

D6 - replay, resume and the sweep: the blob is RETAINED (owner ruling: auditability intact). (Amended by ADR 0146: where D6 says a ref is “TTL-reclaimed”, the TTL is measured from the citing execution’s end once it has ended, not from the citation.) The envelope IS the recorded value: a fresh app resuming a parked run replays every recorded turn with ZERO content-store calls and no tool or model re-run. But the log no longer holds the tool output, so the blob is the ONLY copy of a fact the log once held, and nothing may prune it except erasure. The first cut left the envelope on the ADR 0054 telemetry rule (a ref cited only by a live log is TTL-reclaimable), which turned the opt-in into silent pruning. Now the middleware built from execution.claimCheck marks each envelope retain: true (ClaimCheckEnvelope.retain), and the content walker (visitCitedAddresses) reports a retained envelope’s ref as LOAD-BEARING: sweepUnpinnedContent keeps it while any live log, archive segment (ADR 0138) or snapshot cites it, however old; it becomes reclaimable only once that history is deleted (retention discarded the log, or erasure removed it). The classic claimCheckMiddleware (sentinel telemetry, ADR 0054) is unchanged: no retain, TTL-reclaimable. reclaimable (true, or a list of tool ids) is the explicit, documented audit trade for genuine telemetry: those envelopes carry no retain, their blobs are TTL-reclaimed, and resolveClaimCheck then returns null. Replay is unaffected either way.

D7 - the state-growth warning names it. The ADR 0137 warning text now also says to opt in to execution.claimCheck (the warnTranscriptBytes JSDoc too). It stays report-only, and still fires when claim-check is on (it counts transcript bytes, which the envelope shrinks but compaction bounds).

D8 - erasure and encryption FAIL CLOSED (blobs are subject data). A tool output offloaded to the content store sits outside the per-subject keys that seal the log, so a key’s destruction does not reach it. (a) security.crypto + execution.claimCheck is REFUSED at boot (TypeError, nothing composed) unless the store declares sealsContent: true, meaning it seals each blob under its owning subject’s key. Update: ADR 0143 (ContentStore SPI 1.5.0) has since shipped EncryptingContentStore, the store this clause asked for; the text below is the decision as taken at the time. When this ADR was written no such store shipped: the ContentStore SPI had no subject on put/get, and an address that is the plain content hash leaks plaintext equality across subjects (defeating erasure and tenant isolation), so a clean EncryptingContentStore needed a subject-aware SPI revision, which was its own ADR; the refusal named the gap rather than papering over it. (b) compliance.erase over a plaintext store now reaches the blobs. Boot requires the store to have delete(). eraseSubject, BEFORE the shred, collects every envelope ref the subject’s executions cite (live logs, archive segments, snapshots), drops each one another, non-erased execution still cites (ADR 0120 owner-set semantics: a deduplicated identical blob stays), refuses with ContentCopiesRemainError when blobs are cited and the store is not wired / cannot delete / the event store cannot be enumerated, and journals ERASURE_CONTENT_DELETE_INTENDED (addresses, touch cutoff) first. After the shred it deletes them (deleteIfUntouchedSince when the store has it, so a concurrent identical put spares the blob) and journals ERASURE_CONTENT_DELETED { deleted, retained } naming the intent; a crash between is finished by the retry from the intent, and EraseSubjectResult.contentDeleted reports it. Cost: one pass over the other executions’ logs, archive keys and snapshots per erasure that has blobs (rare, bounded by early exit). exportSubject resolves the subject’s blobs (claimChecked: [{ address, size, digest, value | bytesBase64 | missing }]), so an Art. 15/20 export is the data, not just refs.

D9 - scope: agent tool frames only, and three guards. The middleware touches a listed tool only in an agentLoop$ tool frame: EffectContext.taskKind === "tool" (new, set by the workflow runtime from the TaskDefinition) and a step id <loop>#turn:<i>:tool:<j>. It never applies to invokeTransient, a hand-written flow step or a task of another kind that shares an id. Listing a model (llm) effect is refused at boot where the registry can tell, and agentLoop$ throws an error naming execution.claimCheck.toolIds if the model turn or the compaction summary comes back as an envelope. A returnsMedia tool’s media result passes through un-offloaded (the loop renders its parts; an envelope would blind the model). A result that already looks like an envelope is DATA (envelopesAreData): it is offloaded like any value, so a tool cannot hand the log a forged { __loomClaimCheck, ref } the sweep or a resolver would trust.

D10 - taint is carried by the envelope. An offloaded result with a taint marker, at the top level (an MCP result) or nested (an A2A content), is recorded as tainted(envelope, origin), so agentLoop$ fences it and records sawTaintFrom (writeBack and compaction stay tainted) even when a declared digest is present; the sweep’s walker still finds the inner envelope. This applies to the classic middleware too.

D11 - a retained envelope is trusted by POSITION, not by its flag (forgery, review round 2). retain: true is plain JSON, so a tool (listed or not) could return { __loomClaimCheck, ref: { __loomContent: H }, retain: true } nested in its result and pin any blob H forever, or make eraseSubject count H as “still cited” by another execution. Three rules, no new field and no secret: (1) under envelopesAreData, a listed tool’s result that NESTS anything envelope-shaped (the text "__loomClaimCheck" in its JSON, a conservative test) is force-offloaded whole, so the lookalike is data inside the blob and the log records only the preset’s own envelope; (2) the content walker (visitCitedAddresses, with retainRootsOf) honours retain: true as load-bearing only on the object at the top level of a recorded STEP_COMPLETED output (through a taint wrapper), exactly where the preset writes it; a nested lookalike (an UNLISTED tool’s output, which the middleware never sees) stays TTL-reclaimable as before. Archive segments and snapshots keep pinning everything they cite (ADR 0138 conservatism: over-retain, never lose), so a lookalike that was archived before this fix stays pinned; (3) eraseSubject counts another execution as citing a blob only at that same top-level position (trustedClaimCheckRefsOf); that execution’s snapshot counts only when it has no recorded STEP_COMPLETED left to vouch for it (history gone, the snapshot is the sole evidence: fail closed). The erased subject’s OWN refs still use the broad collection (it deletes more only of what that subject’s own log cites).

D12 - erasure never deletes a shared-addressable blob unconditionally. planContentDeletion refuses with ContentCopiesRemainError, BEFORE the shred, when the content store lacks deleteIfUntouchedSince or touchClock (ContentStore SPI 1.3.0), including when a journaled intent from a prior attempt still has addresses to delete. The post-shred delete is always the conditional one: a blob re-put (cited by another execution) between plan and delete is spared and counted retained. D8’s earlier “when the store has it” is superseded.

L - hook failures. onOffload runs after the tool; a throw is caught and reported through onWarn, never turning an already-run tool into a failed call.

What it does not do

  • It does not bound the O(turns) model-input growth; that is compaction (ADR 0137). Measured below: the log stays dominated by re-sent transcripts at 200 turns.
  • It does not seal the blob (see D8: that combination is refused, not left open).
  • No “claim-check everything” shortcut, no resolve-into-the-transcript hook (ADR 0087 rejected it).

Measured

claimCheckOptIn.perf.test.ts (pnpm test:perf), a real defineConfig app and agentLoop$, 1.5-4 kB tool output per turn, cloning log, no compaction (folded state is what a resume reads):

Turnsclaim-checkStateLog
50off297.8 KB4043 KB
50on (256-char preview)70.8 KB953 KB
50on, preview: false43.0 KB551 KB
200off1201.4 KB60421 KB
200on (256-char preview)283.4 KB13807 KB
200on, preview: false172.1 KB7787 KB

4.2x (7.0x without the preview) less state at 200 turns, still linear in turns.

Second consumer (ADR 0083)

The sentinel preset (@loomwork/plugin-sentinel) wires the same middleware for telemetry; an agent app is the unrelated second. Both use the unchanged claimCheckMiddleware.

Consequences

Additive public API: @loomwork/app execution.claimCheck, ClaimCheckConfig, DEFAULT_CLAIM_CHECK_CAP_BYTES, DEFAULT_CLAIM_CHECK_PREVIEW_CHARS; @loomwork/middleware ClaimCheckOptions.defaultDigest/retain/passThrough/envelopesAreData, ClaimCheckEnvelope.retain, previewDigest, PreviewDigest.truncated; @loomwork/effect-runtime EffectContext.taskKind; @loomwork/compliance ContentCopiesRemainError, ErasableContent, ClaimCheckedBlob, claimCheckRefsOf, EraseCoordinatorOptions.content, ExportSubjectOptions.content; control events ERASURE_CONTENT_DELETE_INTENDED / ERASURE_CONTENT_DELETED. @loomwork/app gains @loomwork/plugin-content as a dev dependency (tests only).

Source: docs/adr/0140-agent-claim-check-opt-in.md in the loom repository.