ADR 0098: Build Artifact Provenance and Version-Based Staleness Invalidation

Status

Accepted (2026-06-23), Implemented — all 4 phases: project stamp, dependency provenance, self-describing modules, workspace attach gate (BT-2674–BT-2677)

Context

Problem

Stale compiled artifacts keep causing silent, confusing failures. The build system decides what to reuse purely from file mtimes and content equality, with no record of which toolchain version produced an artifact. Three incidents trace to this single gap:

mtime-based age checks cannot catch any of these: mtimes are unreliable across git checkouts, worktrees, and clock skew, and they say nothing about the compiler version that produced the bytes. The missing axis is toolchain provenance.

Current state

Everything that gates artifact reuse today is mtime- or content-based, and none of it is version-aware:

MechanismFileReuse signalVersion-aware?
Pass-1 metadata cachebuild_cache.rssource-path → mtime; CACHE_VERSION: u32; manifest mtimeNo
BEAM rebuild decisionbuild.rs::detect_changesper-file .bt mtime vs .beam mtimeNo
Dependency freshnessdeps/mod.rs::deps_are_freshbeamtalk.toml mtime vs beamtalk.lock; ebin presenceNo
Workspace native rebuildbeamtalk_repl_ops_load.erl.erl/.bt mtime + generated-header content equality (BT-2653)No
Shared type-spec cachebeam_compiler.rskeyed by OTP version (BT-2470)OTP only

The version information needed already exists — it is simply never written into a _build/ artifact:

So we know the producing version at build time and can read the running version at load time — we just never compare them.

Constraints

Decision

Stamp every build scope with toolchain provenance and invalidate on mismatch. Provenance — not mtime — is the authoritative staleness signal across a toolchain change.

1. Provenance stamp

After a successful build, write a JSON stamp into the build scope:

// _build/dev/.beamtalk-stamp.json   (project)
// _build/deps/<name>/.beamtalk-stamp.json   (each dependency)
{
  "schema": 1,
  "beamtalk_version": "0.4.0-dev+a1b2c3d",  // BEAMTALK_VERSION, verbatim
  "otp_release": "27-15.0.1",               // compound OTP+ERTS version, keyed
  "built_at": "2026-06-23T10:04:11Z"        // informational only
}

A new BuildLayout::stamp_path() / dep_stamp_path(name) owns these locations (consistent with the rest of the layout API). built_at is diagnostic and never an invalidation input — only beamtalk_version and the OTP version are.

The stamp is written last, after every artifact in the scope has landed, via the same temp-write-then-rename discipline BT-2653 already uses for the generated header — so a crash mid-build never leaves a stamp claiming freshness for incomplete output. Concurrent builders are an accepted race: two processes that both read a missing/stale stamp each rebuild and each write at the end; last-write-wins still yields a valid stamp, so the only cost is duplicate work — not worth a lock file at this project's scale.

The stamp lives under _build/ (gitignored) and is therefore build-local: it never travels with source, so a fresh clone or new worktree starts with no stamp → a provenance miss → a clean rebuild. Once this ships, the CLAUDE.md "run just build first in a worktree" caveat can be retired.

Single-file builds (which use <root>/build rather than _build/dev/ and carry no dependencies) are out of scope for v1: they are throwaway, rebuilt per invocation, and never serve a workspace.

2. Invalidation rule

Artifacts in a scope are reused only if the stamp's beamtalk_version and OTP version equal the current toolchain's. On any mismatch — or a missing, corrupt, or unrecognized-schema stamp — the scope is treated as fully stale and rebuilt (the equivalent of --force for that scope), bypassing mtime entirely.

read stamp
  ├─ missing / corrupt / unknown schema / version ≠ current → rebuild all (provenance miss)
  └─ version == current → fall through to existing mtime fast paths

A project-scope miss additionally discards the Pass-1 metadata cache (.beamtalk-pass1-cache.json): its ClassInfo / class-index entries are compiler-derived from the source AST and are just as version-sensitive as the .beam output, so reusing them across a toolchain change would carry stale metadata even after the bytecode is rebuilt.

This makes provenance a coarse gate in front of the fine-grained mtime logic: within one toolchain version, builds are exactly as incremental as today; across a toolchain change, the whole scope rebuilds once.

The signal is the full BEAMTALK_VERSION, including the -dev+<sha> suffix. For consumers on a released binary this is just 0.4.0 and changes only on upgrade. For compiler developers (this repo) it changes per commit, so an artifact built by an older commit — exactly the beamtalk-http failure mode — is detected and rebuilt. That cost is accepted (see Steelman).

The OTP signal is the compound version built by discover_otp_beam_files() (beam_compiler.rs) — the OTP release joined with the ERTS version, e.g. 27-15.0.1 — not just the major. (Note: erlang:system_info(otp_release) alone returns only "27"; Phase 3/4 must reuse the compound from beam_compiler.rs rather than re-deriving from otp_release directly.) This matches the BT-2470 shared type-spec cache, which already keys on the same compound because minor OTP/ERTS releases can shift the primitive type specs baked into compiled output. Keying both caches on the same granularity keeps them from disagreeing about whether an upgrade is material.

3. Self-describing modules

Add beamtalk_version and otp_release to the __beamtalk_meta/0 map so a loaded module is self-describing without a side file:

#{ class => 'Counter', superclass => 'Actor', kind => actor,
   beamtalk_version => <<"0.4.0-dev+a1b2c3d">>,   % NEW
   otp_release => <<"27-15.0.1">>,                 % NEW
   fields => [...], method_info => [...] }

The side stamp is the primary gate (O(1) per scope, readable before loading anything). The meta keys are the consumer-side check the IDE/REPL uses to validate already-loaded modules against the running toolchain.

A module compiled by an older toolchain has no provenance keys at all, so a missing beamtalk_version/otp_release in the meta map is itself treated as a provenance miss (stale → recompile), never as an error or a pass. This is essential: the absent-key case is the older-toolchain failure mode this ADR exists to catch (it is what bit beamtalk-http), so it must fail toward rebuild.

4. Provenance at each layer

Error example

$ beamtalk build
warning: dependency 'beamtalk-http' was compiled by beamtalk 0.3.2 (OTP 26);
         current toolchain is 0.4.0 (OTP 27) — recompiling.
   Compiling beamtalk-http v1.2.0
    Finished dev build in 4.1s

Prior Art

Adopted: the Cargo/Mix/Gleam principle — toolchain version is part of the cache validity key, not just input mtimes. Adapted: a lightweight per-scope JSON stamp rather than a fingerprint directory or content-addressed store, proportional to project scale. Rejected: full content-addressed storage (Bazel) — over-engineered for a pre-public, one-person project.

User Impact

Discoverability

The stamp is a plain JSON file at a predictable path; __beamtalk_meta exposes the same data reflectively. Both answer "what built this?" without special tooling.

Steelman Analysis

Alternatives Considered

mtime-only + manual clean (status quo)

Keep heuristics; rely on humans to clean. Rejected — empirically fails silently (three incidents).

Release-version-only key (drop the -dev+<sha> suffix)

Key only on 0.4.0, ignoring git sha. Avoids dev rebuild churn. Rejected — two different compiler commits share 0.4.0-dev, so it would not invalidate the beamtalk-http case, the very failure this ADR targets.

Hash the compiler binary / port executable

Use a content hash of the toolchain itself. Robust, but expensive to compute on every build and opaque to humans. The version string is cheap, already available, and human-readable — sufficient for the failure modes observed.

Content-addressed artifact store (Bazel-style)

Fully hermetic action keys. Maximally correct but disproportionate to project scale and a large build-system rewrite. Rejected per ADR 0073 proportionality.

__beamtalk_meta only (no side stamp)

Make modules self-describing and skip the stamp file. Rejected as the sole mechanism — it requires loading every module to check provenance, whereas the side stamp is an O(1) per-scope read before loading. We do both: stamp as the primary gate, meta as the consumer-side validation.

Consequences

Positive

Negative

Neutral

Implementation

Affected components: CLI build (build.rs, build_layout.rs, build_cache.rs), dependency resolution (deps/mod.rs), codegen (gen_server/methods.rs), workspace load (beamtalk_repl_ops_load.erl). The lockfile (deps/lockfile.rs) is not touched — provenance lives in a side stamp, not beamtalk.lock, since the stamp is build-local and the lockfile is committed.

Migration Path

No user action required. The first build after this ships finds no stamp, performs one full rebuild, and writes the stamp; subsequent same-version builds are incremental as before. Pre-existing _build/ directories without a stamp are treated as stale exactly once. beamtalk clean remains the manual escape hatch.

Follow-up. When this ships, drop the "run just build first in a worktree" caveat from CLAUDE.md — the provenance miss now handles it automatically.

Toolchain downgrade / schema skew. Mixing toolchains in one _build/ is always safe in both directions. If a newer toolchain writes schema: 2 and an older binary then reads it, the older binary sees an unrecognized schema and treats it as a miss (rebuild); the reverse (newer reads an older schema, or a downgraded beamtalk_version) is the same version-mismatch miss. The failure mode is always an extra rebuild, never silent reuse of foreign-toolchain bytes.

References