Skip to content
Like what we’re building? Star on GitHub

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 ​

  1. 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 AND stored_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).
  2. 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.
  3. The heuristic dies. storedUnitsRead, importersReadWhole, importersOf, the lib/ walk and head sniffing are removed; the file door no longer reads stored files to route a save (the request's revision and unit_files decide). The unit door keeps saveUnit'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).
  4. 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.UnmarshalFile refuses the mark and (*ast.File).UnmarshalJSON routes every encoding/json decode 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).
  5. 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. .botz bundles refuse manifest edits at their doors (the overlay toggle is the disable).
  6. 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, .botz install/sync, git rewind/merge) remains ticketed and named.
  • The assistant authoring commit drops the EngineUnknown warning 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.