feat(repo): rebuild exact-version schemas from tags, and harden the specification for 1.0.0 - #42
Merged
Merged
Conversation
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>
This was referenced Aug 17, 2026
This was referenced Aug 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changes
Hardens the specification and its publication pipeline before the first
1.0.0tag, 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.tswiped the publication tree and copiedthe current working-tree bundle to the exact-version path. After
v1.0.0,the next merge to
mainwould have republished at/component/v1.0.0/; afterv1.1.0, that URL would have 404'd.README.mdcalls those URLs "Immutableforever". The guard that appeared to prevent this tested a tree
rmSynchaddeleted 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 -lis empty and the three1.0.0release 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
discriminatorrename both tighten validation and can only ship before thefirst tag.
New: ADR 0006.
Four defects the review missed
$id— the alias URL. All releasesclaimed the same canonical identity, so a validator resolving or caching by
$idcould not tell two pinned versions apart. Pinning was defeated at theidentity layer, not just transport.
pages.ymlandrelease.ymlboth trigger on push tomainand release-please tags mid-run,so a tag-sourced build would have deployed without the version just cut.
Fixed by calling Pages from
release.ymlafter the tag exists.assertSelfContainedgives a false green on the dormant multi-modulebundler bug — it checks lexically against the root's
$defs, which is exactlywhere the missing definition still exists.
RULESETS.mdclaimed a tag-creation restriction that did not exist.Highlights
document.tswas 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.
BCP 14 boilerplate.
formatpolicy that livedonly in a code comment.
discriminator→x-musher-discriminator.zero disagreements.
deleting a fixture cannot hide a regression.
traceability matrix.
release-tagsbypass removed.Two findings that came out of building rather than planning
enum_to_construle wants
specVersion's single-value enum collapsed to aconst— butvalidator.tsmapsenumandconston those paths to differentdiagnostics. Blanket adoption would have silently changed
ERR_UNSUPPORTED_SPEC_VERSION. All six excluded rules carry written reasons..gitattributesdestroyed a fixture I had just written. The CRLF caseasserts CRLF endings are accepted;
* text=auto eol=lfnormalised it oncommit, 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
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 checkgreen. The regression tests were confirmed to fail against theold
site.tsbefore it was replaced, and the release flow was simulatedend-to-end against a copy of this repository:
/component/v1.0.0/still served, byte-identical,after
v1.1.0shippedmainFinal 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 checkis ~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 checkpasses locallyschemas/dist/regenerated withtask bundleand committedclausespec.md🤖 Generated with Claude Code