Skip to content

Read-only imports: a consumer-declared repo_imports grant materializes each target's freshly-fetched origin/<default> as a .git-less snapshot in the per-run directory, mounted read-only at a host-identical path and refreshed only by /pull-base

An instance sees exactly one repo: its worktree, its reference repo, and nothing else of the fleet. Real systems are more than one repo — a server and its client, a library and the three services that consume it — so an agent working on one of them codes against the others from memory: it guesses a function signature, invents a field name, assumes an endpoint shape, and the mistake surfaces only when the other repo's run fails to build, hours later and in a session that has no idea where the bad assumption came from. The operator's workaround is to paste the sibling's source into the chat, which is expensive, stale on arrival, and unavailable to an AFK run, which has no operator to paste anything.

None of this is accidental. Before ADR-0052 (Containerized runner) a host-Runner agent could read every other repo's worktree and bare clone — full host access, exactly what the "unsandboxed" label admits — and containerizing the pane deliberately closed that hole: the mount inventory is a whitelist, and what is not on it does not exist inside the container. ADR-0053 (Per-repo dev images) then generalized the injection rule and reserved the seam for this feature by name: everything lab injects arrives as a read-only mount, an image must treat lab's mount points as reserved, "and future lab features will import additional content the same read-only way." This ADR is that feature. It re-opens the hole exactly one read-only import wide — one target repo at a time, named by the importing repo, materialized as a dead copy, mounted with no write path back — rather than leaving it sealed (agents keep guessing) or re-opening it ambiently (an agent reads the state dir, including other runs' credentials).

The decisions, pinned (settled via PRD #260 / issue #261 — do not re-litigate):

  • The grant is consumer-declared, directional, and flat: the importing repo's settings list what it reads. Repo settings → Imports holds a set of other lab repos, by repo ID, registered repos only. One place answers "what can this repo read", and it is the repo being edited. Flat, with no transitive closure: importing A never brings A's imports. Mutual imports are legal — a server importing its client while the client imports the server is the common case, not a cycle to detect. Self-import is rejected at save (400), as is an unknown target ID. The base is always the target's origin/<default>; there is no ref pinning, no branch or tag selection, and no per-spawn toggle anywhere — the declaration belongs to the repo, so every one of its instances sees the same world and none can be handed a narrower one by accident. And the grant is code only: the run token stays scoped to the run's own repo, so labctl never reads an import's issues or PRs. Reading a sibling's source to get a signature right is a different capability from acting in that repo's tracker.

  • Targets are lab repos, which is what makes the feature credential-free. Each import fetch reuses the target's own reference repo and its vault credential — both already provisioned, already exercised on every spawn of the target itself. Zero new credential surface, no second acquisition path beside internal/reposvc, and no new failure mode lab has not already had to handle. This is the whole reason targets are repo IDs rather than git URLs.

  • repo_imports (repo_id, target_repo_id), and deleting an imported repo is refused with the importers named. Migration 0021 in both dialects: PRIMARY KEY on the pair — the set semantics are the key, so no surrogate id — with both columns FK to repos. repo_id, the consumer, cascades: deleting a repo takes its own declarations with it. target_repo_id deliberately carries no delete action, so a raced delete trips the FK as a backstop behind the real guard. That guard is store-level, in DeleteRepo, shaped after the credential ReferencedError precedent exactly: the delete is refused and an ImportersError (matching ErrHasImporters) names the importing repos. force does not bypass it — and because the guard lives in the store, below the layer force is interpreted at, it cannot be bypassed by accident either. Force overrides guards about lab's own recoverable state (a clone in flight, live instances lab can tear down); an importers guard is about another repo's declared world, and silently narrowing that is exactly what must not happen. The operator removes the import declarations first — one edit, and the narrowing is on the record.

  • Materialization is fetch, resolve, then clear-and-extract, in a new deep gitx module. gitx.Engine.MaterializeSnapshot(ctx, bareDir, destDir, branch, extraEnv) (commit, error): fetch the reference repo, resolve origin/<branch> to a commit, and only then clear the destination's contents and stream git archive --format=tar through pure-Go extraction. The order is the contract — a refresh against a dead remote (target host down, credential rotated) fails before anything is destroyed, so an existing snapshot survives a network blip instead of evaporating. The destination directory's inode is preserved across re-materialization: its contents are cleared, the directory itself is never recreated, because that directory is bind-mounted into a live container and recreating it would leave the container holding a corpse — the same inode-pinning hazard ADR-0052 hit with the agent socket. The output carries no .git and therefore no history: git archive writes a tree, not a repo. Every git op runs against the reference repo, never a checkout, so an import costs its target no worktree and touches none of its refs.

  • Imports are materialized at every spawn, in parallel, and a failure refuses the spawn before the claim. Materialization lives in instance.Launch — the one path every run kind goes through — so spawn parity is by construction rather than by keeping several call sites in step: manual, AFK, lander, fix, escalate, and scheduled runs all get their imports because none of them has its own launch path. That is the ADR-0062 (Schedules) pattern applied again, for the same reason. Fetches run in parallel, so spawn latency is one target's fetch rather than the sum. A failed import fetch refuses the spawn, with an actionable error naming the failing target; for an AFK spec the refusal lands before the claim, so the issue stays claimable and no issue is parked. This is the refusal-before-claim rule of ADR-0052 (unready host) and ADR-0053 (unpullable dev image) extended to one more precondition, on the same principle: an agent must not run against a false world. A target-side outage therefore pauses the importing repo's AFK throughput — the accepted trade, because a stalled queue is recoverable and work built against a missing sibling is not.

  • Each snapshot is a read-only bind at its host-identical path — the inventory's first :ro bind, and no contract to renegotiate. internal/podmanx gains ImportDirs, rendered as -v <path>:<path>:ro. Every other bind in ADR-0052's inventory is rw; the agent-tools injection was already read-only but by mount type (--mount type=image), so this is the first read-only bind. Host-identical paths, like every mount except the instance HOME, so the absolute path the context file prints is the path the agent uses inside the container and the path the server sees outside — one path, nothing to rewrite. Nothing about the container contract moves: ADR-0053 already declared that everything lab injects arrives read-only and that an image must never bake content at lab's mount points or depend on writing to them. This feature consumes that reservation; it does not renegotiate it.

  • The host runner gets the same paths and a best-effort chmod a-w. A host pane has no mount namespace, so read-only-ness there is advisory: after materialization lab strips write bits from the snapshot tree, best-effort, logging rather than refusing on failure. The host runner is the labeled "unsandboxed — full host access" break-glass and its agent can already read every repo on the box, so the enforcement gap adds no exposure that runner does not already carry. What the chmod buys is that the same path holds the same content under both runners, so nothing an agent, a skill, or a generated context file knows about an import is runner-specific.

  • The generated context file names every import: name, absolute path, snapshotted commit. An import the agent cannot find is not an import. The seeded context file gains a section listing each materialized import with its absolute path and the commit its snapshot came from, stating plainly that it is a read-only snapshot outside the working repo — so the agent reads it for signatures and behavior and never tries to edit it, branch it, or run git in it. The render rides ADR-0035's context-file mechanism including its goldens: a repo with no imports renders byte-identically to today's golden, and the imports render gets a golden of its own, so drift in the shared render path fails a test instead of silently reshaping every seeded worktree.

  • /pull-base is the only refresh; it reports per-import changes in the same digest, and a failed refresh is reported, not fatal. The lab command that already repairs an instance's base takes the imports with it: it re-materializes every import that was materialized at spawn, in place, and its injected digest gains a line per import — the commit it moved to and what changed. One repair verb for "make my world current", and no new command to teach: an agent already told to /pull-base before continuing does the right thing for free. A refresh failure is reported in the digest rather than aborting: the base merge has its own failure semantics (conflict aborts with the worktree untouched) and a dead import target has no business overriding them — the agent is told which import is stale and keeps its still-valid snapshot. Only imports materialized at spawn refresh, because the run's mount inventory is fixed at spawn: a declaration added mid-run has no mount to fill, and one removed mid-run cannot be un-mounted. A settings change takes effect at the next spawn — one rule, no partial-effect middle ground. /clear is untouched: it clears the conversation, never the filesystem.

  • Snapshots die with the per-run directory. imports/ is a third sibling of home/ and runtime/ under <state>/instances/<runID>/, so existing teardown covers it for free — instancehome.Wipe removes the run's whole tree. One hardening is required: Wipe must now clear write-protected trees (the host runner's chmod a-w, plus read-only bits carried in from a target's own file modes), restoring write bits as it descends instead of failing on the first unwritable directory. Nothing is shared and nothing is cached — one copy per run, never reused across runs.

  • Vocabulary. Read-only import joins CONTEXT.md's Language section with its avoid-list: never dependency or submodule (both imply a build-time or git-level relationship that does not exist), never link or sibling (too vague to carry the direction), never referenced repo (that is the target's bare clone, a different object), and never mount — the mount is the container runner's mechanism, not the concept. The globally-banned words stay banned.

Status

Accepted. Settled via PRD #260 and issue #261. Extends ADR-0052 (Containerized runner) on two axes: one more entry in the mount inventory — its first read-only bind — and one more precondition on the refusal-before-claim rule, with the runner otherwise untouched. Rides ADR-0053 (Per-repo dev images), which reserved exactly this contract ("future lab features will import additional content the same read-only way"), so the dev-image contract is consumed as written rather than renegotiated. Inherits spawn parity by construction from the ADR-0062 (Schedules) pattern: materialization sits in instance.Launch, so every run kind gets its imports without a per-kind code path. Uses ADR-0035's context-file render and its goldens for the imports section, keeping the no-imports render byte-identical. Leaves ADR-0006's credential model untouched — an import fetch reuses the target's existing vault credential and adds no credential kind — and the store-level delete guard mirrors that model's ReferencedError precedent. Leaves ADR-0009's tracker seam untouched: the run token does not widen.

Considered options

  • Target-side grants, or a two-sided handshake in which the target approves its importers. Rejected: it doubles the places an operator must look to answer "what can this repo read", and it invents an approval inbox, a pending state, and a revocation story for a fleet where both sides of every grant have the same owner. Consumer-declared is one list in one place, and the real trust boundary is the operator's, not a repo's. The one thing a target genuinely needs — not being deleted out from under its importers — is delivered by the delete guard, which is a store invariant rather than a workflow.

  • Transitive closure: an import brings its target's imports. Rejected: a repo's readable world must be exactly what its own settings list. Closure makes that world a graph walk whose result changes when a repo two hops away edits its settings, so no operator can predict a spawn's mount set from the screen they are editing — and since mutual imports are legal, it also needs a visited-set to terminate. Flat costs one extra declaration when the second hop is genuinely wanted, and buys a world that can be read off the settings page.

  • Ref pinning, or importing an arbitrary git URL. Rejected together. Pinning a ref (import v2.3.1, or branch next) turns imports into a dependency manifest with an upgrade workflow, a staleness question, and a lockfile-shaped future — while what an agent needs is what its siblings look like now, which is origin/<default>. Arbitrary URLs would need their own credentials, their own bare clones, and their own fetch failure modes: a second repo-acquisition path beside internal/reposvc, and a new credential surface to design, store, and rotate. Restricting targets to registered lab repos makes the feature credential-free by construction.

  • One shared or cached materialization, reused across runs. Rejected: a snapshot is bind-mounted into live containers, so a shared copy is a shared mutable surface with N container handles on it — refreshing it for one run rewrites another run's world mid-turn, and the read-only mount stops the agent from writing, not lab. Per-run copies make a run's world immutable for its lifetime except at the single point the operator asks for a refresh. The cost is disk plus a local fetch and a tar extract per import per spawn, which is cheap and runs in parallel.

  • Granting tracker access to imported repos alongside their code. Rejected: that is a different capability wearing the same word. An agent reading a sibling's source to get a signature right has no need to open, comment on, or close that repo's issues, and a run token spanning repos turns every run into a fleet-wide actor — precisely the blast radius per-run scoping exists to bound. Reading an import is on-disk, at spawn, and immutable; writing to a tracker is live, remote, and irreversible.

  • Letting force bypass the delete guard. Rejected: force exists to override guards about lab's own recoverable state, which lab can tear down and rebuild. This guard protects a different repo's declared world, and bypassing it narrows what that repo's next spawn can read with no signal — the symptom being an agent that quietly stopped seeing a library it was written against, surfacing far from the delete that caused it. Removing the import declarations first is one edit and leaves the narrowing explicit. Placing the guard in the store rather than in reposvc makes the rule structural instead of a flag check someone can later thread force through.

  • Refreshing imports on /clear, or adding a dedicated refresh command. Rejected: /clear clears the conversation, not the filesystem — overloading it would make a context reset silently rewrite files under a working agent. A dedicated /refresh-imports is a second verb for "make my world current" that an agent must be taught to reach for and will forget at the moment it matters; /pull-base is already that verb, already in the slash-command catalog, and already injects a digest for the per-import lines to hang on.

Consequences

  • migrations/{sqlite,postgres}/0021_repo_imports.sql: the repo_imports relation — PRIMARY KEY (repo_id, target_repo_id), repo_id FK cascading, target_repo_id FK with no delete action as the backstop behind the named-importers guard.
  • internal/store/{repoimports.go,errors.go,repos.go}: add to and remove from a repo's import set, read it, reverse-lookup its importers, and refuse DeleteRepo while any exist — ImportersError/ErrHasImporters naming them, shaped after the credential ReferencedError and raised below the layer force is read at.
  • internal/reposvc and internal/httpapi: the repo-scoped imports read/write endpoints with write-time validation (unknown target and self-import are 400s — repo-settings business rules live in reposvc, the ADR-0053 placement), and the delete-guard error mapped to a refusal that names the importers.
  • web/src: repo settings gains an Imports section (categories entry, section component, harness stubs, probe) — a picker over the fleet's other repos, with the delete refusal surfaced where the operator hits it.
  • internal/gitx/materialize.go: MaterializeSnapshot — fetch, resolve, then clear-and-extract, preserving the destination inode and never destroying an existing snapshot on a failed refresh.
  • internal/instance/launch.go, internal/instancehome, internal/podmanx: parallel materialization ahead of the claim with a target-naming refusal; ImportsPath(runID) beside HomePath/RuntimePath, and Wipe hardened to remove write-protected trees; ImportDirs rendering -v path:path:ro, the inventory's first read-only bind.
  • internal/seeder: the context-file imports section (name, absolute path, snapshotted commit, and the read-only-snapshot statement) plus a new golden beside the existing ones; the no-imports render stays byte-identical.
  • internal/pull: re-materialize every spawn-materialized import in place, per-import digest lines, and refresh failures reported in the digest rather than aborting the pull.
  • CONTEXT.md: the Read-only import term with its avoid-list; the Runner mount inventory, Lab command (/pull-base), and Dev image entries updated, and Relationships gains the import bullet.
  • Read-only-ness is enforced only under the container Runner. The host runner's chmod a-w is best-effort and a host pane can undo it — accepted, because that runner is full-host-access break-glass by definition.
  • A target-side outage stalls the importing repo's AFK throughput. Refusal-before-claim keeps issues unparked and claimable, but the repo launches nothing until the target's fetch succeeds again. Deliberate: a stalled queue is recoverable, a run built against a missing sibling is not.