- Status: done
- Date: 2026-06-05
- Specs touched: No spec change —
APP_STORE.md# Catalog schema already specifies the object-formimagesmap ({digest, download_bytes, disk_bytes}) and the per-appfootprint; this realizes them in the skeleton.NEXT.md# Developer / app-author surface past-tensed (the size/digest resolver shipped).docs/dev/authoring-apps-with-an-agent.mdupdated (the manual digest step is now a CLI command;storage.estimated_sizeis now read).
Closes issue #69 (part of #68, "on-disk footprint before install"). The catalog schema promises each image's download/disk size and a per-app footprint so the store can show on-disk cost before install; the skeleton carried only hand-curated digests in a string-form images map. This migrates the data model to the object form, adds the malmo manifest resolve subcommand that fills the sizes from the registry, and regenerates the three sample manifests. It is the root of the footprint chain — #70 (brain serves the footprint) and #71 (UI shows it) were blocked on it.
Images map[string]ImageRefreplacesmap[string]string.ImageRef{Digest, DownloadBytes, DiskBytes}is the catalog's per-image object (APP_STORE.md# Catalog schema). ItsUnmarshalYAMLaccepts both the object form and the legacy scalar shorthand (image:tag: sha256:…, digest only), mirroring theHealthProbestring-or-mapping pattern, so pre-#69 manifests still parse during the migration.Storage{EstimatedSize}— the manifest now readsstorage.estimated_size(APP_MANIFEST.md# Storage). Only that one key;data_volumes/cache_volumesstay held by the compose, unparsed.Footprint{ImageDownloadBytes, ImageDiskBytes, EstimatedState}+(*Manifest).Footprint()— the per-app summary, derived (sums theImagesentries, hoistsestimated_size), never hand-authored. This matches the spec: the realcatalog.jsonfootprint is CI-generated, not an author field. #70 will surface it on the catalog browseEntry.ComposeImages(composeBytes)(insynthesize.go, next toComposeServiceNames) — the distinct, sortedimage:refs the resolver sizes. Distinct because services share images (the hermes gateway + dashboard run one binary).
malmo manifest resolve <manifest.yml>— the secondmanifestsubcommand. It reads the sibling compose, resolves each distinct image's{digest, download_bytes, disk_bytes}, rewrites the manifest'simagesblock in place, and prints the per-image sizes + the derived footprint. Fails loudly — a single unsizable image aborts the whole run with no write, so the catalog never carries a bogus zero (Done-when #3).dockerSizerdrives the local Docker daemon (the maintainer's call over addinggo-containerregistry; consistent withinternal/lifecycle/pinning.go, which already shells to docker, and no new dependency):docker manifest inspectfor the compressed download size (the amd64 sub-manifest's layer sizes — malmo is x86-only), a--platform linux/amd64pull +docker image inspect {{.Size}}for the uncompressed disk size, and the pulled image's RepoDigest for the pinned (index) digest. It is behind animageSizerinterface so the parse/footprint/write-back logic is unit-tested with a fake.- Comment-preserving write-back —
replaceImagesBlockrewrites only theimages:block as surgical text (block = theimages:line through every following indented/blank line; appended at EOF if absent), leaving every other byte — the explanatory comment above the block, blank lines — untouched. A YAML re-encode was rejected because it collapses the curated manifests' blank lines and comment spacing. The regenerated content is re-Parsed before the file is written, so a malformed block can never land on disk.
internal/lifecycle/pinning.go— the catalog-digest verification readsman.Images[img].Digest(was the bare string). Behaviour identical; the mismatch error still names the promised vs. served digest.catalog/{whoami,files-demo,hermes-agent}/manifest.yml— migrated to the object form by running the new subcommand. Real resolved sizes: whoami/files-demo (traefik/whoami:v1.10.3) 2.85 MB download / 6.58 MB disk; hermes-agent 1.13 GB download / 3.20 GB disk. Download is the compressed registry total; disk is the larger uncompresseddocker image inspect .Size, the two diverging by the layers' compression ratio. The resolved index digests match the values that were hand-curated, confirming the resolver agrees with the prior pins.
APP_STORE.md# Catalog schema — theimagesobject andfootprintsummary are now real types. # Trust model — sizes are display-only (omitempty, gate nothing); onlydigestbinds bytes, enforced unchanged at install.APP_MANIFEST.md# Storage —estimated_sizehoisted verbatim intofootprint.estimated_state.CLAUDE.md# Go discipline — export-on-second-consumer (ComposeImages); no new dependency; the resolver's docker calls sit behind a consumer-side interface so the logic stays testable;cmd/owns file I/O,internal/manifeststays filesystem-free.
- Footprint is derived, not stored. The spec puts
footprintin the generatedcatalog.json, not the manifest. The skeleton has nocatalog.jsonyet, so the manifest'simagesblock stands in for it andFootprint()computes the summary on demand. #70 wires the derived footprint onto the catalogEntryand the install-plan response. NoDECISIONS.mdentry — nothing flipped; this realizes the locked schema. - Cross-image layer dedup is not implemented.
disk_bytesis each image's owndocker image inspect .Size(uncompressed, already deduped within that image). When one app declares multiple distinct images sharing base layers, summing their.Sizeslightly over-counts the shared base. All three samples are single-distinct-image, so it is a no-op today; the spec's "dedup within the app's image set" is satisfied for them. Sizes are advisory, so the over-count is a cosmetic bug, not an integrity one — true cross-image dedup is a follow-up for the first multi-image curated app. - Resolver needs a Docker daemon + network.
dockerSizeris therefore not unit-tested (theimageSizerseam is faked in tests); it was verified by resolving the three real samples. A daemon-free registry client (go-containerregistry) would suit a no-Docker CI better — deferred with the catalog-repo CI. - No
catalog.jsongeneration. This sizes the per-app manifest; rolling the tree into a signedcatalog.jsonis still the follow-up the lint/resolve CLIs feed (APP_STORE.md# What we run). - Legacy scalar
imagesform still parses. Kept for migration back-compat; the curated catalog is fully object-form now.
go test ./cmd/malmo/ ./internal/manifest/ green (plus the full non-PAM suite — the pinning.go consumer change rippled cleanly).
internal/manifest/images_test.go— object-form parse; legacy scalar parse (sizes zero);Footprintsums + hoistsestimated_size; the unset-estimated_sizecase;ComposeImagesdistinct/sorted + dedup + rejects (no services, no image).cmd/malmo/resolve_test.go—resolvevia a fake sizer: appends the object block + footprint, replaces an existing block keeping its comment, and fails loud leaving the file untouched on a sizer error or an empty digest. PurereplaceImagesBlock: trailing-newline preserved, append-when-absent, idempotent.renderImagesBlocksorts;repoOftable; resolve dispatch usage errors.dockerSizerend-to-end: verified by hand resolving the three committed samples (the resolved sizes/digests above).
- #70 — brain serves the footprint: parse the object form in
internal/catalog, add the coarsefootprintto the browseEntry, and the incremental (already-pulled-aware) footprint +free_bytesto the install-plan response. - #71 — UI: size on the store card, Storage block in the consent dialog.
- Cross-image layer dedup for
disk_bytesonce a multi-image app with shared bases enters the catalog. - Catalog-repo CI that runs
malmo manifest lint+malmo manifest resolveover the tree and regeneratescatalog.json(APP_STORE.md# CI on the repo); a daemon-free sizer fits there.