PRD: read-only imports — repos grant read-only code access to other repos #260

Closed
opened 2026-08-02 23:58:10 +02:00 by dominik.polakovics · 1 comment

Problem Statement

An instance runs against exactly one repo. When that repo's code depends on a sibling repo the operator also manages in lab — a shared library, an API server the repo is a client of, a protocol definition — the agent codes against that sibling from memory. It guesses signatures, invents endpoints, and drifts from the real API, because the container runner (ADR-0052) deliberately closed the hole through which a host-run agent could once see other repos' worktrees and bares. The operator has no supported way to hand an instance the code of a second repo, and the unsupported way (host runner + full host access) gives up isolation entirely.

Solution

A repo declares read-only imports: other lab repos whose code its instances may read. At every spawn, lab fetches each import's reference repo and materializes a snapshot of origin/<default> (no .git) into a per-run directory outside the worktree, mounted read-only into the container at a host-identical path. The generated context file tells the agent each import's name, path, and snapshotted commit. /pull-base refreshes the snapshots in place and reports what changed in its injected digest. The grant is directional, consumer-declared, flat, and code-only — controlled re-opening of exactly the hole ADR-0052 closed, along the read-only-mount contract ADR-0053 reserved for it.

User Stories

  1. As an operator, I want to declare on a repo's settings which other lab repos its instances may read, so that agents code against the real sibling API instead of guessing it.
  2. As an operator, I want imports declared on the consuming repo only, so that there is one place to look to know what a repo's instances can see.
  3. As an operator, I want import targets restricted to repos already registered in lab, so that no new credential or URL plumbing exists to configure or leak.
  4. As an operator, I want an import snapshot taken from freshly-fetched origin/<default> at spawn, so that the agent reads the sibling as it exists now, not as lab last saw it.
  5. As an operator, I want the snapshot immutable for the run's lifetime, so that a mid-run push to the imported repo never shifts the world under the agent.
  6. As an operator, I want a spawn refused before the claim when any import fetch fails, so that an AFK run never claims an issue it would resolve against a false world, and no issue is parked by the refusal.
  7. As an operator, I want the refusal message to name the failing import, so that I can fix the credential or remote it actually concerns.
  8. As an agent, I want the context file to list each import's name, absolute path, and snapshotted commit, so that I know the snapshot exists and where it is without exploring outside my worktree.
  9. As an agent, I want the context file to state that an import is a read-only snapshot and not part of the working repo, so that I never try to edit it, commit it, or treat its files as in scope.
  10. As an agent, I want /pull-base to re-materialize every import alongside the base merge, so that one command freshens my whole world.
  11. As an agent, I want the /pull-base digest to include what changed in each import, so that my context layer stays honest about what moved under me.
  12. As an operator, I want imports mounted read-only in the container runner, so that an agent cannot modify a sibling repo's snapshot even by accident.
  13. As an operator, I want the import path host-identical between the host and container runners, so that transcripts, docs, and habits transfer between runners unchanged.
  14. As an operator, I want every spawn kind of a repo — manual, AFK, scheduled, lander, fix, escalate — to carry its imports by construction, so that a lander validates code against the same world the author saw.
  15. As an operator, I want mutual imports (A imports B, B imports A) to work, so that a client repo and a server repo can each see the other.
  16. As an operator, I want no transitive imports, so that editing one repo's settings never changes what another repo's instances can see.
  17. As an operator, I want self-import rejected at save, so that a nonsensical configuration cannot exist.
  18. As an operator, I want deleting a repo blocked while other repos import it, with the importers named, so that no repo's declared world silently narrows.
  19. As an operator, I want import snapshots cleaned up with the run's directory at teardown, so that parked or torn-down runs leave no orphaned copies.
  20. As an operator, I want import fetches to reuse each target repo's existing reference repo and vault credential, so that the feature adds zero new credential surface.
  21. As an operator, I want the run token to stay scoped to the run's own repo, so that granting code visibility never grants tracker access.
  22. As an operator on the host runner, I want the snapshot made best-effort unwritable, so that even the unsandboxed break-glass discourages accidental edits.

Implementation Decisions

  • Vocabulary: the domain term is read-only import ("import" in context). Avoid: dependency, submodule, link, mount (mechanism, not concept), sibling, referenced repo. CONTEXT.md gains the term; an ADR records the decisions below.
  • Grant model: directional and consumer-declared — the importing repo's settings list its imports. Targets are lab repos by ID. Flat: no transitive closure; mutual imports legal; self-import rejected at save. An external dependency becomes importable by registering it as a lab repo.
  • Schema: a new imports relation keyed (importing repo, target repo), unique per pair, foreign-keyed to repos. Deleting a target repo is refused while importers reference it, with importers named in the error.
  • Materialization: a new deep module in the git layer exposes one operation — fetch the target's reference repo, export origin/<default>'s tree (no .git) into a destination directory, return the snapshotted commit; idempotent in-place re-materialization for refresh. All fetch/branch git ops stay on the reference repo, never a checkout.
  • Spawn integration: the spawn path materializes every import into a per-run directory outside the worktree before the run claims anything; any failure refuses the spawn with an actionable error naming the target (dev-image precedent). Import fetches may run in parallel to keep spawn latency flat. The container runner's mount inventory grows one read-only mount per import at the host-identical path; the host runner relies on the same path with best-effort write-protection.
  • Ref choice: always origin/<default> of the target; no ref pinning.
  • Context layer: the generated context file gains an imports section — one line per import (name, absolute path, snapshotted commit) plus a sentence marking it a read-only snapshot outside the working repo. Skills-index mechanism precedent (ADR-0035); provider-agnostic.
  • Refresh: /pull-base fetches every import, re-materializes each snapshot in place, and includes per-import changes in the injected digest. No other refresh trigger; /clear is untouched.
  • Run kinds: all spawns of the repo carry its imports by construction (ADR-0062 spawn-parity pattern) — no per-spawn toggle.
  • UI: a repo-settings Imports section listing current imports with add/remove against the registered repos; validation errors (self-import, unknown target) are 400s from the repo-settings service.

Testing Decisions

  • A good test exercises external behavior through the module's interface — real git repos on disk, real materialized trees, asserted file contents and commits — never internal call sequences.
  • The materializer is the deep module and gets the densest coverage: integration tests with real bare repos (prior art: the git layer's existing integration tests) — fresh materialize, re-materialize after upstream change, deletion of files removed upstream, fetch failure surfaced.
  • Store/migration tests cover the imports relation and the delete-guard query (prior art: existing migration tests).
  • Repo-settings service tests cover self-import rejection, unknown-target rejection, and delete-blocked-naming-importers.
  • Context-file rendering gets a golden test for the imports section (prior art: the seeder's golden tests).
  • The /pull-base extension gets tests for import refresh and digest content alongside the existing pull tests.
  • Spawn-refusal behavior is asserted at the service level: failing import fetch → no claim, error names the target.

Out of Scope

  • Tracker access to the imported repo — the run token stays scoped to one repo; labctl never reads an import's issues or PRs.
  • Git history in the import — no .git, no log/blame; the snapshot is a tree.
  • Ref pinning, arbitrary git URLs, transitive closure, per-spawn toggles, target-side grants or handshakes.
  • Shared or cached materializations across runs — one copy per run.
  • Any change to /clear / New conversation.

Further Notes

  • ADR-0053 explicitly reserved this shape: "future lab features will import additional content the same read-only way" — dev images must already treat lab's mount points as reserved, so the container contract needs no renegotiation.
  • Spawn coupling is accepted: a spawn of the importing repo now depends on each target's remote being reachable; refuse-before-claim keeps this safe, parallel fetches keep it fast, and a target-side outage pausing the importer's AFK throughput is the intended trade against running on a false world.
  • Watch-item, not scope: an imported repo's own CLAUDE.md/CONTEXT.md ride along in its tree. Providers only auto-discover context relative to the worktree, so nothing loads them, but an agent grepping the snapshot could read them; act only if live runs show confusion.
## Problem Statement An instance runs against exactly one repo. When that repo's code depends on a sibling repo the operator also manages in lab — a shared library, an API server the repo is a client of, a protocol definition — the agent codes against that sibling from memory. It guesses signatures, invents endpoints, and drifts from the real API, because the container runner (ADR-0052) deliberately closed the hole through which a host-run agent could once see other repos' worktrees and bares. The operator has no supported way to hand an instance the code of a second repo, and the unsupported way (host runner + full host access) gives up isolation entirely. ## Solution A repo declares **read-only imports**: other lab repos whose code its instances may read. At every spawn, lab fetches each import's reference repo and materializes a snapshot of `origin/<default>` (no `.git`) into a per-run directory outside the worktree, mounted read-only into the container at a host-identical path. The generated context file tells the agent each import's name, path, and snapshotted commit. `/pull-base` refreshes the snapshots in place and reports what changed in its injected digest. The grant is directional, consumer-declared, flat, and code-only — controlled re-opening of exactly the hole ADR-0052 closed, along the read-only-mount contract ADR-0053 reserved for it. ## User Stories 1. As an operator, I want to declare on a repo's settings which other lab repos its instances may read, so that agents code against the real sibling API instead of guessing it. 2. As an operator, I want imports declared on the consuming repo only, so that there is one place to look to know what a repo's instances can see. 3. As an operator, I want import targets restricted to repos already registered in lab, so that no new credential or URL plumbing exists to configure or leak. 4. As an operator, I want an import snapshot taken from freshly-fetched `origin/<default>` at spawn, so that the agent reads the sibling as it exists now, not as lab last saw it. 5. As an operator, I want the snapshot immutable for the run's lifetime, so that a mid-run push to the imported repo never shifts the world under the agent. 6. As an operator, I want a spawn refused before the claim when any import fetch fails, so that an AFK run never claims an issue it would resolve against a false world, and no issue is parked by the refusal. 7. As an operator, I want the refusal message to name the failing import, so that I can fix the credential or remote it actually concerns. 8. As an agent, I want the context file to list each import's name, absolute path, and snapshotted commit, so that I know the snapshot exists and where it is without exploring outside my worktree. 9. As an agent, I want the context file to state that an import is a read-only snapshot and not part of the working repo, so that I never try to edit it, commit it, or treat its files as in scope. 10. As an agent, I want `/pull-base` to re-materialize every import alongside the base merge, so that one command freshens my whole world. 11. As an agent, I want the `/pull-base` digest to include what changed in each import, so that my context layer stays honest about what moved under me. 12. As an operator, I want imports mounted read-only in the container runner, so that an agent cannot modify a sibling repo's snapshot even by accident. 13. As an operator, I want the import path host-identical between the host and container runners, so that transcripts, docs, and habits transfer between runners unchanged. 14. As an operator, I want every spawn kind of a repo — manual, AFK, scheduled, lander, fix, escalate — to carry its imports by construction, so that a lander validates code against the same world the author saw. 15. As an operator, I want mutual imports (A imports B, B imports A) to work, so that a client repo and a server repo can each see the other. 16. As an operator, I want no transitive imports, so that editing one repo's settings never changes what another repo's instances can see. 17. As an operator, I want self-import rejected at save, so that a nonsensical configuration cannot exist. 18. As an operator, I want deleting a repo blocked while other repos import it, with the importers named, so that no repo's declared world silently narrows. 19. As an operator, I want import snapshots cleaned up with the run's directory at teardown, so that parked or torn-down runs leave no orphaned copies. 20. As an operator, I want import fetches to reuse each target repo's existing reference repo and vault credential, so that the feature adds zero new credential surface. 21. As an operator, I want the run token to stay scoped to the run's own repo, so that granting code visibility never grants tracker access. 22. As an operator on the host runner, I want the snapshot made best-effort unwritable, so that even the unsandboxed break-glass discourages accidental edits. ## Implementation Decisions - **Vocabulary**: the domain term is **read-only import** ("import" in context). Avoid: dependency, submodule, link, mount (mechanism, not concept), sibling, referenced repo. CONTEXT.md gains the term; an ADR records the decisions below. - **Grant model**: directional and consumer-declared — the importing repo's settings list its imports. Targets are lab repos by ID. Flat: no transitive closure; mutual imports legal; self-import rejected at save. An external dependency becomes importable by registering it as a lab repo. - **Schema**: a new imports relation keyed (importing repo, target repo), unique per pair, foreign-keyed to repos. Deleting a target repo is refused while importers reference it, with importers named in the error. - **Materialization**: a new deep module in the git layer exposes one operation — fetch the target's reference repo, export `origin/<default>`'s tree (no `.git`) into a destination directory, return the snapshotted commit; idempotent in-place re-materialization for refresh. All fetch/branch git ops stay on the reference repo, never a checkout. - **Spawn integration**: the spawn path materializes every import into a per-run directory outside the worktree before the run claims anything; any failure refuses the spawn with an actionable error naming the target (dev-image precedent). Import fetches may run in parallel to keep spawn latency flat. The container runner's mount inventory grows one read-only mount per import at the host-identical path; the host runner relies on the same path with best-effort write-protection. - **Ref choice**: always `origin/<default>` of the target; no ref pinning. - **Context layer**: the generated context file gains an imports section — one line per import (name, absolute path, snapshotted commit) plus a sentence marking it a read-only snapshot outside the working repo. Skills-index mechanism precedent (ADR-0035); provider-agnostic. - **Refresh**: `/pull-base` fetches every import, re-materializes each snapshot in place, and includes per-import changes in the injected digest. No other refresh trigger; `/clear` is untouched. - **Run kinds**: all spawns of the repo carry its imports by construction (ADR-0062 spawn-parity pattern) — no per-spawn toggle. - **UI**: a repo-settings Imports section listing current imports with add/remove against the registered repos; validation errors (self-import, unknown target) are 400s from the repo-settings service. ## Testing Decisions - A good test exercises external behavior through the module's interface — real git repos on disk, real materialized trees, asserted file contents and commits — never internal call sequences. - The materializer is the deep module and gets the densest coverage: integration tests with real bare repos (prior art: the git layer's existing integration tests) — fresh materialize, re-materialize after upstream change, deletion of files removed upstream, fetch failure surfaced. - Store/migration tests cover the imports relation and the delete-guard query (prior art: existing migration tests). - Repo-settings service tests cover self-import rejection, unknown-target rejection, and delete-blocked-naming-importers. - Context-file rendering gets a golden test for the imports section (prior art: the seeder's golden tests). - The `/pull-base` extension gets tests for import refresh and digest content alongside the existing pull tests. - Spawn-refusal behavior is asserted at the service level: failing import fetch → no claim, error names the target. ## Out of Scope - Tracker access to the imported repo — the run token stays scoped to one repo; `labctl` never reads an import's issues or PRs. - Git history in the import — no `.git`, no log/blame; the snapshot is a tree. - Ref pinning, arbitrary git URLs, transitive closure, per-spawn toggles, target-side grants or handshakes. - Shared or cached materializations across runs — one copy per run. - Any change to `/clear` / New conversation. ## Further Notes - ADR-0053 explicitly reserved this shape: "future lab features will import additional content the same read-only way" — dev images must already treat lab's mount points as reserved, so the container contract needs no renegotiation. - Spawn coupling is accepted: a spawn of the importing repo now depends on each target's remote being reachable; refuse-before-claim keeps this safe, parallel fetches keep it fast, and a target-side outage pausing the importer's AFK throughput is the intended trade against running on a false world. - Watch-item, not scope: an imported repo's own CLAUDE.md/CONTEXT.md ride along in its tree. Providers only auto-discover context relative to the worktree, so nothing loads them, but an agent grepping the snapshot could read them; act only if live runs show confusion.
Author
Owner

This was generated by AI during triage.

Closing as done. The full PRD was implemented as one slice in #261, merged via PR #264 (commit 8516358's sibling 8bf9c86, feat(imports): consumer-declared read-only repo snapshots mounted into every spawn). The decisions are recorded in ADR-0063 (docs/adr/0063-read-only-imports.md) and CONTEXT.md defines the read-only import term.

> *This was generated by AI during triage.* Closing as done. The full PRD was implemented as one slice in #261, merged via PR #264 (commit 8516358's sibling 8bf9c86, `feat(imports): consumer-declared read-only repo snapshots mounted into every spawn`). The decisions are recorded in ADR-0063 (`docs/adr/0063-read-only-imports.md`) and CONTEXT.md defines the **read-only import** term.
dominik.polakovics 2026-08-03 21:08:02 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/coding-lab#260
No description provided.