Development record
`abcd update` — fetch, verify, swap, in one verb
Summary
spc-32 delivers itd-130: a user-invoked abcd update [<tag>] verb that
completes a chosen update for the PATH-installed binary — resolve (or accept)
the tag, fetch the platform asset over a pinned transport, verify it against
the same release's checksums.txt, swap atomically via minio/selfupdate,
and print a receipt. All logic lives in a new internal/core/update package
returning a structured report; internal/surface/cli renders it (adr-23).
Every design decision below is a grilled commitment carried from the intent's
Decisions section (2026-08-20 interview).
Scope
- New package
internal/core/update:Resolve(tag string)— empty tag resolvesreleases/latestthrough the existinginternal/core/vintagefetcher (one origin for checker and provisioner, per the release.go comment contract); a given tag is used verbatim after shape validation and termsafe sanitising for render.Plan(cwd)— install-shape dispatch producing a typed verdict before any network fetch beyond resolution (see Dispatch below).Apply(plan, tag)— download asset +checksums.txt, verify SHA-256, swap viaminio/selfupdate(new dependency; sign-off recorded in itd-130's Decisions), emitReport{Origin, Tag, Digest, OldVersion, NewVersion, Action, Refusal}.
- CLI verb
abcd updateininternal/surface/cli: TTY detection (progress bar + resolved-tag confirmation on TTY; silent otherwise), receipt rendering in both modes, exit codes (0 applied or already current; non-zero refusal/failure, each named). - Transport: a dedicated client in
internal/core/updatethat (a) ignoresHTTPS_PROXY/HTTP_PROXY/ALL_PROXYandSSL_CERT_FILE/SSL_CERT_DIR(explicitTransport{Proxy: nil}and a root pool built without env overrides), (b) follows redirects only to the release origin's own asset hosts, re-checked per hop underinternal/urlguard(DialControl re-verification, asvintage/release.godoes), (c) streams to a temp file in the destination directory under a size bound, (d) fsyncs before the swap. No body is ever read from an unverified redirect target.
Dispatch (Plan verdicts)
Keyed on what actually runs: the first abcd PATH occupant (reusing
internal/core/ahoy's scanPathEntries/classifyBinTarget seam — one
canonical ownership predicate; the ahoy package exports what update needs
rather than update re-implementing it).
| shape | verdict |
|---|---|
| binary resolves into a plugin root | refuse; name the host's plugin-update path (itd-108 one-cut coherence) |
| dev shim at the entry | refuse; name the shim's track-latest contract and ahoy install mode switch |
| abcd-owned dangling symlink (iss-345 shape) | refuse; name abcd ahoy install as the heal |
| owned symlink, healthy | refuse; the plugin update owns that binary — name the plugin-update path |
| regular file whose SHA-256 is proven against a published release (the install tag's manifest first, then the release list walked newest-first from the origin's atom feed, bounded) | OWNED BY PROVENANCE → swap in place; the old version is the release the on-disk bytes belong to, derived here, never read from the running binary |
| regular file matching no release | refuse; describe the occupant, suggest --help install docs |
resolved path under a brew prefix (/opt/homebrew, /usr/local, /home/linuxbrew/.linuxbrew — prefix test on the RESOLVED path) |
refuse; print brew upgrade abcd |
| entry shadowed by an earlier PATH occupant | proceed on the owned entry but the receipt reports the shadowing occupant; never claim the machine runs the new version |
| nothing on PATH | refuse; name the install one-liner and ahoy install |
Every refusal is a structured Refusal{Shape, Detail, Remedy} — loud, named
remedy, non-zero exit (per the intent's refusal criteria).
How each acceptance criterion is satisfied
- Verified swap + receipt, exit 0 —
Applyverifies the asset digest against same-releasechecksums.txtbeforeselfupdate.Apply;Reportcarries origin/tag/digest/old→new; CLI exits 0. - Checksum mismatch — digest compare failure aborts before any write to the target path; both digests in the error; non-zero exit. Test: serve a mismatched pair through the test transport seam.
- Hostile proxy/CA env — the transport is constructed with
Proxy: niland a system root pool loaded withoutSSL_CERT_FILE/SSL_CERT_DIR(t.Setenv tests assert both are ignored: the request goes to the test server directly and env-planted CA files are never read — asserted via a canary file whose read would be observable). - Plugin-root refusal —
Planshort-circuits before any fetch; test withABCD_PLUGIN_ROOTpointing at a hermetic root. - Dev-shim refusal —
classifyBinTarget→binTargetDevShimverdict; test reuses ahoy's shim fixtures. - Shadowing report —
PlanrecordsshadowingEntryoutput into the report; test mirrors ahoy's shadowed fixtures. - No network → loud fail, no partial file — resolution failure surfaces the vintage fetcher's bounded error; download failure unlinks the temp file (defer-cleanup asserted by test walking the target dir).
- Zero-network seam — the release-origin client is constructible only
inside
internal/core/updateandinternal/core/vintage(package-var seam likeversion.go'snewReleaseFetcher, so the existing zero-network harness proves no other verb reaches it). - Non-TTY silence — progress writer selected on
term.IsTerminal; non-TTY test asserts stdout carries only the receipt.
Deliberately out (carried from the intent)
Ambient checks (adr-38), the Homebrew tap, plugin-surface delivery
(itd-108), the bootstrap trampoline demotion (follow-on rung; delegation
seam pre-specified in the intent's Decisions), in-process attestation
(deferred; checksums bar per itd-105), Windows cold-start shim. Sequencing:
iss-232's vcs.revision guard lands before the first release this verb can
fetch.
Test-first order
Each dispatch row and criterion lands as a failing test before its code;
the transport policy tests (proxy/CA/redirect) precede the transport; the
minio/selfupdate dependency enters with the first swap test
(Assisted-by and dependency sign-off already recorded).
Frontmatter
| id | spc-32 |
|---|---|
| type | spec |
| lifecycle | closed |
.abcd/development/specs/closed/spc-32-abcd-update-completes-a-chosen-update-in.md
open on GitHub ↗ · commit history ↗
References1
- itd-130`abcd update` completes a chosen update in one verbimplements
mentions12
- adr-23A transport-agnostic Go core behind thin front doors
- adr-38Implicit checks are disk-only — the network answers only an explicit ask
- iss-232itd-111 latent coupling: the ahoy install staleness refusal keys on the binary's vcs.revision (via vintage current-from-BuildInfo). Release binaries are VCS-stamped today (release.yml runs make build VERSION=<tag> from a git checkout, -buildvcs=auto default), so pinned users have a determinable vintage and proceed. But if a future release build ever sets -buildvcs=false or builds from a .git-less source archive, Known flips false for every pinned user and ahoy install begins REFUSING until they pass --allow-stale-binary. Add a guard/test on the release build config asserting the shipped binary carries vcs.revision, so this coupling cannot silently break. Non-blocking; surfaced by the independent ruthless review of feat/itd-111-staleness. NOTE: captured in the iss-200 design worktree to avoid an allocator collision (iss-231 taken there); it is an itd-111/release-build follow-up, not iss-200 work.
- iss-345ahoy symlink heal never fires for the entry a plugin update strands: classifyBinTarget owns a symlink only when its dest equals the current plugin root binary, so a PATH entry pointing into the deleted previous plugin cache dir classifies foreign+dangling — no symlink.dangling gap, stepSymlink refuses, and the user is told to resolve manually a link abcd itself wrote. The heal only covers same-root-binary-deleted, a state no real plugin update produces.
- iss-2609012000222546abcd update refuses to replace the very binary that is running it when that binary's release no longer exists on the forge, and its remedy sends the user to remove the file, which removes the only tool that could reinstall. Observed 2026-09-01: ~/.local/bin/abcd was v0.6.6 (byte-identical to the plugin-cache binary the bootstrap provisioned); abcd update resolved v0.7.0, then refused with shape unprovenanced-file because the file's digest appears in no published checksums.txt. The 2026-08-30 decision deleted every release object older than v0.6.9 and notes that v0.6.0-v0.6.6 never had one, and it claims removing old assets affects only a deliberate pin; that is wrong for abcd update, whose ownership proof (spc-32, the OWNED BY PROVENANCE row) walks exactly those deleted manifests, so every v0.6.7, v0.6.8 and plugin-provisioned install now refuses the same way. The remedy text ('remove it and reinstall') names no reinstall command, and once the file is removed the update verb cannot run at all; the user was left with no abcd and recovered only by the README one-liner. Two directions, neither adopted: (1) when the target resolves to the running executable itself (os.Executable after symlink resolution, os.SameFile), the file is abcd by construction and cannot be a foreign binary, so provenance should only derive the old version, reporting it as an unpublished build with its digest and proceeding after the existing TTY confirmation; (2) at minimum the refusal remedy carries the reinstall one-liner or the docs path and states that update cannot run once the file is gone. Part of the ease-of-update design work: users must be able to move the CLI and the plugin route forward without a dead end.
- iss-2609012111162089Three gaps the itd-130 fidelity audit (receipt rcp-264f7b144576) found against spc-32. (1) spc-32 line 61 promised that on a shadowed entry the verb proceeds on the owned entry and reports the shadow; delivered dispatch targets only the first PATH occupant (ResolveUpdateTarget), so the 'update completes on a shadowed entry' path is unreachable, and when the first occupant is an unprovenanced regular file Plan drops LaterOwned so the refusal never mentions the shadowed working install. (2) The non-TTY silence criterion (ac-9) gates progress on stderr's TTY-ness rather than stdout's as written, and no test pins silence when piped (spc-32 line 93 promised one). (3) No test covers a failure after the download starts: mid-stream truncation is file-free only because minio/selfupdate buffers the body, and a copy failure into the .new file has no unlink path (spc-32 line 87 promised the cleanup test); the CA canary-read assertion at spc-32 line 78 is also absent (tests assert the env is unset instead).
- iss-2609300015353414itd-130 criterion 9 reads 'Given stdout is not a TTY, when the update downloads, then no progress output is emitted', but the delivered gate keys progress on stderr, the stream the progress is written to: stdout carries only the receipt in every mode, and progress appears on stderr only when stderr is a terminal. The code is the right reading and the criterion's wording is loose: keyed literally on stdout, it would hide progress from a person watching 'abcd update | tee log' on a terminal while still writing it into a redirected '2>log'. spc-32 line 93 already words the test as 'stdout carries only the receipt'. Found by the itd-130 fidelity audit (receipt rcp-264f7b144576, ac-9 concern), gap 2 of iss-2609012111162089.
- iss-2609300032219282Superseded itd-47's Implementing specs section says its frontmatter spec_id records spc-27 as the primary delivering spec, while the frontmatter holds spec_id: null, and spc-27 and spc-32 are predecessor-store ids that the live store reuses for the surface-coverage registry and abcd update. The same defect iss-2609300025372991 fixed in itd-36 and itd-4, found in a third intent while qualifying predecessor-store citations for iss-2609290448510918.
- iss-2610262035569535The brief index says spc-N references trace the predecessor implementation's spec store and are not delivery claims; the update chapter cites spc-32 as a closed spec in this repository's own store, paired with a shipped intent
- itd-47Oracle-Backed Gates Pass Honestly Without A Human In The Loop
- itd-105A fresh plugin install just works: the hook binary survives install and every update
- itd-108The plugin installs from the curated release artifact, not the repo, and every cut release reaches users automatically