20 KiB
Crucible build parity — local == CI, zero-config reproducible
Date: 2026-06-21
Status: approved (design); implementation pending
Repos touched: sow-tools (Crucible + contract doc + image notes),
sow-topdata (config, flake, wrapper, CI), sow-assets-manifest (flake pin),
and the consumer wrapper/flake pattern shared by every Crucible repo.
Supersedes the resolver-script approach in
2026-06-21-topdata-vfx-autogen-channel-design.md (the channel→tag→vfxs.yml
resolution moves out of a bash wrapper and into Crucible).
Problem
crucible topdata build-topdata run by hand does not reproduce the CI
build. CI runs scripts/build-topdata.sh, which resolves the accessory-VFX
model list with scripts/resolve-accessory-vfx.sh and writes
.cache/sow-accessory-vfx-manifest.json before Crucible runs; Crucible only
reads that pre-staged file (nwn-tool.yaml: manifest_file:). Run Crucible
directly and the file never exists, the optional consumer skips, and
visualeffects.2da ships zero accessory rows — exactly the local build the
operator observed (637 rows in CI, 0 locally).
Root cause: a build-output-affecting input is resolved outside Crucible, in a wrapper script. The wrapper, not Crucible, is authoritative for that input.
Goal
Crucible is the single authority for everything that lands in a build artifact. A clone of any consumer repo, on any system, builds the same bytes CI builds — with no secret beyond what cloning already required and no hand-written configuration.
Zero-config reproducibility requirements
- R1 — No extra secrets. All build-input reads are anonymous: the asset
channel pointer (
releases/haks/channels.json) andvfxs.ymlare public CDN text. The crucible binary is fetchable anonymously — verified: the sow-tools release assets return HTTP 200 without auth, and the bootstrap tries anon before any token, soCRUCIBLE_TOKENin consumer CI is vestigial and is removed. Git-LFS asset bytes use the same credentials the clone already used — never a separate key. - R2 — No hand-written config. Every default needed to build is baked into
committed
nwn-tool.yamland Crucible defaults: CDN base, channel selection, source paths. The user typescrucible topdata build-topdataand nothing else. - R3 — No host data assumptions, no
NWN_ROOT. Builders read every input from the project tree (nwn-tool.yaml+ committeddata/**/base.json); no NWN install is required.NWN_ROOTis dead today — nothing in Crucible reads it (see NWN_ROOT cleanup below). The accessory build readsdata/visualeffects/base.json. (Enforced by the CI parity guard, which runs in a bare checkout with noNWN_ROOT.) - R4 — Nix is convenience, not a requirement. The flake devshell provides
crucible,git-lfs,jq,yq, etc. onPATHfor Nix users. Non-Nix users (incl. Windows) get the same Crucible via thecrucible.sh/crucible.ps1bootstrap and a hostgit-lfs; the build path through Crucible is identical. - R5 — Same Crucible, both worlds. Local dev and CI invoke the same Crucible subcommand against the same committed config. No CI-only pre-steps that change output.
Parity contract (prevents recurrence)
Added to sow-tools/docs/consumer-contract.md. A consumer build wrapper may only:
- validate — a pre-build gate that does not change output;
- publish — post-build, on a separate artifact;
- resolve the crucible binary — the bootstrap in
consumer-contract.md.
A wrapper MUST NOT fetch, resolve, generate, or stage any input Crucible reads to produce the artifact. If Crucible needs an input, Crucible acquires it. This is the existing "no local-machine assumptions" + "same inputs → identical output" rule, made explicit: the wrapper is not allowed to be authoritative for build inputs.
Design
A. Accessory-VFX resolution moves into Crucible
Config (sow-topdata/nwn-tool.yaml). Drop manifest_file:; declare the
source on the consumer. The 4-group + mdl filter is not re-declared — it
is derived from the keys already present under accessory_visualeffects.groups:
autogen:
consumers:
- id: accessory_visualeffects
producer: accessory_visualeffects
dataset: visualeffects
mode: accessory_visualeffects
optional: true
source:
kind: cdn_channel
cdn_base_env: BUNNY_CDN_BASE # default https://depot.westgate.pw
channels_path: releases/haks/channels.json
manifest_path: releases/haks/{tag}/vfxs.yml
release_marker_path: releases/haks/{tag}/haks.json # the broken-release guard probe
channel_env: SOW_TOPDATA_ASSET_CHANNEL # else derive from git tag
offline_override_env: SOW_VFXS_MANIFEST # dev/air-gapped vfxs.yml path
accessory_visualeffects:
groups: { chest_accessories: {...}, head_accessories: {...},
head_decorations: {...}, head_features: {...} }
# ... rest of the policy block unchanged
New struct field on AutogenConsumerConfig (sow-tools
internal/project/project.go): Source AutogenSourceConfig with the keys
above. Validation: when source.kind == cdn_channel, channels_path and
manifest_path are required and manifest_path must contain {tag}.
Resolution (internal/topdata/autogen.go,
resolveAutogenConsumerManifest). A new branch for source.kind == cdn_channel, reusing the existing fetchJSON HTTP client and the project YAML
parser:
- Offline override. If
offline_override_envnames a readable file (or a manifest-repo checkout root containingassets/vfxs.yml), parse it and skip the network — the dev/air-gapped path. - Channel.
channel_envif set; else from the git tag /GITHUB_REF_NAME(v*-*→testing,v*→current, otherwisecurrent). - CDN base.
cdn_base_envvalue, else the baked default. GET {cdn}/{channels_path}→ JSON →tag = channels[channel].GET {cdn}/{manifest_path with {tag}}→ parse YAMLassets[].- Filter to
restype == mdlwhosepathis undervfxs/<g>/for<g>in theaccessory_visualeffects.groupskeys. Derive each entry'ssource(leadingvfxs/stripped),group,subgroup,model_stem. Sort bysource. Hand the entries to the existing accessory augmentor unchanged.
Fail policy (ported verbatim from the resolver — this is the v0.1.4-bug guard). Distinguish unreachable (fail open) from broken (hard fail):
- channels.json unreachable / not JSON / channel absent → fail open: the
optionalconsumer contributes no rows anddata/visualeffects/lock.jsonIDs are preserved. - vfxs.yml network error, or 404 and the
release_marker_path(haks.json) is absent for that tag (no published release) → fail open. - vfxs.yml 404 and haks.json present → HARD fail ("release
<tag>has haks.json but no vfxs.yml — accessory rows would silently vanish"). - vfxs.yml present but malformed / no
assetsarray → HARD fail.
Fail-open writes nothing and lets the optional consumer skip; hard-fail aborts the build with the message above.
Deleted: sow-topdata/scripts/resolve-accessory-vfx.sh,
tests/resolve-accessory-vfx-contract.sh, the manifest_file: key, and the
resolver pre-step at build-topdata.sh:19. The four bash contract cases
(offline override, unreachable fail-open, broken-release hard-fail, no-release
fail-open) are re-expressed as Go tests in internal/topdata.
B. Git-LFS materialization moves into Crucible
So a bare clone + crucible packs real bytes, not pointer stubs (today only
build-topdata.sh pulls LFS, so direct Crucible would ship stubs in CI's
non-smudging checkout).
Before packing assets into a hak, Crucible: detects git-LFS pointer stubs under
the asset tree; if any, runs git lfs install --local then git lfs pull in
the repo; re-checks and hard-fails if stubs remain ("refusing to build from
pointer stubs"). Uses the clone's own credentials (R1). Requires git +
git-lfs on PATH — provided by the Nix devshell, a standard host install
otherwise (a clone without git-lfs already yields stubs, so this is the existing
host expectation, now enforced loudly by Crucible instead of a wrapper).
A skip control replaces TOPDATA_SKIP_LFS for the maintain-tree flow, which
regenerates the data/ tree and discards the package: a Crucible flag/env
(e.g. --skip-lfs / CRUCIBLE_SKIP_LFS) so that one job can opt out. Default
is fail-closed (pull).
C. Wrappers thin out
scripts/build-topdata.sh (and the shared pattern in every Crucible repo)
reduces to: validate (gate) → resolve crucible → crucible <build>. No input
resolution, no LFS pull. sow-module/scripts/package-module.sh already matches
this shape (validate → resolve → build); it stays the reference.
D. CI parity guard (catches the next regression)
A sow-tools integration test builds the topdata package via Crucible alone
in a bare checkout (no wrapper, clean .cache, no NWN_ROOT, anonymous CDN)
and asserts visualeffects.2da contains accessory rows. This is the test that
would have caught today's bug: it fails if any build-output-affecting step ever
drifts back out of Crucible into a wrapper, and it enforces R2/R3/R5 mechanically.
The contract rule in consumer-contract.md is the written half; this test is
the enforced half.
E. NWN_ROOT cleanup (stale, contradicts the goal)
NWN_ROOT is unused — no os.Getenv, no --nwn-root flag, no consumer. The
only traces are documentation/help-text describing a rule for a thing that does
not exist: the ## NWN_ROOT rule section in consumer-contract.md, the help
text in internal/dispatch/dispatch.go ("if a builder ever needs an NWN
root… crucible never guesses from $HOME"), and the README.md pointer. That
old rule — never guess, require an explicit env — directly contradicts the
zero-config goal (R2): it would make a future builder demand a hand-set path.
- Remove the
NWN_ROOTreferences: theconsumer-contract.mdsection, the dispatch help-text lines, the README pointer. Leave themigration-from-nwn-tool.mdbullet as historical record. - Replace the forward rule. Should a future builder ever need NWN game data,
it auto-detects it (the NWN home — containing
modules/,hak/,tlk/— and the Steam install path are reliably discoverable), with an optional explicit override for non-standard layouts. Auto-detect first, never a required hand-set env. This keeps "clone → run → Just Work" intact even for a builder that one day reads game data. None do today.
F. Toolchain convergence — one Crucible, three modes
Parity needs everyone to run the same Crucible. Today three mechanisms
diverge: non-Nix uses the crucible.sh bootstrap download; sow-assets-manifest's
flake pins crucible v0.2.5 via fetchurl+hash; sow-topdata's flake ships no
crucible at all (falls back to the bootstrap, with a needless CRUCIBLE_TOKEN).
The pin is scattered (a hardcoded flake version, none in another flake, an image
sha in prod.yml, a HEAD-floating CI binary build).
Converge on one binary, one pin, three modes — all anonymous, all identical:
- Non-Nix (local + CI fallback).
crucible.sh/crucible.ps1bootstrap downloads the release binary anonymously.CRUCIBLE_TOKENis dropped from consumer CI (R1). - Nix (local + CI optimized). Every consumer flake provides
cruciblein its devshell from a sow-tools flake input, pinned by that repo'sflake.lock. Idiomatic, reproducible, no manual hashes; bump withnix flake update. This replaces sow-assets-manifest's manualfetchurlpin and adds crucible to sow-topdata's devshell (today it has none). - CI runs the same
crucible <build>as local, inside the nix devshell (binary-cache fast). No build container, no token, no CI-only pre-steps.
Pin source of truth = flake.lock per repo, bumped deliberately in a PR. We
reject floating "latest": it would let local drift from CI between releases
and break reproducibility. The non-Nix bootstrap, which resolves "latest" by
default, is the convenience path; reproducible/parity-critical builds pin (nix
devshell, or CRUCIBLE_* pin env for the bootstrap).
G. The crucible container is deployment-only
sow-tools builds and publishes registry.westgate.pw/deployment/crucible:<sha>
on v* tags (nix build .#image → skopeo). It exists for deployment —
prod.yml pins it so tools travel with the runtime host (nwn.enable), and it
is a disabled placeholder until the NWN stack lands. No build/CI job consumes
it, and the host-mode runners have no container runtime to run it. It is not a
build tool.
- Keep the image build (cheap,
v*-gated) for its deployment purpose. - Fix
consumer-contract.md: remove the crossed-out "run CI inside the pinned image" guidance and its OPERATOR NOTE; state plainly that builds use the binary (bootstrap or nix devshell) and the image is deployment-only. This kills the "why do we build a container we don't use?" confusion.
H. Autogen lock identity — model-anchored, immutable
General lock contract (hard rule, all datasets). A dataset lock maps a stable
key to a row id. The build MUST: reuse the existing id for a key that still
exists; allocate the first free id (lexicographic 0→1→2…) for a new key; and free
an id only when its key is gone everywhere. An id, once assigned to a live key,
never changes. (Already implemented for the accessory consumer via
historicalLockData reuse + nextAvailableAutogenID; this section makes it the
written rule and fixes the autogen-specific anchor below.)
The autogen problem. Autogen rows describe models that do not exist in
sow-topdata/data/ — they live in the asset vfxs.yml manifest. Today the lock
key is the config-derived presentation key:
KeyFormat = {dataset}:{group}{delimiter}{category_segment}{stem}. Group token,
category, delimiter, case, and prefix-stripping all come from nwn-tool.yaml, so
a config-only change (key_format, delimiter, case, a group rename,
category_from, strip_model_prefixes) changes the key → the lock no longer
matches → a new id is allocated → every row shifts. That is a config edit
silently reshuffling ids for models that never changed.
Fix — anchor the lock identity on the dataset + model, not the presentation.
The lock key for an autogen row becomes {dataset}:{model_source_path} — the
stable dataset namespace prefix (kept as the 2da/namespace linker developers use
instead of raw ids) plus the model source path from vfxs.yml, e.g.
visualeffects:head_accessories/hat/hfx_bandana.mdl (leading vfxs/ stripped —
the path is the value already on entry.Source). Dropped from the key are only
the config-derived parts (group token, category_segment, delimiter, case,
prefix-stripping). The row's key/label columns still derive from config via
KeyFormat/LabelFormat — only the lock identity is decoupled and
model-anchored. Consequences:
- A config-only change keeps every id (the path is unchanged).
- A model re-export (same path, new
sha256) keeps its id — a content change is not an identity change. (Audit trail comes free fromgit diffon the committedvfxs.yml, which carriespath+sha256; the lock stays a flatstring→intmap — no schema change, no fork of the shared lock format. A futureid+shaobject form is a clean later add if observability ever needs it, without changing id semantics.) - A renamed/moved/removed model is a genuinely different (or absent) identity.
One-time cutover remap. On the first build under {dataset}:{path} keys, for
each current model compute its old config-key (with the current policy) and its
new {dataset}:{path} key; if the new key is absent but the old key carries an id
in the lock, adopt that id under the new key. So ids carry over with zero shift on
cutover; the now-unreferenced old keys fall stale and are pruned (below).
Stale pruning. A lock key absent from the resolved entry set is dropped and its id freed for first-free reuse — but only on a successful, non-empty resolution. The fail-open paths (section A) contribute no entries and skip the augmentor entirely, so an unreachable/missing manifest can never mass-prune the lock. This realizes the general "free a fully-stale id" rule without risking the lock on a transient source outage.
Testing. Go tests in internal/topdata: config-only change (rename a group
token, change key_format/delimiter/case) → ids unchanged; model re-export
(same path, different sha) → id unchanged; model rename/remove → old id freed and
reusable; cutover remap → existing ids preserved across the key-scheme switch;
fail-open → no pruning, lock untouched.
Determinism vs. channel mutability (decision)
The committed topdata commit pins everything except the asset channel
pointer. current resolves through channels.json, which is intentionally
mutable, so "reproducible" here means same commit + same channel state →
identical bytes, not "this commit is frozen forever." This is deliberate: the
accessory rows must track the asset HAKs shipped beside sow_top.hak, which is
exactly what the channel pointer expresses. A given asset release tag is
immutable; the pointer moving is a real input change, and lock.json preserves
accessory IDs across it (no reshuffle). Builds against an explicit tag
(SOW_TOPDATA_ASSET_CHANNEL set, or a v* ref) are fully pinned.
This satisfies R1–R5: no secret, no config, deterministic for a fixed input set, on any system.
Testing
- Go unit tests (
sow-tools/internal/topdata) for thecdn_channelresolution: offline-override path; unreachable channels.json → fail open; channel absent → fail open; vfxs.yml 404 + no haks.json → fail open; vfxs.yml 404 + haks.json present → hard fail (asserts message); malformed vfxs.yml → hard fail; happy path → correct filtered/sorted entries (mdl-only, 4-groups- only,vfxs/stripped, group/subgroup/stem). Mirrors the deleted bash contract cases. - Go config validation test for the new
sourceblock. - CI parity guard (section D) in the topdata build workflow.
- Existing accessory augmentor / lock-preserve tests unchanged.
Rollout
- sow-tools: add
AutogenSourceConfig, thecdn_channelresolution branch, LFS materialization, the model-anchored lock identity + cutover remap + stale pruning (section H), the parity-guard test; updateconsumer-contract.md(add the parity contract, remove theNWN_ROOTrule per section E, replace the image guidance with deployment-only per section G). - Release Crucible (assets already anon-downloadable — R1).
- Toolchain convergence (section F): expose
cruciblefrom a sow-tools flake input in each consumer flake — add it to sow-topdata's devshell, switch sow-assets-manifest off its manualfetchurlpin; dropCRUCIBLE_TOKENfrom consumer CI; CI builds inside the nix devshell. - sow-topdata: swap
manifest_file:→source:innwn-tool.yaml; delete the resolver script + bash contract test; thinbuild-topdata.sh(validate → resolve crucible → build); pin Crucible viaflake.lock. - Verify: bare clone →
crucible topdata build-topdata→ 637 accessory rows, no env, no key; and the same command insidenix develop.
Follow-ups (not this work)
- Parts
2da/cachedmodelsreinstatement (separate spec) can reuse the samecdn_channelsource type. - Roll the thinned-wrapper pattern review across
sow-module/sow-codebase.