Implements Increment 1 of `docs/superpowers/specs/2026-07-04-crucible-depot-core-design.md`: a new stdlib-only `internal/depot` package wired into the dispatcher. - `crucible depot status|push|verify|get|pull` with backends `local`/`cdn`/`bunny`; exit contract `0` clean / `1` drift / `2` unconfirmed-only / `64` usage / `70` internal. - Presence is always probed against the real target (IPv4 1-byte range GET; HEAD is banned with a regression-tripwire test). `unconfirmed` is a distinct state, never collapsed into `missing`. - No prompting anywhere: missing `BUNNY_STORAGE_*` env fails closed (read path included), enforced by a no-stdin test. - Uploads: probe-then-PUT with `Checksum: <UPPER-sha>`; read/write key split (`BUNNY_STORAGE_READ_PASSWORD` falls back to `BUNNY_STORAGE_PASSWORD`). - Field-driven fix included: per-probe transient retry (curl `--retry 2` equivalent) — without it a real 1490-blob CDN sweep reported 1222 false-unconfirmed; with it, 1490/1490 present in 74s, exit 0. - Registry: depot `Wired: true`, joins the interactive menu; stale "(SeaweedFS)" wording removed. Tests: unit + httptest fake-Bunny (probe sequence, Checksum header, key split, 428 throttling → exit 2) + local→bunny integration (drift → push → clean → idempotent no-second-PUT; incremental pull). `make check` green. **Merge ordering:** this merges FIRST; the companion `sow-assets-manifest#crucible-depot-cutover` PR needs its flake input bumped to include this. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #30 Reviewed-by: xtul <mpiasecki720@protonmail.com> Co-authored-by: vickydotbat <vickydotbat@tutamail.com> Co-committed-by: vickydotbat <vickydotbat@tutamail.com>
20 KiB
Crucible Depot Core Design (Increment 1)
Goal
Give Crucible real ownership of the content-addressed asset depot. Today
crucible-depot is a registered-but-unwired stub (fails closed, exit 70), and
the actual depot logic lives as ~2000 lines of bash embedded in
sow-assets-manifest/scripts — a direct violation of the Crucible principle that
artifact repos invoke Crucible through wrappers and never embed a toolkit.
Increment 1 wires the depot core — blob backends plus status / push /
verify / get — into crucible depot, and cuts the manifest repo over to call
it directly. The bash depot layer is retired, not wrapped: one tool owns
depot byte-motion for both local workflows and CI/CD, with complete parity.
Why now (root cause that triggered this)
Release run 222 (v0.1.5) failed in 45s at the "Warm depot cache" step. PR #27's
mdl-name normalization recompiled the corpus, producing 5903 net-new sha256s
in assets/*.yml. Sampling proved 15/15 net-new blobs return HTTP 404 on the
CDN while 10/10 old blobs return 200 — the CDN works; the new content was
never uploaded. The blobs exist in the local depot (workspace/depot, 69,473
blobs) but never reached Bunny. mirror.sh then 404s and dies — the release is
correctly failing closed on a depot missing data.
Root cause of the missing upload: import.sh's stat-cache
(.cache/import/<cat>.tsv) records (path,size,mtime,sha) after any import
and excludes "unchanged" files from the only upload call (depot_put_many) —
without tracking which backend those bytes actually landed in. A prior
local import poisons the cache so a later bunny import silently skips the
upload. Combined with depot_require_write only normalizing cdn/unset→bunny
(never local→anything), it is very easy to leave the manifests referencing
blobs that exist only in the local depot.
The design principle that kills this bug class: presence is always checked against the real target backend, never a cache.
Update 2026-07-04 — interim bash fix landed + corrected drift measurement
The bug above was fixed in bash ahead of this rewrite (this spec's Increment 2
retires the stat-cache concept entirely, making the fix structural):
sow-assets-manifest#29
keys the stat-cache dir by write backend + target
(.cache/import/<target>/, scripts/import.sh), so a local import
(make haks) can no longer mark a blob "done" in a cache the next bunny sync
trusts. A cross-backend regression test guards it. When Increment 1/2 delete the
bash depot layer, this becomes moot — but until then, do not "simplify" the
stat-cache back to a single shared dir; that reintroduces the exact orphaning
that broke run 222.
Corrected drift numbers. The "15/15 sampled net-new blobs 404" reading in the
section above is a sampling artifact, not the real drift. The CDN edge
(cdn-a7f3k9.westgate.pw) is IPv6-reachable but resets HTTP/2 from some hosts,
and it throttles concurrent HEADs with 428/000 — which produce both
false-404s and false-200s. A reliable sweep (force curl -4, 1-byte range GET
-r 0-0 → expect 206, low concurrency, then serial re-confirm any non-2xx)
shows the true missing set was exactly 2 net-new blobs, not ~5903:
item/_shared_aenea_weaponvfx/wblpl_fxshblk.mdl(0b1d3f…)item/_shared_aenea_weaponvfx/wxdbsc_fxshpur.mdl(4e8d4f…)
A 600-blob random sample of the pre-existing corpus was 100% present. mirror.sh
still correctly failed the release — it dies on the first missing blob — but the
gap was two files a local make haks had primed in the shared cache, not a
wholesale upload failure. Both blobs were re-uploaded to Bunny from
workspace/depot (present in the local depot, sha-verified), unblocking the
v0.1.5 re-run.
Takeaway for crucible depot status/verify: probe over IPv4, treat a single
concurrent-probe non-2xx as unconfirmed (re-check serially) before reporting a
blob missing, so the tool doesn't over-report drift the way an ad-hoc HEAD sweep
did here.
Field notes 2026-07-04 (content-side session) — issues the migration must fix
Surfaced while verifying a content edit (over/ravenloft_potm_blood, 18 blobs) had
actually reached the CDN. All corroborate the design above; #1 is a new gap to close in
Increment 1.
-
pushmust be non-interactive — no tty prompt (new gap). Today the only write path,depot_require_write(lib.sh), prompts forBUNNY_STORAGE_PASSWORDon a tty when it is unset. Headless agents and CI-without-a-pre-exported-secret cannot answer that prompt, so there is no way to push except with a human at a terminal — which is exactly why the current operational workaround is "an agent/human manually pushes each fixed asset."crucible depot pushmust take the write key from env only, fail closed with a clear message when it is absent, and never prompt. The Backends section lists the env names but does not state the no-prompt requirement — make it explicit and add a test that a missing write key exits non-zero without reading stdin. -
status --target cdnis the everyday author check, not only the release gate. The spec framesstatusas the release pre-flight (correct), but the same command, credential-free againstcdn, is the "did my edit actually reach the CDN?" check content authors need routinely. This session answered that question by hand-probing 18 sha256s withcurl— precisely the toilcrucible depot status --manifests assets --target cdnshould replace. Document it as the canonical author-facing "is my content live?" command so people stop ad-hoc probing. -
Probe reliability confirmed again — bake it into the tests. Reproduced the HEAD hazard directly: a
curl -sIHEAD sweep of the 18 blobs returned all-200; the prescribed reliable method (curl -4, 1-byte range GET-r 0-0→206, serial) also returned all-206with the fake-sha control at404. HEAD did not false-negative this time, but it stays the wrong tool. Make the IPv4 range-GET probe a hard requirement and add a test that fails if a HEAD-based existence check is introduced. -
Drift is recurring, not the single v0.1.5 incident. Operationally, local→CDN sync is treated as "basically broken": every
make haks/ local import can re-orphan blobs from Bunny, so authors push by hand after edits. That raises Increment 1's priority and argues for makingcrucible depot pusha standard post-edit step (or an on-edit CI hook), not only a release-time gate.
Ownership principle (from the maintainer)
- Crucible owns the depot tools. No wrapper scripts preserved for their own sake.
- Every consumer repo already has
cruciblein its flake; that is the only dependency. Consumers callcrucible depot …directly (Makefile targets and CI workflow steps alike). - Complete parity: the same
crucible depotcommands serve local workflows and CI/CD. No divergent code paths, no "CI-only" or "local-only" logic. - Total clean-out over legacy preservation: bash depot code is deleted as its Go replacement lands, not kept as a fallback.
Scope
This spec covers Increment 1 only. Roadmap for context:
- Increment 1 (this spec):
crucible depot {status, push, verify, get, pull}- blob backends (
local/cdn/bunny). Cut overmirror/verify/release gate. Delete thelib.shdepot functions that lose their last caller (see Cutover for what actually remains until Increments 2–3).
- blob backends (
- Increment 2:
crucible depot import/sync(edit-tree → manifest+depot), replacingimport.sh/sync-assets.sh. Retire the stat-cache concept. - Increment 3: mdl integrity checks,
mirror/pullergonomics,prune,gc,export. Delete remaining bash.
Each increment ships independently and deletes the bash it replaces.
Command surface (Increment 1)
crucible depot status [--manifests DIR] [--source LOCAL_DEPOT] --target bunny|cdn|local
crucible depot push [--manifests DIR] --source LOCAL_DEPOT --target bunny
crucible depot verify [--manifests DIR] --target bunny|cdn [--sample N]
crucible depot get <sha> <dest> --target cdn|bunny|local
crucible depot pull [--manifests DIR] --dest DIR --target cdn|bunny
-
pullis the bulk fetch that replacesmirror.sh: every referenced sha into--dest/sha256/ab/cd/<sha>, incremental (skip blobs already present with a matching hash — the release warm step re-runs against a persistent/var/cachedir holding ~69k blobs; a non-incremental pull would re-download the corpus every release), sha re-verified on download, parallel with the same probe/transfer bounds asstatus. Withoutpull, Increment 1 cannot deletemirror.sh(single-blobgetis not a replacement). -
--manifestsdefaults toassets/(the repo's manifest dir). Confirmed decision:crucible depotparsesassets/*.ymldirectly (same coupling model ascrucible-hakreading hak manifests); the caller does not pre-extract sha lists. -
--sourceis the local content-addressed store used as the byte source forpush(e.g.workspace/depot). -
Config/auth is env-first with flags as local aliases (see Backends).
Drift model (the fix)
The manifest is the source of truth for what must exist; the target backend
is the source of truth for what does exist; push reconciles them. There is
no cache of "what I uploaded."
-
status— collect everysha256referenced by--manifests/*.yml, run one parallel existence sweep against--target, report{referenced, present, missing, unconfirmed}. Non-zero exit if any referenced blob is missing. This is the release pre-flight gate.unconfirmedis a distinct state, notmissing. Any non-2xx from the parallel sweep is re-probed serially with backoff (the bashdepot_confirm_absentbehavior). If the serial re-probe budget is exhausted (bash caps at 200; keep a cap,DEPOT_CONFIRM_MAX), the remainder is reported asunconfirmed— not collapsed intomissing. Forpushthe distinction is harmless (unconfirmed → treat as absent → re-upload; wasteful but idempotent and safe). Forstatusas the release gate it is critical: a throttled CDN edge must produce "N unconfirmed, retry" (distinct exit code, proposed2), never a false "thousands missing" hard-fail — exactly the over-reporting the corrected v0.1.5 drift measurement documented above.Target authority split:
bunnyprobes storage — the origin, authoritative, needs the read key; this is what the release gate uses.cdnprobes the edge — credential-free, what players actually fetch, the author-facing "is my content live?" check; the edge cache can briefly lag storage (bash carriescdn_purgefor this reason). A blobpresenton bunny butmissingon cdn is propagation lag, not drift. -
push— runstatus, then for each missing sha read the bytes from--sourceand upload to--target. Stateless and idempotent; re-running does nothing once clean. Cannot silently skip an upload — "should I upload this?" is answered by probing the target, not by trusting a local record. -
verify— existence sweep plus a bounded random sample downloaded and re-hashed (the current--fulltag-build check), decoupled from packing. -
get— single-blob fetch with sha re-verify (backfill / debugging).
Backends & config
Faithful port of lib.sh semantics — this is a re-home, not a redesign of the
wire protocol.
| Backend | Read | Write | Role |
|---|---|---|---|
local |
filesystem sha256/ab/cd/<sha> |
filesystem | dev/CI; byte source for push |
cdn |
CDN GET + sha re-verify | ✗ read-only, fails closed | credential-free public reads |
bunny |
CDN GET, storage fallback | Storage PUT |
the only writable remote |
Layout: blob_key(sha) = sha256/<sha[0:2]>/<sha[2:4]>/<sha>.
Config (env names unchanged so existing CI secrets and release.yml keep working
untouched; flags are local aliases):
DEPOT_CDN_BASE/BUNNY_CDN_BASE— CDN read base.BUNNY_STORAGE_HOST,BUNNY_STORAGE_ZONE— Bunny storage endpoint.- Read/write key split (preserved):
BUNNY_STORAGE_READ_PASSWORD(falls back toBUNNY_STORAGE_PASSWORD) for probes/reads;BUNNY_STORAGE_PASSWORDforPUT. Note for therelease.ymlcutover:status --target bunnyas the gate step needs at least the read key exported in that step's env. - No prompting, read path included. The no-prompt rule from field note 1
applies to every credentialed operation, not just
push:status/verify/getagainstbunnywith no read key fail closed with a clear message — never a hang, never a tty read. BUNNY_STORAGE_HOSTstays env-required, no baked default — the bash never defaulted it (depot_require_writedefaults only the zonesow-assets-depotand the CDN base); resolving the earlier open question.- Concurrency:
DEPOT_PROBE_JOBS(read probes),DEPOT_JOBS(uploads);DEPOT_CONNECT_TIMEOUT,DEPOT_PROBE_MAX_TIMEper-transfer bounds.
Invariants carried over exactly:
- Existence probe is an IPv4 1-byte range request (no body download) — for
all backends. This is a deliberate deviation from
lib.sh, whosecdnbackend probes with HEAD (_depot_cdn_probe_code); the "faithful port" clause above does not extend to the probe method. HEAD is banned (see field note 3 and the test requirement there). - Upload is
PUTwithChecksum: <UPPER-sha>so Bunny rejects corrupt writes server-side; each sha uploaded at most once per run. - Fail-safe: a blob with no confirmed-present reply is treated as absent and (re)uploaded — never silently skipped. This is the invariant the stat-cache violated; here it is structural, not cached.
getre-hashes every downloaded blob and deletes on mismatch.cdnwrite operations fail closed (never fake a write).
Stale-wording note to reconcile in code/docs: the dispatch registry summarizes
depot as "(SeaweedFS)", but the real depot is Bunny-CDN + local
(sow-assets-manifest/AGENTS.md: "No S3, no SeaweedFS"). The Go implementation
and the registry summary adopt the Bunny/local/cdn reality.
Data flow
assets/*.yml ──parse──▶ referenced sha set ─┐
├─▶ target.HasMany() ─▶ missing set
--target (bunny) ───┘ │
▼
--source (workspace/depot) ──read bytes──▶ target.PutMany()
Cutover / clean-out
Increment 1 deletes the bash it replaces (no wrappers left behind):
scripts/mirror.sh→crucible depot pull; script removed.build-haks.sh's--fulldepot-verify block →crucible depot verify.- All three depot-touching workflows switch in the same change (not just
release.yml):release.yml"Warm depot cache (verified full pull)" →crucible depot status --target bunny(gate) thencrucible depot pull --dest $DEPOT_CACHE_DIR; the inline bash ticker/compgen logic goes away.publish-haks.ymlmirrors the same warm+verify+pack flow — same swap.build-haks.yml(PR check, cdn-backend verification) →crucible depot verify --target cdn.
- Registry wiring: the
depotentry ininternal/dispatchflips toWired: truewith itsCommandslist populated (status/push/verify/get/pull) and the stale "(SeaweedFS)" summary corrected. This automatically puts depot in the interactivecruciblemenu alongside the other wired tools — no menu-specific code needed.
What lib.sh deletion actually looks like. The depot layer cannot be fully
removed in Increment 1 — it is still referenced by scripts outside this
increment's scope:
import.sh/sync-assets.sh(make haks,make import,make sync) — Increment 2.export.sh(make pull),pack-haks.sh(release-timeDEPOT_BACKEND=bunnypacking),publish-release.sh+release_put, and the path-addressedartifact_has/get/put+cdn_purgehelpers — Increment 3 (these were previously unlisted; they are now explicitly Increment 3 scope).
Increment 1 deletes the functions whose last caller it removes (the
mirror/verify probe-and-get paths) and leaves the rest with a header comment
marking them scheduled for deletion in Increments 2–3. No compatibility shims,
no new wrappers — but no pretending lib.sh dies before its last caller does.
Consumers call crucible depot … directly. Nothing new is wrapped.
Interim contract for local builds (until Increment 2)
make haks keeps running bash sync-assets.sh with DEPOT_BACKEND=local until
Increment 2 replaces import/sync. The gap this leaves — local imports creating
blobs Bunny never sees — is held closed by two things:
- The backend-keyed stat-cache
(
sow-assets-manifest#29) stays exactly as it is; do not "simplify" it (see the 2026-07-04 update above). make haksgains a trailingcrucible depot status --manifests assets --target cdn(credential-free, report-only, non-fatal) so drift is visible the moment it is created instead of at release time; andcrucible depot push --source workspace/depot --target bunnyis documented as the standard post-edit step (field note 4's ask, landed here). Upload when you want the content live; build local-only when you don't — but either way the drift is reported, never masked.
Exit-code contract
Matches Crucible's fail-closed convention:
0— clean / success.1— drift found (status/pushsaw referenced-but-absent blobs), sorelease.ymlcan gate onstatusbefore packing.2— unconfirmed only: no confirmed-missing blobs, but the probe budget ran out before every blob got a confirmed reply (throttled edge). The gate should retry, not report drift.70— internal/backend error; never a faked result.
Testing
- Go unit tests (
internal/depot):localbackend round-trip against a temp dir; manifest parsing; drift computation (referenced − present = missing) as table tests; backend selection + config resolution (incl. read/write key fallback). - httptest fake-Bunny: assert the range-probe-then-PUT sequence, the
Checksum: <UPPER-sha>header, and the read/write key split — no real Bunny. - Integration (local→local):
statusshows drift →pushclears it →statusclean →pushagain is a no-op (idempotency). - New-surface tests: fake-Bunny throttling (
428) → blobs land inunconfirmed, exit2, nevermissing;pullinto a pre-populated dest skips present-and-hash-ok blobs (incremental); credentialed op with no key exits non-zero without reading stdin (read path and write path both).
Non-goals (Increment 1)
import/sync/ edit-tree ingestion (Increment 2).- mdl integrity checks,
prune,gc,export,mirrorergonomics (Increment 3). - Any change to the depot wire protocol, blob layout, or Bunny account config.
- A depot-specific rich TUI. Direction note: Crucible is headed toward being a TUI overall — the existing interactive dispatcher menu is the first step, and depot joins it for free via the registry wiring above. What Increment 1 does not build is anything richer than that menu (progress UIs, pickers, dashboards); those come with the Crucible-wide TUI work, not per-tool.
Immediate operational payoff
crucible depot push --source workspace/depot --target bunny is exactly the
command that unblocks the stuck v0.1.5 release: it probes the target and
uploads whatever is actually 404 while it re-hydrates from the local depot.
(The 5903 figure below is superseded — see the 2026-07-04 update above: the
real drift was 2 blobs, already re-uploaded; push would have been the
clean way to do it and is idempotent once the depot is whole.) Building
Increment 1 first both fixes the tooling and clears the current outage.
Risks / open questions
— resolved: env-required, no baked default (see Backends & config).BUNNY_STORAGE_HOSTdefault- Manifest schema coupling:
crucible depotnow depends on theassets/*.ymlshape (assets[].{path,sha256,size,restype,hak}). Acceptable and intentional, but a schema change now touches Crucible. - Cutover ordering:
release.ymland Makefile must switch tocrucible depotin the same change that removes the bash, to avoid a window where both exist.