ADR-136 — A bot-source write carries what a whole read issued: stored bytes are replaced only from bytes the reader read whole
- Status: Proposed (2026-10-23; lands with PR #2712 and is reviewed there. The operator absorbed #2678 into #1690 on 2026-10-22 after the round-7 review proved the write-guard class could not converge)
- Date: 2026-10-23
- Deciders: jo (absorb #2678; the raw-edit boundary), Claude (design, the 10-round adversarial loop)
- Serves: #1690, #2678; #2689 names the writers this door deliberately does not serve
Context
The DSL reader carries a read budget (#1690), so a bot past its bounds — or holding one unreadable file — opens as a salvage: a view of what was read, not the program. A client that saw only a salvage could write its truncated content back over the stored bytes; the studio refuses client-side, but the server's guarantee had to hold "whichever client sends them". Through round 7 the defence was a unit-membership heuristic at the save doors (which files could the salvage have reached?): it recopied the loader's rule, leaked a new blind shape at three consecutive rounds (a lib/ nested under lib/, a symlinked directory, a block-scalar head that hides the imports), and its widenings cost real money — a chain-of-30 save reached 1.5 GiB, and one stray non-UTF-8 file refused every other fragment's save. Separately, single-file saves were last-write-wins between open and save, and a re-created bot could be overwritten by a stale write (#2678's two measured sequences).
Decision
- The rule. Every write of an EXISTING stored bot file — the disk file save, the cloud per-file PUT, the bundle PUT, the run-file editor, the assistant authoring commits, the CLI push, the studio's saves — carries the digest (disk: the file's sha256; cloud: the bot's
id+ version ANDstored_sha256) that a WHOLE read of those bytes issued. The issuers are the open doors (single file, unit per-file, examples, parse), the bundle GET, the run-file GET and the save responses. An open that did not read a file whole issues no digest for it; a refused open issues none. A mismatch — bytes changed, version moved, bot re-created — is a 409 naming what changed. Omission from a bundle PUT is a deletion and requires the omitted file's digest. The per-file DELETE carries?bot_id=; by-name DELETE without version stays last-write-wins (documented at the route and in the digest paragraph). - Creations stay free. Save As, import, fork, scaffold, a first push and every new file need no digest — a no-replace rename semantics nothing to protect. The boundary is deliberate: a RAW edit presenting the stored bytes' whole-read digest replaces knowingly (the bundle GET is a whole read of the bytes); the salvage view never issues one, so a salvage cannot ride it.
- The heuristic dies.
storedUnitsRead,importersReadWhole,importersOf, thelib/walk and head sniffing are removed; the file door no longer reads stored files to route a save (the request's revision andunit_filesdecide). The unit door keepssaveUnit's own whole-read discipline; where the file door lands what the unit door would refuse, both the 409 and docs/cloud-rest-api.md say so, and the landed state stays visible (the bot opens short, the cut files digest-less). - The marker holds the document path. Every refused open (size cap, non-UTF-8) and every short unit load marks its display document (
salvage: "not read whole: <code>", reserved, fold-refused, stated in the wire docs);ast.UnmarshalFilerefuses the mark and(*ast.File).UnmarshalJSONroutes everyencoding/jsondecode of a document through the same check — so a marked document decodes nowhere, whatever helper holds it. The static guard holds the mirror-drop class for future code; its charter names its residuals (reflection, out-of-module instantiation,var-held callees — latent, censused at zero). - The engine floor judges the resulting state. A write that deletes a requirement-declaring manifest is refused (the documented forced escape lands with a warning); a manifest the write REPLACES is judged by the text it lands.
.botzbundles refuse manifest edits at their doors (the overlay toggle is the disable). - Breaking change, documented as such. Older clients receive 409 on every update of an existing bot until they send id + digests; the migration notes live in docs/cloud-rest-api.md. Rollback floor: a pre-digest server accepts digest-less writes again — the data-loss class this rule closes reopens on a rollback, so the rule is relied upon only once this PR is the floor.
Consequences
- The cost dies with the heuristic: a chain-of-30 save fell 1535 MiB → 0.1 MiB, 60×190 KB fragments 262 MiB → 0.1 MiB; memory bands are flat across rounds 7-10.
- Every future writer of bot sources inherits the rule by construction (the doors are the chokepoints); a writer outside them (#2689: catalogue regeneration,
.botzinstall/sync, git rewind/merge) remains ticketed and named. - The assistant authoring commit drops the
EngineUnknownwarning the push routes carry (its response has no warnings channel); the launch gate still holds — never a silent pass. - The studio holds the opened snapshot and never refetches at save time; the CLI pulls before it pushes; the operator MCP and the assistant commits present their expected digests.
