ADR-145: A shared fragment is a bundle DEPENDENCY, not a copy — exports.fragments declares it, the install materializes it under lib/, and the parser learns nothing
- Status: Accepted (2026-10-29) — on the clauses below, each backed by a record or a measurement already in the tree. What is named under What is not promised is not decided here: each axis is owed its own decision, taken when the slice that needs it starts. Arbitrated as ARB-75 of the debt-leap lane, on the shape ADR-143 set and ADR-144 just applied: decide what the tree backs, name what it does not.
- Date: 2026-10-29
- Authors: devthejo (session with Claude Opus 5)
- Landed by this change: this record, and the one sentence of the authoring surface that owed it (dsl.md, the
importsection). No code changes with it. Owed by the slices behind it, in this order, each with its own witness: the manifest key with its per-index refusals and its release floor (#1418 lot 2 slice 1), the install-time materialization with its typed miss (slice 2), thegroup goal(condition, cap)exemplar (lot 3). - Relates to: ADR-094 (shared subbot bundles — the two-sided key and the pin this record reuses) · ADR-144 (the same decision for skills; this one is its mirror for the other artefact kind) · ADR-098 §3 (the compilation unit an
importdefines) · dsl.md (the authoring surface)
Context
A .bot can already be several files. import "lib/x.bot" sits at the head of a file and is held to two sets of rules: the portable ones, which need no disk (ImportPathError — relative, /-separated, no .., no NUL, a .bot file; parser_import.go:59, refused as E045, diagnostic.go:47), and the one only the disk settles: the resolved path stays under the bot's lib/ (unit.go:843), where a symlink or a path that resolves out is ErrOutside (unit.go:586). The syntax has a release floor in the ONE table — parser.ImportSince = 3.145.0 (preamble.go:31), pinned into syntaxFloors (profile.go), so validate's C252 and the push admission ask for it together.
What that confinement deliberately cannot express is a brick shared BETWEEN bundles: every path resolves under the importing bot's own lib/, so a second bot that wants the same brick carries a copy. Lot 2 of #1418 said it would wait for "a named second use".
The named use is here, and it is not the one the ticket's own framing expected. Measured at ba8b9990b, by cutting each tracked .bot under bots/ at its top-level declarations and hashing each body:
- Fragment FILES do not duplicate. The 9 production fragments (
bots/{feature-dev,gitops-warden,review-pr}/lib/{schemas,prompts,nodes}.bot, 4 757 lines) hold 9 distinct contents — not one byte-identical pair. Same-named files are unrelated: the largest cross-bot overlap of unique non-trivial lines is 42 of 695 (nodes.bot, gitops-warden vs review-pr), and half of that is Python boilerplate (try:,except,continue). - Named DECLARATIONS duplicate heavily. Over the 51 files outside
bots/testdata/: 176 names carried by more than one file, 714 copies, 570 distinct bodies. Of those names, 59 have a byte-identical group across different files, covering 210 copies — copies a dependency can retire with no CONTENT reconciliation, the bytes having already agreed (the extraction itself stays a reviewed change per bot, below). The largest groups:schema plan_revise_input,schema plan_output,schema forge_auth_stateandschema plan_topology_statein 7 identical copies each,schema verify_planand the threeplan_review_*schemas in 6,tool forge_auth_probein 5; the largest identical BODY istool verify_run(280 lines), inbots/feature-dev/lib/nodes.botandbots/instrument/main.bot. Thesupply-shield/supply-shield-cvepair alone shares some twenty identical declarations.
So the brick to share is the declaration cluster, and sharing is a property a bundle has to express — which three more facts constrain:
- The key already exists, on both sides.
BundleExports(manifest.go:506) carriesWorkflowsonly, withBundleDependencies(:519) as the consumer-side allow-list — "a workflow can only resolve a shared bundle that its own manifest declares" — and ADR-144 has just addedSkillsto the same pair.exports.fragmentsis a FIELD on a live key, not a new key, anddecodeManifestreads withyaml.UnmarshalStrict(manifest.go:1125), so an older binary refuses it loudly. No shipped manifest declaresexports:ordependencies:today — measured over the 38bots/and 3examples/manifests — so a floor is owed by adopters and by nobody else. - The run record already carries every fragment's bytes.
store.WorkflowSourceFile{Path, Text}(run.go:2393) is filled bysourcesOf(engine_run.go:116) with "every file of the unit the run executed — the main and the fragments its imports reach". The cap is 1 MiB for the whole unit (maxPersistedWorkflowSource,engine_run.go:32) and past it nothing is recorded —rewind --autounavailable,fork --new-inputsrefused. The largest unit today isgolden-masterat 606 016 bytes, 58 % of that cap. - The transport carries files, and refuses symlinks.
Snapshot.AddDirwalks the bundle directory and errors on any symlink ("not portable", snapshot.go:63); the cloud snapshot freezeslib/with the rest (dsl.md). A materialized fragment needs no transport of its own, and may not be a link.
Decision
A shared fragment is a dependency, declared on BOTH sides. The producer exports it, the consumer declares what it may resolve and under which name its
importlines reach it:yaml# producer exports: fragments: - id: forge-auth path: lib/forge-auth.bot # consumer dependencies: fragments: - name: forge-auth # the name an `import "lib/<name>.bot"` reaches from: app-dev # the bundle that exports it id: forge-auth # defaults to nameThis is
exports.workflows+dependencies.workflows(ADR-094) andexports.skills+dependencies.skills(ADR-144) for the third artefact kind — same two-sided shape, same allow-list property,.botunderlib/where the skill contract says.mdunderskills/. A producer-only key cannot express consumption, and the consumer is the side that must stop carrying bytes.Resolution is a materialization at install, into the consumer bundle's own
lib/, by COPY. The install writes the resolved fragment where a shipped copy would have been, so the parser, the unit loader,E045and thelib/confinement learn nothing: the materialized fragment is an ordinary file underlib/,import "lib/<name>.bot"keeps the meaning it has today, and every surface that reads the unit —validate,run,resume,fork,rewind --auto, the dispatcher, the studio, the cloud editor, the registry, the catalog — keeps reading what it reads now. Teaching each of them about dependencies would be the engineifthe modularity rule exists to prevent. A symlink is not an option: the snapshot refuses one, andErrOutsiderefuses it at load. A dependency may not SHADOW: a name that collides with a fragment the bundle itself ships, or with another dependency, is refused at install rather than overwritten — a silent overwrite would makeimport "lib/x.bot"mean one thing in the repository and another after an install, which is the drift this record exists to end.Amended 2026-11-03 (#2865), with the discipline clause 4 states: "every surface that reads the unit … keeps reading what it reads now" holds for every surface that reads a bundle from a working tree an install has run in, and for no surface that reads one which never had that install. Two do not: the launch freeze (clause 4's own amendment, 2026-10-29) and the cloud editor, whose bundle lives in a store. There, "keeps reading what it reads now" means reading an
importthe loader cannot follow, so the per-file apply, the write-back, the bot-source write gate and the studio assistant's gate refused the very import the declaration exists for — the author could not even save it, and the state the launch resolves was unreachable from the editor. The cloud authoring surface therefore RESOLVES a declared fragment too, at the bundle-relativelib/<name>.botthis clause defines and nowhere else, lazily (only once a load or a compile has missed a declared path, and then over every declared entry, the freeze's own shape), scoped to the team the bot's own editor path names, and READ-ONLY: the resolved file is marked as the producer's and refused by name by every save, because persisting it is exactly the shadow this clause forbids — a verdict BOTH planes read over every declared entry and only a WRITE refuses on: a READ leaves the shadowed entry unresolved and opens on the author's own file, so a bundle in that state still opens where it is fixed, with the file it has to drop still editable. The three planes share ONE producer rule, per clause 4's last sentence; what differs is only what a MISS reads, and it is read PER ENTRY — the install namesiterion bots sync, the server names what resolving THAT entry found.No new diagnostic code, because the refusal does not belong to the parser. A dependency that cannot be resolved is a typed error at INSTALL, naming the bundle, the fragment and every layer looked in — the
ResolveExtraSkillsshape ADR-144 adopts. At parse time the file is either there (andE045/E046/E047mean exactly what they mean today) or absent, andE046would then say "no such fragment" about a file the author never wrote: a true sentence pointing at the wrong actor. The install is where the dependency exists, so it is where the miss is named.Reproducibility rides the record that exists.
workflow_sourcesis unchanged: a materialized fragment is a file of the unit, so its bytes are recorded by the path that records the others, and a per-fragment digest is a DERIVATION over that record (views are additive) rather than a new durable field — which is why no cloud twin is owed by this record. What the cap owes is named below, and it is a decision about the cap, not about sharing.Amended 2026-10-29, when a cloud launch of a consumer was measured (#2853, ARB-120 of the debt-leap lane): "no cloud twin is owed" is true of the DURABLE record and was read as true of the CAPABILITY, which it is not. Neither carrier that brings a bundle to the server plane carries what an install materialized —
PackDirleaves the local-state file and its members out of a.botz, and a bundle authored in the studio never had them — so the launch froze a consumer whose fragment was absent, and died onE046 import "lib/x.bot": no such fragment— the wrong actor clause 3 named: a true sentence about a file the author never wrote, blaming as corrupt a bundle that is perfectly valid git-native. The launch therefore RESOLVES what a bundle declares while it freezes the collection, through the team-scoped tiers a child workflow's authority comes from, at the path the install would have written — the moment a bundle JOINS the collection, which is before the unit that imports it is parsed. That ordering is what a fragment asks of the freeze and a skill (read by nobody until the run starts) does not. Nothing durable is added, so this clause stands as written: the bytes travel in the run's own snapshot, pinned by its digest, andworkflow_sourcesrecords them as it records every other file of the unit. A producer that reaches no tier stops the launch, naming the consumer, the fragment, the producer and the tiers searched —iterion bots syncis named by the INSTALL's refusal, which is the plane that has a working tree for it to act on. What a found producer is held to is one chokepoint for both planes (skills.go, fragments.go): a rule recopied for the second plane is the rule that drifts.The pin comes from the mechanisms that already distribute bundles —
botinstall/botregistry/marketplace/botsource, pinned the way ADR-094 pins a shared workflow bundle (exact version and content hash). Git-native first: no new fetcher, no second distribution system, and no lock file of this record's own invention.The release floor goes through the ONE table, which learns to read the manifest — once.
parser.ImportSincepins the import SYNTAX; a floor a manifest KEY asks for is whatsyntaxFloors'need(SyntaxRequirements)cannot express, which is ADR-144 clause 6's widening.exports.fragmentsasks for the same widening and must not hand-roll a second check: whichever of the two keys lands first builds the seam, the other declares its pin in it.Every falsifiable claim above is owed by a named unit, because this record proves nothing by itself: slice 1 proves 1 and 6 (decode, per-index refusals, the floor), slice 2 proves 2, 3 and 5 (materialization, the typed miss, the pin), and lot 3's exemplar proves that a consumer bot compiles and runs with a fragment it does not carry.
Amended 2026-10-29, when slices 1 and 2 landed: the claim that a consumer compiles and travels with a fragment it does not carry is proven by fragment_transport_test.go, not by the exemplar — because it cannot be. An unresolved
importis anE046on the unit, and every gallery shape is held to clean parse, compile and bundle diagnostics (TestBotsCreate_EveryTemplateValidates), so the only place in this tree where a consumer legitimately lacks its copy is a test: the witness reads theE046and the empty program first, then the whole unit, the shared declaration inMergedand the snapshot carrying the bytes at the recorded digest. Lot 3's exemplar is therefore self-contained and shows the PATTERN —group goal(condition, cap)with a judge running a deterministic check — where an author looks for it.
Rejected alternatives
- A new import form resolved through the registry —
import "bot://<id>/lib/x.bot", resolved at compile time, with a new diagnostic for an unresolved library and a resolved digest per fragment inworkflow_sources. It is the shape the wave's own table described, and it is refused on four measured grounds. It is a second sharing mechanism for a third artefact kind, recorded hours after the first (ADR-144) — the Nth-variant test says build the seam, not the next variant. It breaksE045's contract ("a path intolib/") for every reader that quotes it, and prose, hints and tests all quote it. It adds a durable field and so owes a cloud twin in the same change, for information the record already holds (Context). And it puts the shared library's bytes inside the CONSUMER's compiled unit, against the 1 MiB cap past which nothing is recorded at all — with the largest bot already at 58 % of it, that is a reproducibility regression dressed as a reproducibility feature. A materialized dependency, by contrast, records exactly what the copy it replaces recorded: the cap moves neither way. - A producer-only
exports.fragments. It reads like the smaller change and cannot do the job: with no consumer declaration there is no allow-list, so a bundle would resolve a name from any installed bundle that happens to export it. The workflow contract settled that already. - Relaxing the
lib/confinement — letting a path reach a sibling bot (../other-bot/lib/x.bot) or follow a symlink into a shared directory. The confinement is a boundary, not an inconvenience:ErrOutsideat load, a symlink refused by the snapshot as unportable, and every escape case pinned by a test today (TestAnImportPathIsHeldToItsRules, import_test.go; "outside the bot'slib/", unit_test.go:125). A path that escapes also travels: the bundle that resolves on one disk fails in the snapshot, in cloud, and in any consumer that installed it. - Declaring the duplication and stopping there — a
skillshare-style inventory of duplicated declarations, with a gate on drift. It would keep 714 copies honest, which is worth having and is what #2357 shipped for skills, but every consumer still carries the bytes and a shared fix is still N edits. A gate measures drift; it cannot remove a copy.
What is not promised
- Sharing a declaration that lives in the middle of a file. A dependency shares a FILE; the 210 byte-identical copies measured above sit inside
main.bots and 1 300-line fragments. Extracting a cluster into a fragment of its own is a reviewed change per bot, with its own witness, never a bulk rewrite — the same clause ADR-144 makes about flattening eleven copies of one skill. The measurement is the ceiling of what sharing could retire, not a migration plan. - A per-fragment digest field, and what happens past the 1 MiB cap. The digest is derivable today for every unit under the cap; past the cap the record holds nothing, and a digest list (tens of bytes per file) is exactly what would survive there. Whether
workflow_sourcesgains that field — and whether a run that busts the cap should record digests instead of nothing — is one decision aboutmaxPersistedWorkflowSource, owed by its own ticket, and deliberately not smuggled into this lane. - That a resume notices a fragment arriving. Named 2026-10-29, when slice 2 landed.
bots.lockpins what a producer published, so a materialized fragment is recorded in the consumer's own note and left out ofContentHashDir— otherwise resolving a declared dependency would read as drift at the fifteen call sites that ask "is this install still what the lock pinned" (thebot://resolver would refuse the consumer for good, andbots synccould never short-circuit again). The sixteenth asks a different question:resume_bundle.goasks whether the bundle changed since a run, and under this rule it does not see a fragment APPEAR between two runs, even though the compiled unit did change. That is the price of the pin keeping its meaning, and it is a decision of its own to revisit — not an oversight. - A token inside a shared fragment. Substituting a per-bundle value would make a fragment a TEMPLATE, and
{{ }}already belongs to the DSL: the same axis ADR-144 leaves open for skills, with the same consequences (delimiters, escaping, which reader expands, whatvalidatelints). Until that decision, a dependency shares only fragments that are shareable verbatim. - A cloud store of fragment CONTENT. What is durable is the run's own record of the files it executed; the server plane is a parallel mechanism, never the place a shared fragment's bytes come from when the tree has them. Still exactly true after clause 4's 2026-10-29 amendment: the launch resolves the producer through the tiers that already distribute bundles and keeps nothing of its own.
- Lot 3's mechanism. The exemplar is
group goal(condition, cap)and nothing more: no DSLgoal:field, no engine API, and no stop condition that lets a model re-read its own transcript — its evaluator has no tool access, and LLM-judges-on-transcript were ruled out for high-stakes bots as gameable. That arbitration stands and is not relitigated here; the judge in the exemplar runs a deterministic check.
Consequences
- The authoring surface now says where sharing lives, so the next author who wants a brick from another bot reaches for a manifest key instead of a
... That sentence is prose: nothing executable depends on this file. - A bundle that adopts a dependency STOPS carrying the file, so the duplication measured here shrinks by one copy per consumer that migrates — and the 59 byte-identical names are where that costs no editorial judgment. Progress is the copies gone, not a count in a report.
- A bundle that declares a dependency is launchable on BOTH planes, and one chokepoint per artefact kind holds a found producer to its terms for both: the install walks the directories a project materializes producers into, the launch resolves the producer through its tiers and asks the same function. What differs is only what a MISS reads, because only one of the two planes has a working tree to sync.
exports.fragmentsandexports.skillsnow owe the SAME floor-table widening. Landing it twice would be the second hand-rolled check clause 6 refuses; the second key to land declares its pin in the seam the first built.- #1418 stays open until lot 3: this record is lot 2's first slice, and a record is not a capability.
