Skip to content
Merged
4 changes: 3 additions & 1 deletion docs/superpowers/plans/2026-09-24-per-thread-sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -4859,7 +4859,7 @@ Expected: the job well under its 30-minute `timeout-minutes` (the budget in Task
## Follow-ups recorded, not in this plan

- **The drafter as the same app.** With `sandbox.thread`, the drafter's reason to be a separate process (rung 3 §6: a builder serves one target, intake runs before a target is chosen) is gone. Folding it in is optional (spec §1) and changes the drafter's image and inspection root handling; its own PR.
- **A controller-side identity check.** The Docker lane proves each builder thread's intent records its target's `localId`. The controller could also refuse, after a turn, a builder thread whose recorded identity differs from `task.target.image.localId` (a moved tag between prepare and dispatch). Cheap with `openWorkspaceInstallationReader`; worth it once item 4's image registry owns tags.
- **A controller-side identity check.** The Docker lane proves each builder thread's intent records its target's `localId`. The controller could also refuse, after a turn, a builder thread whose recorded identity differs from `task.target.image.localId` (a moved tag between prepare and dispatch). Cheap with `openWorkspaceInstallationReader`; worth it once item 4's image registry owns tags. Closed by `2026-09-25-images-built-on-demand.md`: the verifier runs the bound image id (PR 1, Task 13a); the builder's handoff names it and its labels are checked (PR 2).
- **Per-thread `images` re-check on reconnect.** A thread admitted under an `images` predicate the operator later narrows keeps running its recorded image (never re-resolved, by design). If that ever needs revoking, it is a deletion of the thread, not a re-resolution.
- **Parent-and-subagent grant sharing.** Under `permissionsMode: "boot"` a subagent's file-store grant is visible to its parent at once; a thread-scoped grant is visible to the parent at its next preparation (each preparation builds its own thread store over the same record). If a turn needs the immediate form, cache the thread store per sandbox key in the manager.

Expand Down Expand Up @@ -4893,3 +4893,5 @@ An independent review found no critical issues. Each item it raised, and where t
Minor: Task 5 Step 2 names the real failure (the constructor throws, since a type-only import is erased); Task 8 Step 3 points at Task 7; Task 20 drops `prepareMs` and the imports only the moved code used; D4's refusal is tested with a real `kubernetesSandbox` over a stub client, at check and at boot (Task 7); the PR 3 README trust paragraph and the PR 3 preamble say the manifest writer controls the whole allow-list, `tool` and `subagent` keys included; `b4 check` prints "sandbox: workspace, image and policy are resolved per thread"; the `withManagedWorkspaceReader` doc comments name `sandbox.thread` (Task 9 Step 3).

**PR 3 review follow-up (recorded, not implemented).** The builder's intent records the image ID the manifest's tag resolved to at the thread's first admission, and the verifier resolves the same tag again at verify time; nothing compares either with the target's recorded `target.image.localId`. A tag moved between prepare, admission and verification would build in one image and verify in another with no refusal. The fix is the controller-side identity check already listed under "Follow-ups recorded" (compare the thread's recorded `environment.identity` and the verifier's resolved ID with `task.target.image.localId`, refuse on a mismatch), best done once item 4's image registry owns tags. The PR 3 review also tightened the manifest: its image tag's target and pin segments must equal its own `targetId` and `pin[:12]`.

Closed by `2026-09-25-images-built-on-demand.md`: the verifier runs the bound image id (PR 1, Task 13a); the builder's handoff names it and its labels are checked (PR 2).
10 changes: 10 additions & 0 deletions docs/superpowers/plans/2026-09-25-images-built-on-demand.md
Original file line number Diff line number Diff line change
Expand Up @@ -5314,6 +5314,8 @@ git commit -m "feat(software-factory): the builder handoff names the bound image
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```

> **As landed:** `openImageRegistryReader` also refuses a registry that records no schema version (a file no factory finished creating), leaving it unmigrated, besides an absent path and a newer factory's registry. It creates and writes nothing, but SQLite may leave `-wal`/`-shm` files beside a quiescent WAL registry; its doc comment and test title say so. `fakeBuilderHandoff` takes the bound image's id but keeps its fixed fake tag (`b4-factory-fake-target:…`), because a handoff's tag must name its own target and pin and the fake's are the fake target's.

### Task 19: The builder checks the image's build labels against its handoff

Added after review (item 7). An image ID alone binds no target: the provider's predicate admits any `sha256:` the daemon holds. PR 1's builds carry `b4.factory.target`, `b4.factory.pin` and `b4.factory.key` labels (Task 6); the builder's thread resolver reads them by ID and refuses an image whose labels are not the handoff's own target, pin and the tag's key. Labels are part of the image config the ID content-addresses, so a check by ID has no time-of-check gap. No framework hook is needed: the resolver is the builder app's own code, and the check runs there before the framework resolves the image. Trust impact, stated: the labels bind an ID to a target, pin and recipe as the controller built it; whoever can build or load images on the daemon can forge labels, which is the bound the handoff had before (anyone who can tag an image can already run anything as root there). Without this task, PR 2's ID-only predicate would be a narrower bound than the tag shape it replaces for the target and pin segments; with it, it is at least as narrow. A framework-level `images: (reference, inspected) => …` predicate would let the provider make the same check and is recorded as a follow-up.
Expand Down Expand Up @@ -5471,6 +5473,8 @@ git commit -m "feat(software-factory): the builder admits only an image built fo
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```

> **As landed:** `builderThreadSandbox` parses the handoff and verifies the staged workspace before the label check, so neither refusal needs a daemon. Parsing is stricter than the plan's: an answer that is not a string-valued object is refused, and `null` labels read as none. Label values are JSON-quoted and capped (80 characters) in refusals, and the daemon's stderr is capped (500). The argv puts `--` before the id, and the thread's abort signal is forwarded to `execFile` beside the 30 s timeout. A behavioural test drives the real `b4.config.ts` thread resolver with `node:child_process` mocked: the argv, the signal, and a refusal for every malformed or mismatched answer (a follow-up commit).

### Task 20: The Docker proof: a moved tag moves nothing, and an image not built for the handoff is refused

**Files:**
Expand Down Expand Up @@ -5540,6 +5544,8 @@ git commit -m "test(software-factory): a moved tag moves no builder, and an unla
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```

> **As landed:** each refusal is asserted by its reason, not only that admission failed. A mutation removing the label check still made the plan's boolean assertion pass: the unlabelled base image was admitted and failed later, at workspace preparation. The recipe tag is put back on the bound image in a `finally`.

### Task 21: Docs: the follow-up is closed

**Files:**
Expand All @@ -5562,6 +5568,10 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

PR 2 verification: the PR 1 table, plus `pnpm --filter @b4-example/software-factory-server test` and Task 20's lane. Push `blove/images-by-id` and open the PR only when Brian asks.

> **As landed:** the README's second statement of the builder's image bound (the builder quick start) and its `builder-handoff` reference paragraph were corrected too (`--image-id`, or the registry read-only, refusing to guess), and the images plan gained As-landed notes for Tasks 18-20.
>
> **Final-review fixes:** dispatch's kept-binding test asserts the builder handoff names the bound id and tag, not the registry's newer build (mutation-checked); the CLI usage and comment say `builder-handoff` reads `images.sqlite` read-only unless `--image-id` is given, and a missing or unreadable registry carries the `--image-id` hint; the README says the handoff names the registry's current image (use `--image-id` from `image_bound` to reproduce a work order), states the full-pin/key-prefix label rule, and that controller and builder upgrade together; `FACTORY_LABELS` is exported from `controller/src/lib/targets/image-builder.ts` and a test pins the builder's copy to it. Follow-up: carry the full recipe key in the handoff so the builder's label check compares all 64 hex, not the tag's 12.

---

## Proof map
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,17 @@ target whose files exist at the pin. `FACTORY_MAX_IMAGE_BUILDS` (default 1) boun
`target:prepare` only warms the registry; `FACTORY_TARGETS_DIR` is retired. The CLI's
`dispatch` follows a build past its request timeout, bounded by the journalled `deadlineMs`.
CI's explicit prepares are gone: each lane run builds through a registry of its own. PR 2
moves the builder to the bound ID, checked against the image's build labels. Deferred: the
moved the builder to the bound ID: the builder handoff is version 4 (`target.image` is the
bound ID, `sha256:<64 hex>`, with its recipe tag beside it in `target.tag`; a version-3
handoff is refused at first admission, and a thread admitted before the upgrade keeps its
recorded image), the builder's `dockerSandbox({ images })` accepts only IDs, and its thread
resolver reads the ID's build labels (`docker image inspect -- <id>`, 30 s, the thread's abort
signal) and refuses, failing closed, unless `b4.factory.target`, the full `b4.factory.pin` and
a 64-hex `b4.factory.key` whose prefix is the tag's key segment are the handoff's own. Whoever
can build or load images on the daemon can forge labels, the bound the tag shape had before.
`factory builder-handoff` takes `--image-id` or reads `images.sqlite` read-only, refusing to
guess. The Docker lane proves a moved recipe tag moves no builder, and that a version-3
handoff and an unlabelled image are each refused by their own reason. Deferred: the
factory's own git object store (§9 finding 3) and budgets from measured verifier time (§9
finding 5).

Expand Down
52 changes: 35 additions & 17 deletions examples/software-factory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,15 +169,25 @@ image, policy and permissions, by uploading a source and creating a thread with
within the builder's own bounds, which no handoff can move: the network is
denied (a thread may not open what the app denies), and the permissions mode is
`non-interactive`. The image bound is narrower than "an image the factory prepared": a
handoff may name only a tag in the factory's shape (`b4-factory-<target>:<pin[:12]>-<key[:12]>`,
the recipe key's first twelve hex digits; `dockerSandbox({ images })`) whose target and pin
segments are the handoff's own `targetId` and `pin` (the handoff schema refuses any other at
admission), and only an image present on the daemon under that tag runs. The verifier runs the
work order's bound image by id (its verdict and receipt digest that image); the builder runs
the recipe tag until PR 2 of the images plan moves it to the id. Until then, whoever can tag an
image on the builder's Docker daemon can put anything behind the tag the builder runs, but
that access is already root on the host, so it adds no power a token holder lacks, and the
verdict is still earned in the bound image. That includes the WHOLE allow-list: `permissions` is a record keyed by any
handoff names the image its work order bound by id (`target.image`, `sha256:<64 hex>`), with
that image's recipe tag beside it (`target.tag`, `b4-factory-<target>:<pin[:12]>-<key[:12]>`,
the recipe key's first twelve hex digits) whose target and pin segments must be the handoff's
own `targetId` and `pin` (the handoff schema refuses any other at admission, and refuses a
version-3 handoff that named only a tag). The controller and the builder must be upgraded
together: a mismatch refuses every new work order at its builder's first run (spending one
candidate attempt each), while threads already admitted keep running. The builder's provider accepts only ids
(`dockerSandbox({ images })`), and before the framework resolves the image, the builder's
thread resolver reads the id's build labels (`docker image inspect -- <id>`, bounded at 30
seconds and by the thread's abort) and refuses it, failing closed, unless `b4.factory.target`,
`b4.factory.pin` (the full pin) and `b4.factory.key` (a 64-hex recipe key whose prefix is the
tag's key segment) are the handoff's own. The labels live in the image config the id
content-addresses, so nothing can change between the check and the run. The framework records
that id as the thread's environment identity at its first admission, and the verifier runs the
same bound id (its verdict and receipt digest that image). A tag is a name for people and for
pruning, never the identity: moving the recipe tag onto another image moves no builder and no
verdict. A thread admitted before this upgrade keeps the image it recorded. The bound that
remains: whoever can build or load images on the builder's Docker daemon can forge the labels,
but that access is already root on the host, so it adds no power a token holder lacks. That includes the WHOLE allow-list: `permissions` is a record keyed by any
tool name, so a handoff's author also decides the `tool` and `subagent` keys (which tools run
without approval and which subagents may be dispatched), not only `bash` and the path keys;
the builder's `non-interactive` mode means anything off that list is refused, never asked
Expand Down Expand Up @@ -365,10 +375,13 @@ terminal (the same value in all three):
pnpm --filter @b4-example/software-factory-server dev --port 4100

One builder serves every target and pin. Each work order's handoff names the image its task
is verified in, the sandbox policy and the permission allow-list; the builder records them at
the thread's first admission and runs that thread in them, and only an image the factory
built (`b4-factory-…`) can be named. The handoff names an image, it does not build one: the
controller builds it before the handoff is written. An upgraded controller's allow-list reaches the next
is verified in (by id, with its recipe tag), the sandbox policy and the permission
allow-list; the builder records them at the thread's first admission and runs that thread in
them, and only an image id whose build labels name the handoff's own target, full pin and a recipe
key matching the tag's key prefix can run. The handoff names an image, it does not build one: the controller builds it
before the handoff is written. To write a handoff by hand, `factory builder-handoff` takes
`--image-id sha256:<64 hex>`, or reads the image `<FACTORY_STATE_DIR>/images.sqlite` records
for the task's target at its pin (read-only), and refuses rather than guess. An upgraded controller's allow-list reaches the next
work order at once, because it travels in that work order's handoff; a thread already
admitted keeps the list it was admitted with.

Expand Down Expand Up @@ -679,15 +692,20 @@ policy too, so set the token for `check` after `build` as well (or delete the gi
`FACTORY_BUILDER_LANE` (`1` runs them, anything else skips them with a notice). The drafter app reads
`FACTORY_DRAFTER_IMAGE` (default: the pinned digest in `drafter/src/drafter-image.ts`) and
`FACTORY_DRAFTER_MODEL` (default `gpt-5-mini`). The CLI reads `FACTORY_CONTROLLER_URL` for
writes and `FACTORY_STATE_DIR` for reads; `builder-handoff` needs
neither. `builder-handoff --task <id> --out <dir> [--work-order <id>]` writes one work
order's captured source and its handoff (`<work-order>.source.json` and
writes and `FACTORY_STATE_DIR` for reads; `builder-handoff` needs no controller.
`builder-handoff --task <id> --out <dir> [--work-order <id>] [--image-id sha256:<64 hex>]`
writes one work order's captured source and its handoff (`<work-order>.source.json` and
`<work-order>.handoff.json`, named by the task id by default) for driving a builder without a
controller: `PUT` the source to `/workspace/sources/<sourceDigest>`, then `POST /threads` with
the handoff as `metadata.factoryBuilder` and its `workspace` as the body's `workspace`, both
with the token. It stages its capture under `FACTORY_STATE_DIR` when that is set (as the
controller does) and otherwise under a temporary directory it removes, never under the
controller package. `target:prepare` builds from a temporary archive into
controller package. The handoff names `--image-id`, or else the image
`<FACTORY_STATE_DIR>/images.sqlite` records for the task's target at its pin, read without
creating, migrating or writing the registry; with neither it refuses rather than guess (run
`target:prepare`, or pass the id). It names the image the registry records now, not any work
order's binding; to reproduce a work order's builder, pass `--image-id` from its `image_bound`
event (`factory events <id>`). `target:prepare` builds from a temporary archive into
`<FACTORY_STATE_DIR>/images.sqlite` and writes nothing under the target.

A work order whose worker has left the map — `FACTORY_DRAFTER_URL` unset while a draft is in
Expand Down
Loading
Loading