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

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 import section). 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), the group 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 import defines) · 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_state and schema plan_topology_state in 7 identical copies each, schema verify_plan and the three plan_review_* schemas in 6, tool forge_auth_probe in 5; the largest identical BODY is tool verify_run (280 lines), in bots/feature-dev/lib/nodes.bot and bots/instrument/main.bot. The supply-shield / supply-shield-cve pair 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) carries Workflows only, with BundleDependencies (: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 added Skills to the same pair. exports.fragments is a FIELD on a live key, not a new key, and decodeManifest reads with yaml.UnmarshalStrict (manifest.go:1125), so an older binary refuses it loudly. No shipped manifest declares exports: or dependencies: today — measured over the 38 bots/ and 3 examples/ 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 by sourcesOf (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 --auto unavailable, fork --new-inputs refused. The largest unit today is golden-master at 606 016 bytes, 58 % of that cap.
  • The transport carries files, and refuses symlinks.Snapshot.AddDir walks the bundle directory and errors on any symlink ("not portable", snapshot.go:63); the cloud snapshot freezes lib/ with the rest (dsl.md). A materialized fragment needs no transport of its own, and may not be a link.

Decision ​

  1. 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 import lines 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 name

    This is exports.workflows + dependencies.workflows (ADR-094) and exports.skills + dependencies.skills (ADR-144) for the third artefact kind — same two-sided shape, same allow-list property, .bot under lib/ where the skill contract says .md under skills/. A producer-only key cannot express consumption, and the consumer is the side that must stop carrying bytes.

  2. 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, E045 and the lib/ confinement learn nothing: the materialized fragment is an ordinary file under lib/, 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 engine if the modularity rule exists to prevent. A symlink is not an option: the snapshot refuses one, and ErrOutside refuses 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 make import "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 import the 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-relative lib/<name>.bot this 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 names iterion bots sync, the server names what resolving THAT entry found.

  3. 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 ResolveExtraSkills shape ADR-144 adopts. At parse time the file is either there (and E045/E046/E047 mean exactly what they mean today) or absent, and E046 would 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.

  4. Reproducibility rides the record that exists. workflow_sources is 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 — PackDir leaves 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 on E046 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, and workflow_sources records 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 sync is 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.

  5. 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.

  6. The release floor goes through the ONE table, which learns to read the manifest — once. parser.ImportSince pins the import SYNTAX; a floor a manifest KEY asks for is what syntaxFloors' need(SyntaxRequirements) cannot express, which is ADR-144 clause 6's widening. exports.fragments asks 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.

  7. 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 import is an E046 on 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 the E046 and the empty program first, then the whole unit, the shared declaration in Merged and 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 in workflow_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 breaks E045's contract ("a path into lib/") 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: ErrOutside at 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's lib/", 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_sources gains that field — and whether a run that busts the cap should record digests instead of nothing — is one decision about maxPersistedWorkflowSource, 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.lock pins what a producer published, so a materialized fragment is recorded in the consumer's own note and left out of ContentHashDir — otherwise resolving a declared dependency would read as drift at the fifteen call sites that ask "is this install still what the lock pinned" (the bot:// resolver would refuse the consumer for good, and bots sync could never short-circuit again). The sixteenth asks a different question: resume_bundle.go asks 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, what validate lints). 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 DSL goal: 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.fragments and exports.skills now 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.