Skip to content

feat(software-factory): the builder runs the bound image by ID and checks its build labels - #855

Open
blove wants to merge 8 commits into
mainfrom
blove/images-by-id
Open

blove wants to merge 8 commits into
mainfrom
blove/images-by-id

Conversation

@blove

@blove blove commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Framework-gaps item 4, PR 2 of 2 (plan docs/superpowers/plans/2026-09-25-images-built-on-demand.md, Tasks 18-21). Follows #852. The builder now runs the work order's bound image by Docker ID, just as the verifier already does. The builder and the verifier now provably run the same image.

What changes

  • Builder handoff version 4. target.image is the bound image ID (sha256:<64 hex>), and target.tag carries the recipe tag. The tag's target and pin must be the handoff's own. Version 3 handoffs are refused at first admission. Threads admitted before the upgrade keep the image they recorded.
  • The builder accepts only IDs. Its dockerSandbox({ images }) predicate is isFactoryImageId.
  • Label check. The builder's thread resolver inspects the ID (docker image inspect -- <id>, with a 30 s timeout and the thread's abort signal). It refuses unless the image's b4.factory.target, b4.factory.pin and b4.factory.key labels name the handoff's own target, the full pin, and a key matching the tag's key prefix. It fails closed. Labels sit in the config the ID content-addresses, so nothing changes between the check and the run. Anyone who can load images on the daemon can still forge labels, and the README says so.
  • factory builder-handoff takes --image-id, or reads <FACTORY_STATE_DIR>/images.sqlite read-only. It refuses to guess.
  • Pinned across packages. The label names are shared between the controller and the builder, and a test pins them.

Closes the per-thread-sandbox plan's controller-side identity follow-up.

Upgrade note. The controller and the builder must be upgraded together. A mismatched pair refuses each new work order at its builder's first run, and each refusal spends one candidate attempt. Threads already admitted keep running.

Recorded follow-up: carry the full recipe key in the handoff, so the label check compares all 64 hex characters instead of the tag's 12.

Review

Each task had a spec review and a quality review, then the branch had a final review. The "As landed" notes in the plan record every fix. The most important ones:

  • Refusals asserted by reason. A mutation that removed the label check still passed the plan's boolean assertion.
  • The parser tested for real. A behavioural test goes through the real config with a mocked docker binary.
  • Dispatch proven to hand over the bound ID. A test pins that dispatch hands the builder the bound ID even when the registry records a newer build of the same key.

Verification (local, Node 24)

  • Unit tests: controller 857 passed, server 27 passed, drafter 17 passed.
  • test:sandbox, the Docker lane: 11 files passed and 1 skipped (the opt-in cli target); 30 tests passed and 4 skipped. The new proof covers three cases:
    • a moved recipe tag moves nothing
    • a v3 handoff is refused
    • an unlabelled image is refused
  • pnpm lint, node scripts/check-docs.mjs and the factory packages' typechecks pass.

Examples only, so there is no changeset.

🤖 Generated with Claude Code

blove and others added 8 commits September 25, 2026 13:43
The handoff is version 4: target.image is the bound image's id
(sha256:<64 hex>) and target.tag carries its recipe tag, whose target and
pin segments must be the handoff's own. The builder's dockerSandbox
predicate (isFactoryImageId) admits only ids. Dispatch hands the capture
the binding's id and tag; `factory builder-handoff` takes --image-id, or
reads <FACTORY_STATE_DIR>/images.sqlite through the new read-only
openImageRegistryReader, and refuses rather than guess.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t they prove

The reader creates no registry and writes nothing to it, but SQLite may
leave -wal/-shm beside a quiescent WAL registry; the JSDoc and the test
title say so. A new case proves a file with no schema version is refused
and left unmigrated (sqlite_master stays empty), and the reader test now
checks the absent path is not created and the record is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s handoff

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e docker CLI it calls

The config's own thread resolver is exercised with node:child_process mocked: the argv
(with -- before the id), the thread's abort signal, and a refusal for every malformed or
mismatched answer. Label values and daemon stderr are quoted and capped in refusals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed image is refused

With the devkit recipe tag moved onto the pinned node base, a thread handed the bound image
id still runs the bound id; a version-3 handoff naming the tag is refused by the schema, and
a held id the factory did not build (the base) is refused by its labels. Each refusal is
pinned to its own reason: without the label check the base is admitted and fails later, at
workspace preparation, which a bare "refused" assertion would have accepted. The tag is put
back on the bound image in a finally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing to what they claim

Dispatch's kept-binding test now asserts the builder handoff names the bound image and tag,
not the registry's newer build of the same key (mutation-checked). FACTORY_LABELS is exported
from the image builder, which writes its labels through it, and a test pins the builder's copy
to the same three names. builder-handoff's refusal for a missing or unreadable image registry
carries the --image-id hint, and its usage says it reads images.sqlite read-only unless
--image-id is given.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pgrade both together

The README says builder-handoff names the image the registry records now (pass --image-id
from a work order's image_bound event to reproduce its builder), states the label rule as the
full pin and a key matching the tag's prefix, and that the controller and the builder must be
upgraded together. The plan records the final-review fixes and the full-recipe-key follow-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
b4-run Ignored Ignored Preview Sep 25, 2026 8:53pm UTC

Request Review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated approval: this PR received an intelligent (AI) code review. See the review comments on this PR.

@blove
blove deployed to vercel-preview September 25, 2026 20:53 — with GitHub Actions Active

This branch was successfully deployed

1 active deployment
vercel-preview — 68f64c41 Deployed Sep 25, 2026 by blove via vercel-native #2418
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant