- Status: done
- Date: 2026-05-23
- Specs touched:
APP_LIFECYCLE.md,APP_MANIFEST.md,APP_STORE.md
Makes installs byte-deterministic: every service ends up running against an
override that pins image: name@sha256:…. Door-1 (catalog) verifies the
resolved digest against the catalog's promise; Door-2 (user-pasted compose)
falls back to pure TOFU, since there is no external authority to compare
against.
New optional Manifest.Images map[string]string — the catalog-promised
image:tag → sha256:… map (APP_STORE.md # Trust model). Synthesize
leaves it empty for Door-2 apps. Absent ⇒ TOFU.
resolveImages(ctx, man, composeBytes) parses each service's image: from
the verbatim compose, docker pulls each unique image, reads
RepoDigests via docker image inspect, and picks the digest whose repo
matches. For images already pinned by digest (name@sha256:…) it short-
circuits to that digest after a pull. The returned []servicePin carries
both the original image:tag (kept for the SQLite row and human display)
and the resolved sha256:….
Door-1 verify lives in the same pass: any image present in
man.Images must match the resolved digest exactly. Mismatch is a typed
error, surfaced as a failed job whose step=resolving_digests and
message names the image plus both digests.
Resolution runs before override generation, not after. The spec
(APP_LIFECYCLE.md # install transaction) describes a write-then-rewrite
sequence ("5. generate override / 6. pull, resolve, rewrite"); we collapse
that to write-once-with-pins, which is functionally identical and avoids
emitting a transient unpinned override. The full new ordering:
4. writeInstanceDir
5. resolveImages + Door-1 verify + SetInstanceImages ← new step
6. writeOverride(with pins) + writeEnv
7-12. network / route / up / health / flip (unchanged)
Failure at step 5 (unpullable image, registry error, digest mismatch)
hits the existing rollback path: instance row deleted (FK cascade drops
the instance_images rows), instance dir removed, no Caddy/mDNS state.
writeOverride now takes a []servicePin and emits
image: <repo>@<sha256:…> per service alongside the existing
cap_drop/security_opt/networks/labels. Override image: wins over
the base compose's image: name:tag via compose merge, which is exactly
the spec's contract.
New table:
CREATE TABLE instance_images (
instance_id TEXT NOT NULL,
service TEXT NOT NULL,
image TEXT NOT NULL, -- original image:tag from author compose
digest TEXT NOT NULL, -- sha256:…
PRIMARY KEY (instance_id, service),
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE
);Store.SetInstanceImages writes the set transactionally (delete + insert)
so it's idempotent and ready to receive the next generation on update.
GetInstanceImages returns the rows ordered by service. FK + the existing
PRAGMA foreign_keys=ON mean uninstall and rollback both clean these up
without explicit deletes.
Added the real traefik/whoami:v1.10.3 digest to the manifest's images
map so Door-1 verification actually runs through the live catalog. CI
resolution + the signed-JSON catalog stay deferred (APP_STORE.md).
APP_LIFECYCLE.md# image digest pinning — realized: every install ends up running against acompose.override.ymlthat pins each service by digest. From the seconduponward the compose is byte-deterministic.APP_LIFECYCLE.md# install transaction — step 5/6 collapsed into one write; see "What was done" above.APP_STORE.md# Trust model — the verify path against the catalog'simagesmap is now enforced at install. The catalog itself is still hand-curated; CI + signing is the follow-up.APP_MANIFEST.md# one model, two doors — both doors converge on the same resolver; Door-2 just provides an emptyImagesmap.
Live, against the running dev stack:
- Door-1 happy path — uninstall + reinstall
whoamithrough the new flow. Override containsimage: traefik/whoami@sha256:43a68d10…; SQLite row ininstance_imagescarries the same digest; app routes through Caddy as before. - Door-1 catalog mismatch — hand-tampered the
imagesmap to a bogussha256:deadbeef…. Install failed withstep=resolving_digestsand message "catalog promised … registry served …". No instance row, no instance dir. - Door-2 TOFU — pasted compose with
traefik/whoami:v1.10.3→ resolved, pinned, and persisted under atofu-demoslug. Runs healthy. - Unpullable image — pasted compose with
nope-nope-nope/.... Failed atresolving_digestswith the underlyingpull access deniederror; no instance dir. - Migration on existing DB — the running brain's pre-existing SQLite
picked up the new table on startup without complaint; existing
un-pinned
whoamiinstall kept running.
- One-generation rollback storage is not yet wired. The spec calls
for a "previous digest" kept for rollback during update; that lands
with the update slice, which doesn't exist yet. The schema can grow a
generationcolumn or siblinginstance_images_prevtable when we get there. - Signed-JSON catalog + CI digest resolution are still TODO. The
imagesmap is currently hand-edited inmanifest.ymlrather than resolved by CI at catalog-build time (APP_STORE.md). - No
docker compose pullreuse: we shell out todocker pullper unique image rather than usingcompose pull. The per-image loop is simpler given we also need each image'sRepoDigestsinspected. - Override is rewritten when a manifest lists no
imagesmap: the pin still lands, but there is no external attestation. By definition — that's the TOFU semantics.
- Update + rollback — fetch new manifest/compose, save current
override →
.prev, snapshot data, re-resolve, swap on failure (APP_LIFECYCLE.md# update + rollback). WEB_UI.mdcomponent stack — Tailwind 4 + shadcn-vue; surface splash/failed states, the digest-mismatch reason, and amain_servicepicker for multi-service Door-2 apps.- Signed-JSON catalog + CI digest resolution — replace the hand-
edited
imagesmap with the publish pipeline fromAPP_STORE.md. - VM outer loop — begin the real host-agent.