Decision records
ADR 0059 — Streaming content ingestion: putStream + stream-typed claim-check results
- Status: Accepted — shipped (
ContentStore.putStreamoptional SPI method,streamResult/resolveClaimCheckBytesin@loom/middleware) - Date: 2026-07-15
- Builds on: the db-copilot infrastructure requirements (
loom/docs/requirements/2026-07-15-db-copilot-infrastructure.md) (§3 ST, issue #186), ADR 0046 (ContentStore), ADR 0054 (claim-check). - Part of: the db-copilot infra series (ST-1/2). No kernel changes; KERNEL_ABI and EVENT_SCHEMA untouched.
Context — and the recorded rejection this bounds
The copilot’s large captures (table samples, shadow-execution output, lock graphs) are
memory-bound today: ContentStore.put takes full bytes, and the claim-check middleware
JSON.stringifys whole results. The requirements’ ST-R verdict (recorded, not reopened here)
rejected durable kernel streams/pipes — chunks-as-events breaks the fold economics ADR 0054
exists to protect. What remains is the sound half: streaming as a transport on the transient
side of the determinism boundary, with only the outcome durable — the same discipline as
effect execution, warming, and now() injection.
Decisions
D1 — putStream is an optional ContentStore method with an outcome contract (ST-1)
putStream?(stream: AsyncIterable<Uint8Array>): Promise<ContentRef> — the returned ref MUST
be the content address of the full streamed sequence, idempotent by that address, exactly as
if put(concat(chunks)) had been called. How is backend business (requirements §6.3
resolved): the reference implementation hashes while spooling; an object-store backend that
cannot choose its key before completion may hash-while-upload and verify/re-key on finalize.
Conformance pins the contract structurally (streamed address ≡ whole-buffer address, faithful
round-trip, idempotent) and skips backends that omit the method. Node Readables satisfy the
iterable directly.
D2 — Stream-typed tool results: the tool streams, the envelope is the value (ST-2)
A tool returns streamResult(stream, { digest, codec }); the middleware pipes it into
putStream through a byte-counting wrapper — the only thing between the tool and the store,
so the worker heap never holds the payload — and records
{ __loomClaimCheck, ref, size, digest?, codec }. Three deliberate asymmetries with the
classic offload path:
- Always offloads — a stream is a declared-big payload; there is no cap check (and no sound way to measure without consuming).
- The digest comes from the tool, not the per-tool
digestsmap: the bytes are consumed by the upload, so no post-hoc projection exists. The tool computes it while producing (row counts, max values) — still the CC-2 purity contract. - The envelope carries a
codec(default"bytes"): stream content is not JSON.resolveClaimCheckrefuses non-JSON codecs loudly;resolveClaimCheckBytesis the read-side path — same outside-the-determinism-boundary rule as CC-4.
A store without putStream refuses a streamed result with a typed error — the effect fails
loudly rather than silently buffering.
D3 — Determinism unchanged, by construction
The recorded value is the envelope; replay folds it and never touches the store (ADR 0054’s
contract). A crash-recovery re-run re-produces the stream → same bytes → same address → an
identical envelope (putStream idempotence is what makes the at-least-once re-run safe).
Consequences
- Copilot tools can hand back Node streams without buffering; memory ceilings stop being a tool-author problem. The future object-storage backend gets the SPI shape multipart upload wants.
resolveClaimCheckgained a codec guard — a behavioral tightening only for envelopes that could never have parsed anyway (they did not exist before this ADR).- The K10.6 pin/sweep machinery applies to streamed refs unchanged (refs are refs).
Source: docs/adr/0059-content-put-stream.md in the loom repository.