ADR-0021: Figure Player Bundle as an Engine Release Asset
Status
Accepted — 2026-10-18. Supersedes ADR-0016 section 2.
Context
ADR-0016 section 2 placed the pre-built interactive figure player bundle
(player/loader.js, player/player.js, and player/THIRD-PARTY-LICENSES.txt)
into the managed figure-engine family inventory so praetorctl adopt copied
those three files into every adopter repository with docs:seo-portal.
Emitting a minified third-party JavaScript bundle (player.js carries bundled
React, react-dom, scheduler, and interfig) into adopter trees conflicted
with the repository governance invariant exceptions-never-tiers (AGENTS.md
operational rule 14):
- Every adopter repository needed two dated
codespellexceptions fortools/figures/player/player.jsandtools/figures/player/THIRD-PARTY-LICENSES.txt, plus formatter and ESLint exclusions for a minified bundle that adopters neither author nor edit. - Every bump to React,
interfig, oresbuildchanged the binary-like player bytes across every adopted repository even when no figure source or check changed.
Decision
- Retire the player bundle from adopter trees.
player/loader.js,player/player.js, andplayer/THIRD-PARTY-LICENSES.txtare removed from the managedfigure-engineasset inventory (tools/figures/assets.go,MaxAssets = 16). Their shipped digests are registered in the family'sMovedmap (internal/managedasset/family.go), sopraetorctl adoptremoves unedited copies automatically and refuses edited copies unless--forceis supplied. - Keep the committed bundle in the engine repository. The engine keeps
building and committing
tools/figures/player/for its own documentation site and embeds those three files separately intools/figuresso the deterministic archive packer has the exact bytes. - One deterministic archive packer (
HISS-19).figures.PlayerArchive()(tools/figures/player_pack.go) buildsfigures-player.tar.gzdeterministically from the three embedded files in fixed order (loader.js,player.js,THIRD-PARTY-LICENSES.txt), with mode0644,uidandgid0, emptyunameandgname, Unix epoch zeroModTime, and an unnamed zero-ModTimegzip header.figures.PlayerArchiveSHA256()returns the hex SHA-256 digest of those bytes, andstandardsctl figures pack --out <file>writes the archive and prints<sha256> figures-player.tar.gz. - Release asset publication. A
beforehook in.goreleaser.yamlrunsstandardsctl figures pack --out .release-extra/figures-player.tar.gz; GoReleaser'schecksum.extra_filesandrelease.extra_filesput the archive into the signedchecksums.txtand upload it with the binary archives, so the checksums signature covers it..github/workflows/release-binaries.ymlcopies it intodist/for the provenance steps, which attest and verify every checksummed file. - Adopter lock file (
tools/figures/player.lock.json). In adopter repositories (every repository where the engine-onlytools/figures/bundle.mjsis absent),praetorctl adoptwritestools/figures/player.lock.json:
{
"asset": "figures-player.tar.gz",
"sha256": "<PlayerArchiveSHA256>",
"release": "<engine release tag or empty>"
}
The release field records the engine release tag the adopter pins (from
the running engine binary's release identity or tools/go.mod), or "" when
adopted from an unreleased engine revision. An optional "repository" field
(defaulting to cordanaLLM/praetor) lets an adopter on its own fork fetch
the release asset from that fork.
6. MkDocs hook fetch and verified extraction. tools/figures/mkdocs_hook.py
publishes tools/figures/player/ directly when that directory exists on
disk (the engine repository). Otherwise it reads
tools/figures/player.lock.json:
- When release is empty, it logs one INFO line
(figures: engine pin <...> has no release; figures render as SVG (no interactive player))
and publishes no player files. An unreleased pin is the expected state
until the first engine release carries the archive, so the line is not a
warning: mkdocs build --strict fails on any warning.
- When player.lock.json is missing, it logs the same line as a warning,
naming the missing file and praetorctl adopt as the command that writes
it; a strict build fails on it.
- When release is set, it downloads, over https only,
https://github.com/<repository>/releases/download/<release>/figures-player.tar.gz
with a 30-second timeout and MAX_FILE_BYTES ceiling, verifies that its
SHA-256 matches sha256 in player.lock.json (raising an error naming
both digests on mismatch), extracts only loader.js, player.js, and
THIRD-PARTY-LICENSES.txt (refusing any other member, absolute path, ..
segment, or non-regular entry), and publishes them under
assets/javascripts/figures/.
- When the download fails (such as HTTP 404 or network unavailability), it
logs the same line as a warning: a non-strict build renders SVG figures,
a strict build fails and names the cause.
7. Audit refusal and codespell cleanup. standardsctl audit fails the
documentation gate in any non-engine repository that still carries
tools/figures/player/player.js, loader.js, or
THIRD-PARTY-LICENSES.txt, naming the file and the remedy
(praetorctl adopt removes it; the docs build fetches the release asset).
internal/adopt/codespell.go no longer proposes codespell exceptions for
the figure player bundle.
Consequences
Positive
- Adopters need zero
codespellor ESLint exceptions for the interactive figure player bundle, upholdingexceptions-never-tiers. - Adopter repositories stop carrying ~245 kB of minified third-party JavaScript
while pinning the exact player bytes via SHA-256 in
tools/figures/player.lock.json.
Negative / Trade-offs
- Adopter documentation builds render figures as static SVGs (with one stated
INFO line) until the first engine release that carries
figures-player.tar.gz, or while pinned to an unreleased engine commit. A strict build without network access to GitHub releases fails on the download warning.
Neutral
- ADR-0016 sections 1 and 3–6 remain in force unchanged.
- The canonical engine repository continues to build, commit, and publish
tools/figures/player/directly withouttools/figures/player.lock.json.
Verification & Compliance
go test ./tools/figures/... ./internal/managedasset ./internal/adopt ./cmd/standardsctlpython3 -m pytest -q tools/figures/test_mkdocs_hook.pyactionlint .github/workflows/release-binaries.ymlstandardsctl audit