Skip to content

feat(repo): rebuild exact-version schemas from tags, and harden the specification for 1.0.0 - #42

Merged
justinmerrell merged 16 commits into
mainfrom
feat/publication-immutability
Aug 17, 2026
Merged

feat(repo): rebuild exact-version schemas from tags, and harden the specification for 1.0.0#42
justinmerrell merged 16 commits into
mainfrom
feat/publication-immutability

Conversation

@justinmerrell

Copy link
Copy Markdown
Contributor

What changes

Hardens the specification and its publication pipeline before the first 1.0.0
tag, following an external review. The review's direction was right and the
architecture is unchanged; this fixes what it found plus four defects it missed.

The blocking one. tools/src/site.ts wiped the publication tree and copied
the current working-tree bundle to the exact-version path. After v1.0.0,
the next merge to main would have republished at /component/v1.0.0/; after
v1.1.0, that URL would have 404'd. README.md calls those URLs "Immutable
forever". The guard that appeared to prevent this tested a tree rmSync had
deleted 35 lines earlier.

Pinned paths are now rebuilt from their own git tags, recorded in an append-only
published.json, and stamped with their own exact-version $id.

Why

git tag -l is empty and the three 1.0.0 release PRs (#1, #2, #3) are open.
Merging any of them does two irreversible things: it publishes through the
broken pipeline, and it closes the ADR 0005
§1 window — the only period in which a rule that rejects a previously valid
document can land without a new major.

Important

Do not merge #1, #2, or #3 until this lands. The YAML profile and the
discriminator rename both tighten validation and can only ship before the
first tag.

New: ADR 0006.

Four defects the review missed

  1. Every released schema shared one $id — the alias URL. All releases
    claimed the same canonical identity, so a validator resolving or caching by
    $id could not tell two pinned versions apart. Pinning was defeated at the
    identity layer, not just transport.
  2. A publication race that the obvious fix introduces. pages.yml and
    release.yml both trigger on push to main and release-please tags mid-run,
    so a tag-sourced build would have deployed without the version just cut.
    Fixed by calling Pages from release.yml after the tag exists.
  3. assertSelfContained gives a false green on the dormant multi-module
    bundler bug — it checks lexically against the root's $defs, which is exactly
    where the missing definition still exists.
  4. RULESETS.md claimed a tag-creation restriction that did not exist.

Highlights

  • YAML profile (component §7.1). The parser phase was one table row;
    document.ts was already stricter than the prose. Merge keys, explicit tags,
    multi-document streams, non-string keys, encoding/BOM, scalar resolution, and
    concrete size/depth/scalar bounds are now specified — 7 new diagnostics, 7 new
    fixtures.
  • One precedence rule in all five places that previously disagreed, plus
    BCP 14 boilerplate.
  • Security considerations per family, and the format policy that lived
    only in a code comment.
  • Ajv strict re-enabled; discriminatorx-musher-discriminator.
  • Ajv/Blaze parity — 127 subjects, two independent 2020-12 implementations,
    zero disagreements.
  • Compatibility replay reads each release's documents from its own tag, so
    deleting a fixture cannot hide a regression.
  • Requirement IDs — 36 stable identifiers, 33 fixture-pinned, generated
    traceability matrix.
  • DCO enforced, actionlint pinned by checksum, release-tags bypass removed.

Two findings that came out of building rather than planning

  • Sourcemeta's linter would have broken a normative code. Its enum_to_const
    rule wants specVersion's single-value enum collapsed to a const — but
    validator.ts maps enum and const on those paths to different
    diagnostics. Blanket adoption would have silently changed
    ERR_UNSUPPORTED_SPEC_VERSION. All six excluded rules carry written reasons.
  • .gitattributes destroyed a fixture I had just written. The CRLF case
    asserts CRLF endings are accepted; * text=auto eol=lf normalised it on
    commit, so the committed blob had no CRLF and the case passed while testing
    nothing. Both byte-level fixtures are now -text, verified from a fresh clone.

Compatibility

  • Breaking — a previously valid document now fails.

Every YAML-profile rule rejects a document v1 accepts today. Permitted only
because no family has been published: ADR 0005 §1 waives the new-major
requirement while a family has no released version, and that window closes at
the first tag. Requires CODEOWNERS approval.

Verification

task check green. The regression tests were confirmed to fail against the
old site.ts
before it was replaced, and the release flow was simulated
end-to-end against a copy of this repository:

  • two releases published; /component/v1.0.0/ still served, byte-identical,
    after v1.1.0 shipped
  • pinned bytes unchanged across an unreleased edit to main
  • a rewritten tag caught with a clear diagnostic
  • deleting a fixture and its examples left all 19 regressions still reported

Final state: 139 conformance cases · 36 requirement IDs (33 pinned) · 127
subjects agreeing across Ajv and Blaze · 349 internal links resolving · 23 unit
tests.

Note

task check is ~1 minute slower, almost entirely the Ajv/Blaze parity pass.
Worth it for a published contract in my view, but it is a one-line removal.

Checklist

  • task check passes locally
  • schemas/dist/ regenerated with task bundle and committed
  • Conformance fixtures added for every behavioural change, each citing a clause
  • Normative prose updated in the affected spec.md
  • Commit messages are Conventional and correctly scoped
  • Commits are DCO signed off

🤖 Generated with Claude Code

The published origin could not keep the immutability promise README makes.
site.ts wiped the whole tree and copied the current working-tree bundle to
both the alias and the exact-version path, so a pinned URL moved when main
moved and vanished when a newer version shipped. The guard that appeared to
prevent this tested a tree rmSync had deleted 35 lines earlier.

Pinned paths are now extracted from their own tags, so every release is
reassembled on every deploy. published.json records each release's path and
checksums, written on the release pull request where squash-only merges and
strict status checks guarantee the entry describes the commit that gets
tagged. Pages is called from release.yml because both workflows trigger on
push to main, and a tag-sourced build racing tag creation would omit the very
version being released.

Pinned copies are restamped with their own exact-version $id. The bundler
stamps the alias URL, so every release previously claimed the same canonical
identity as the alias and as each other, and a validator resolving by $id
could not tell two versions apart.

Release assets are verified rather than clobbered.

Refs docs/adr/0006-publication-from-tags.md

Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
…strict

`discriminator` is an OpenAPI keyword, not JSON Schema 2020-12. The adjacent
oneOf and const do the actual validation, so it was already an annotation
wearing a standard-looking name. It is now x-musher-discriminator, which says
what it is.

That rename is what allowed strict mode back on. Both Ajv instances used
strict:false solely to tolerate x-additionalPropertiesName, which also
switched off the unknown-keyword check — so a misspelled `additionalPropertes`
or `minimun` would compile clean and silently enforce nothing, in a repository
whose document format rejects unknown instance fields at every level. The two
extensions are now registered explicitly and lint.ts fails any other x- keyword.

strictTypes stays off: it wants a redundant `type` beside every pattern and
required, including inside an if/then whose container has already fixed the
type, and adding those would edit schemas to satisfy a linter rather than to
say anything new.

The linter and the validator now share one compiler. Two configurations were
two definitions of a valid schema, and the looser one decided what shipped.

Validation behaviour is unchanged: all 130 conformance cases pass.

This must precede the first tag — it changes the published bundle bytes.

Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
The parser phase was one table row: "Strict YAML 1.2. Duplicate keys MUST be
rejected. Anchors and aliases MUST be rejected." Everything else about how a
document is read was unstated, and the implementation had already drifted past
the prose — document.ts set merge:false, which no clause required.

Left unspecified were merge keys, explicit tags, multi-document streams,
non-string mapping keys, encoding and BOM handling, scalar resolution, and any
size, depth, or scalar bound. §7.1 now states all of them, with the reason for
each: a document that means different things to different readers, or that
cannot be judged without unbounded work, is not a contract.

The bounds are numbers rather than advice. "Be sensible" is not a bound — a
document one validator accepts and another refuses on size is not one contract,
and an author has no way to discover a limit except by exceeding it. §7 already
argued for a bound (the billion-laughs shape) and then never stated one.

Scalar resolution is pinned to the YAML 1.2 core schema, which is where naming
the version earns its keep: a 1.1 parser reads `no` as boolean false, so
`country: no` read by both is two different documents.

Seven new parser diagnostics, each with a fixture. Blueprint and listing
inherit the profile rather than restating it.

Every rule here rejects a document v1 accepts today, so it must precede the
first tag — ADR 0005 §1.

Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
Five documents described what is normative and they did not agree.
CONTRIBUTING said "the schema is normative"; the three spec.md headers said the
prose wins over a schema `description`; README named "the schemas and
conformance fixtures" and omitted the prose entirely. Nothing anywhere said how
the prose relates to a schema *assertion* as opposed to a description, which is
the case that actually decides arguments.

One paragraph now appears in all five places: the prose defines complete
behaviour, the schema and the corpus are its executable forms for structural
validity and observable outcomes, and descriptions, examples, and message text
are informative.

It deliberately does not read as a precedence ladder. A disagreement between
two normative artifacts is a defect here that blocks a release — "the prose
wins" is how to read a broken contract, not a licence for the schema to be
wrong.

The keyword boilerplate becomes BCP 14: RFC 8174 alongside RFC 2119, the
missing NOT RECOMMENDED, and the "when, and only when, they appear in all
capitals" clause the specs need, since all three use lowercase "must" and
"should" in explanatory prose.

Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
Four new lint rules, each closing a gap where the repository stated a property
in prose or a $comment and nothing checked it.

Pattern portability. Several $comments assert a pattern is "lookahead-free, so
it compiles under RE2 as well as ECMA-262" — the ReDoS mitigation SECURITY.md
names. That is now enforced across all 28 patterns rather than repeated. A
pattern with lookaround or a backreference is two different rules: RE2 (Go,
Rust) refuses to compile it while JavaScript and Python run it, and both can be
made to backtrack exponentially.

default and examples must satisfy the schema they sit in. Neither keyword
asserts anything, so a default its own schema rejects is invisible forever —
and it is the most misleading kind of wrong, because an editor offers it as a
completion. 61 annotations across the three families are now checked; none was
before.

No TODO, TBD, FIXME, or XXX in a released schema. A description is what an
editor shows an author as they type the field.

One source module per family, until the bundler can embed a second. It keeps an
embedded module's $id — a separate schema resource under 2020-12 — while
hoisting that module's $defs to the bundle root, so an internal #/$defs/Foo
inside it resolves against its own base URI and finds nothing.
assertSelfContained would not catch it: that check is lexical and resolves
against the root's $defs, where Foo does exist. The result would be a bundle
passing every gate that no validator can resolve. The guard is dormant today —
all three families have one module — and says so where someone would otherwise
find out from a published bundle.

Each rule was confirmed to fail closed before being committed.

Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
DCO was required in CONTRIBUTING.md, GOVERNANCE.md, and the pull request
template, and enforced nowhere — a policy that exists only in prose. The new
check verifies every non-merge commit carries a Signed-off-by trailer matching
its author, and is added to main-branch.json's required contexts. Merge commits
are exempt: they are not authored content, and requiring a trailer on one would
fail every branch brought up to date rather than rebased.

actionlint was installed by piping a script fetched from a mutable ref into
bash — the one place in CI where an upstream compromise would run arbitrary
code holding the workflow's token. It now downloads the release tarball and
verifies a pinned SHA-256, checked against upstream's published checksums file.

RULESETS.md claimed tag creation was restricted to administrators and the
release-please workflow. No such rule existed. That is worse than the gap: it
describes a control a reader would then not think to add. Creation stays open
and the file now says why — published.json is the control that matters, since a
tag with no ledger entry fails the deploy, so an unauthorised tag cannot become
a published version.

release-tags no longer allows OrganizationAdmin bypass. Using it there means
mutating a published artifact, which is the one thing this repository promises
never happens, and an escape hatch nobody may legitimately use is one an
attacker inherits. Disabling the ruleset remains possible and is a visible,
logged act rather than a silent one.

Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
No family had a security section. The reasoning existed — listing §4.1 is a de
facto one, path containment is argued in blueprint §4.1, the alias ban is
argued in component §7 — but it was scattered through clauses a reader reaches
only if they are already looking at that field.

Each family now carries one, addressed to implementations rather than to this
repository: what a validator must do so that reading an untrusted document is
safe. SECURITY.md remains the separate question of what counts as a
vulnerability here and how to report it, and now says so.

Several rules are stated as security rules for the first time. A validator MUST
NOT echo a resolved configuration value in a diagnostic, because diagnostics
land in CI logs and pull requests. Containment MUST be decided on the resolved
location, so a symlink out of an item is an escape even when its target does
not exist. Cycles MUST be detected rather than bounded by a recursion limit,
because a stack overflow is a crash and not a diagnostic.

The format policy was documented only in a comment in validator.ts. §7.2 states
it: the keyword is an annotation and asserts nothing, which is the 2020-12
default and not a shortcut, and a rule that must hold everywhere is written
with an assertion keyword instead. It also disambiguates the keyword from the
component field named `format` that §6.1 defines — no schema here uses the
keyword at all, and the field is a Musher enum with unrelated rules.

SECURITY.md's verification recipe named a checksums.txt the release workflow
does not produce. It now names the .sha256 that is actually attached, and adds
the pinned-URL sidecars and published.json.

Sections are appended rather than inserted so that no existing section number
moves; prose across all three families cites them by number.

Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
"Musher conformant" was a claim with no fixed meaning. The corpus has a
capability phase that needs an account, a region, and a quota — so no
implementation a user runs locally can pass it — and an adapter was told to skip
what it cannot run. An implementation skipping every capability case and one
passing all four phases could therefore make the same claim.

Four cumulative profiles now name what was passed: parser, structural, offline,
and platform. `offline` is the highest a client can reach, and is a named
stopping point rather than a shortfall: no phase below capability may touch the
network, so an implementation running everything a client is permitted to run is
complete at offline.

The profiles are computed in conformance.ts from the phases the runner actually
implements, not just described in prose, so a phase added to IMPLEMENTED_PHASES
moves the reported profile with it. This runner reports `offline`.

Also a report shape, so two claims can be compared. It carries suiteCommit as
well as specificationRelease, because cases are added to the corpus between
releases and "which cases were run" is not answered by a version number.

conformance.ts now guards main() behind import.meta.main, matching bundle.ts and
catalog.ts — importing it to reuse profileFor was running the whole suite.

Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
Component §3 promises a document validating against v1.0.0 validates against
every later v1.x.y. Nothing checked it.

The existing gates cannot. Drift proves the bundle matches its sources, and
conformance proves the corpus still behaves — but the corpus is the corpus as it
exists now, so deleting a fixture and tightening the rule it covered passes
both. A schema diff cannot settle it either: whether a change to a oneOf, a
conditional, or a pattern rejects some previously valid document is a question
about documents, not about schema text.

check:compat replays them. For every release tag, it takes that release's
examples and every conformance case it declared expected:pass, and validates
them against the candidate schema. Documents are read from the tag rather than
from the working tree, which is what makes deleting a fixture unable to hide the
regression it would have caught.

Verified against a copy of this repository with a simulated component/v1.0.0:
19 documents replayed clean, a minimum bumped on metadata.version produced 19
precise regressions naming each document and rule, and deleting both the
fixture and the examples directory left all 19 still reported.

Parser and structural only. A semantic rule is decided against the item a
document sits in and those surroundings are not reconstructed here — that is
check:conformance's job — and structural is where "validation became stricter"
actually shows up.

A no-op until the first tag, which is the right time to build it: afterwards
there is a released version whose guarantee is already running.

Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
The specifications are held together by cross-references — a family citing
another family's clause, an ADR citing the section it refines. check:conformance
already verified that a fixture's `clause` resolves, because that citation is
load-bearing. Every other citation is load-bearing for the same reason and had
nothing checking it.

check:links resolves all 223 of them. The failure it catches is quiet: renaming
a heading leaves a link that still renders as a link and still looks right in a
diff, and just goes nowhere. In a document whose job is to be precise about
where a rule lives, a citation to a section that no longer exists is worse than
no citation.

External links are deliberately out of scope. They fail for reasons unrelated to
this repository, and a check that goes red because someone else's site is down
teaches people to ignore it.

check:spelling covers prose, tooling sources, and schema descriptions — the last
because a description is what an editor shows an author as they type. The
dictionary is a record of vocabulary the specification has committed to, not a
list of words a checker had not heard of, and it says so: prefer rewording over
adding an entry.

Both were confirmed to fail closed: a missing file, a missing anchor in another
document, and a missing anchor in the same document are each reported.

Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
Every executable claim here was decided by one library. check:examples,
check:conformance, and check:schema all ask Ajv, so a schema Ajv happens to read
differently from everyone else would pass every gate in this repository and fail
in the CLI, the API, and every SDK — the one failure this repository exists to
prevent.

check:parity validates all 127 structural subjects twice, once with Ajv and once
with Blaze through the Sourcemeta CLI: a separate 2020-12 implementation in a
different language. They currently agree on every verdict. Only the verdict is
compared — diagnostic text is not normative, and requiring Blaze to emit Musher
codes would be requiring a second implementation to be the first. It also
cross-checks both against what the corpus declares, since two validators can
agree and still both be wrong.

check:standards meta-validates every source module and bundle with the same
independent toolchain.

Its `lint` is advisory rather than blocking, with six rules excluded and a
written reason for each. Five are house style. The sixth is why blanket adoption
would have been a mistake: enum_to_const wants `specVersion`'s single-value enum
collapsed to a const, and validator.ts maps enum and const on those paths to
different diagnostics — ERR_UNSUPPORTED_SPEC_VERSION and ERR_WRONG_KIND — so
taking the advice would have silently changed a normative code.

Blaze is invoked in chunks of twenty. Nearly all the cost is startup and
compiling a 36 KiB schema, and past roughly fifty paths the CLI concatenates its
arguments into one nonexistent filename and exits 6. Adds ~1 minute to `task
check`.

The CLI is AGPL-3.0, used as a CI tool only; GOVERNANCE.md now records that and
what it does not imply. Absent, both checks report themselves skipped rather
than passed.

Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
Every fixture cited a section anchor, and a section states several rules. 27
cases cite #envelope, which covers specVersion, kind, unknown fields, and an
unsupported version — so the citation said where to look and not what was being
pinned. An anchor is also only as stable as the heading above it.

36 requirements now carry stable IDs — every rule table across the three
families, plus the envelope and the component semantic rules — and 33 are pinned
by at least one case. metadata.json gains a `requirements` array, validated the
way `clause` already was: an ID that resolves nowhere fails the build, because
an ID that resolves nowhere reads as traceability and provides none.

Coverage runs both ways, reusing the machinery that already does this for
diagnostic codes: a declared ID must be cited by a case or recorded in UNPINNED
with a written reason. Three are — specVersion, metadata, and spec are required
by every fixture in the corpus, so no single case pins them.

An ID names a rule a document can violate. That boundary is what keeps the gate
meaningful: rules about what an implementation does — MUST NOT reach the
network, MUST NOT echo a value in a diagnostic — stay normative prose without
IDs, because an identifier whose permanent state is "excused" documents nothing.

Writing the IDs down found two rules the corpus never tested. COMP-YAML-002 and
-003 say a byte order mark and CRLF endings are accepted and carry no meaning;
both are permissive, both are fixturable, and neither had a case. They do now,
rather than being excused.

docs/traceability.md is generated from the IDs and gated for drift like
catalog.json. It shows each requirement against the clause stating it and the
cases pinning it — a question previously answered by grepping.

Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
…ures

The CRLF fixture asserts that CRLF line endings are accepted. `* text=auto
eol=lf` in .gitattributes rewrote it to LF on commit, so the committed blob had
no CRLF in it at all and the case passed while testing nothing — the exact
failure the fixture exists to catch, committed into the fixture itself.

Both byte-level parser cases are now `-text`, opting out of normalization.

Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
… second false claim

Component §10 said "One debt remains, and it MUST be resolved before v1 is
declared stable" — schema descriptions still speaking the platform's
vocabulary. ADR 0005 follow-up 2 confirmed it untouched. It was the only item
any family stated as a hard precondition for the first tag; blueprint and
listing record theirs as accepted gaps.

Three descriptions named "snapshot compute", a pipeline stage a reader outside
musher-dev/platform cannot look up. A CONFIG_REF is now described the way §5.3
describes it — a name whose value is resolved elsewhere and never appears in
the file. Descriptions are explanatory, so nothing turned on the words; a reader
who could not resolve them was still being sent somewhere they could not reach.

The .github/conventional-commits.yaml header was the same defect as the
RULESETS.md tag-creation claim this branch already fixed: it called itself the
single source of truth "consumed by lint-pr.yml", and nothing read it. The
workflow inlines the lists, the lefthook hook inlines the types again in a
regex, and a comment asked people to keep three copies in step.

Neither copy can read the file at runtime — the action takes static YAML inputs
and the hook is a shell regex — so the fix is not to remove the duplication but
to make it verifiable. check:commits fails when any of the three disagree, which
is what actually makes the file the source of truth. The scope matters more than
it looks: it selects a release train, so a scope the hook accepts and CI rejects
is a release that does not happen.

docs/traceability.md was reachable only from conformance/README.md; README now
names it, and published.json, in the layout.

Two ADR 0006 follow-ups were closed before it was accepted — the RULESETS claim
and the tag-ruleset bypass — and now read as closed rather than open.

Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
Every workflow asked setup-task for `3.x`, which resolves the newest matching
release through the GitHub API on each run. When that API degraded, all three
required checks failed at the setup step — before reaching a single check —
with "unable to get latest version". Nothing about the branch was wrong.

Pinned to 3.52.0, the version the devcontainer already runs, and recorded in
mise.toml beside the actionlint pin with the same "two environments, one
version" note. This repository already SHA-pins every action and now verifies
actionlint by checksum; resolving a range at build time was the last place CI
asked the network what to install.

Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
@justinmerrell
justinmerrell merged commit e840e6d into main Aug 17, 2026
13 of 15 checks passed
@justinmerrell
justinmerrell deleted the feat/publication-immutability branch August 17, 2026 16:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant