Skip to content

feat: fill the spec.md TODOs from implemented behaviour, and settle cycle detection - #13

Merged
justinmerrell merged 12 commits into
mainfrom
spec/fill-todos-issue-9
Aug 9, 2026
Merged

feat: fill the spec.md TODOs from implemented behaviour, and settle cycle detection#13
justinmerrell merged 12 commits into
mainfrom
spec/fill-todos-issue-9

Conversation

@justinmerrell

Copy link
Copy Markdown
Contributor

Closes half the TODO markers across the three spec.md files — the half #9 supplies answers for — and settles the contradiction it raised first.

The cycle decision

§4.2 mandated an acyclic connection graph; the platform deliberately permits cycles under ADR 0116, for mutual service discovery. The specification holds its position. ADR 0001 §1 is the grounds: "Where an implementation and the conformance suite disagree, the implementation is defective."

The consequence is not hidden — the platform gains a cycle check that rejects a composition it accepts today. The platform's argument is recorded in the clause rather than dismissed, because it is correct as far as it goes: an output is a function of its own node alone, so a two-pass resolver needs no topological order and does not fail on a cycle. §4.2 says so, then says why the rule stands anyway — permitting cycles obliges every implementation in every language to be that resolver in perpetuity, forecloses any later rule needing an order, and leaves every reader of a six-node graph working out whether it terminates.

Cycle reporting is pinned so it is comparable across implementations: the participating nodes as a closed walk from the lexicographically smallest node.

Why so much of this is structural rather than semantic

Nearly every rule in #9 is labelled semantic, but conformance.ts implements parser and structural only — a semantic fixture is skipped before its diagnostics.json is even read. Rules that JSON Schema can carry are therefore expressed in the schema, where CI actually runs them, following the BlueprintNode precedent from #12.

Timing: no tag exists yet and the 1.0.0 release PRs (#1, #2, #3) are still open, so these tightenings are free. After those merge each one is a v2 directory plus a migration note. This should land first.

One rule stays deliberately out of the schema: the floating-tag blocklist. It is a curated list that will grow, and growing a pattern is a major version — held as a semantic rule it can be extended in a minor.

Translating #9

The issue is written in platform vocabulary. Three of its rows change meaning on the way across:

Issue says This contract
build forbidden for IMAGE already enforced — ComponentImageSource is closed and has no build
input keys unique within a component no-op — contract.inputs is a mapping, so a repeat is ERR_DUPLICATE_KEY in the parser phase
output templates reference only params.*/self.* no template language exists; restated as "an output's value is a function of the producing node alone"
merge in (ordering, componentId) order neither field exists; redefined as lexicographic by node name

Plus the ordinary renames: imageRefsource.ref, gitReposource.repositoryUrl, runCommandcommand, cronExpressionschedule.cron.

Two rules deliberately not adopted, both worth a second opinion:

  • SERVICE requires ≥1 endpoint. Would invalidate examples/minimal.yaml and structural-001-minimal-valid. The platform is stricter than the spec here.
  • command forbidden on SERVICE/WORKER. The schema's description said "or null otherwise", but overriding an image's default command is meaningful, unlike a stray schedule. Kept permissive.

One rule the spec refuses to bless as implemented: the parameter merge. First-wins silently discarding a conflicting second declaration hands the second component a value validated against the first component's rules, failing at deploy time inside the consuming workload. An identical redeclaration is absorbed; a differing one is ERR_CONFLICTING_INPUT_SCHEMA.

What landed

Prose — 10 of 21 TODOs closed. component §5, §5.1, §5.4, §5.5, §6; blueprint §3, §3.1, §4, §4.2, §5; listing §3, §5. New: blueprint §3.1 defines the item directory, which nothing defined even though §4.1 already said "item root" and every identity and media rule depends on it.

Schema — kind × field validity, image-ref pinning grammar, build required on a GIT source, the readiness rule, mountPath absoluteness, the ui/suppliedBy and valueFrom/value correspondences, the generated-input rule, node-name grammar, media path grammar.

Fixtures — 22 new cases. 16 negatives were red before their schema commit and green after; 6 positives are regression pins. 5 semantic cases cover every rule a single document can express.

Toolingconformance.ts validated nothing for a phase it does not implement, so a semantic fixture's declared codes and clause were checked by nothing. Metadata validation now runs for every case: the clause resolves to a real anchor, and every declared code appears in a diagnostics table reachable from the family's own, at the phase that table assigns it.

Two corrections found on review

  • component §5 claimed a forbidden field may be "absent, an empty mapping, or an explicit null". Only one per field is true, and the schema was right.
  • blueprint §5 claimed an empty parameters mapping differs from an absent one. Nothing distinguishes them; both now mean derive.

Deferred

Eight semantic rules need a directory on disk — slug vs directory name, version vs sibling, unreferenced components, reference resolution, media existence. A case is one case.yaml, so none is expressible. Extending the contract is ADR-gated (GOVERNANCE.md → "Changing the conformance fixture contract"); conformance/README.md now records exactly what is blocked and on what. #9's author offered to contribute those fixtures — worth taking up against that ADR.

Also worth tracking: the examples reference files that do not exist here (./components/postgres.yaml, media/icon.png). Harmless while only the structural phase runs; every one fails the moment semantic checking lands.

Verification

task check is green: 3 modules linted, 3 bundles match a fresh compile, 8 examples validate, 47 conformance cases pass (5 skipped — the semantic ones, as conformance/README.md mandates).

The three test(conformance) commits are deliberately red on their own; that redness is the fail-before evidence CLAUDE.md non-negotiable #3 requires, and each commit message records which cases failed and why the green ones are pins rather than proof.

Refs: #9

🤖 Generated with Claude Code

The runner skipped unimplemented phases before it read `diagnostics.json`,
so a `semantic` fixture's declared codes and clause were checked by nothing
at all. Issue #9 turns nearly every remaining `spec.md` TODO into a
`semantic` rule, which would have landed a corpus this repository cannot
execute and did not inspect either.

Split the two concerns. Executing a case needs a phase implemented here;
checking that a case is well-formed does not, and now runs for every case:

- `metadata.phase` is one of the four, and `cases.json` agrees with it. The
  index entry's `phase` was previously cast and discarded.
- `id` leads with its phase, per the README's `<phase>-<NNN>-<description>`.
- `clause` resolves to an `<a id="…">` that exists in the cited `spec.md`.
  Anchors are load-bearing precisely because fixtures cite them, and nothing
  noticed when one went stale.
- every declared `code` appears in a diagnostics table reachable from the
  family's own — its own additions unioned with component §8, mirroring the
  "The codes in component §8 apply. This family adds:" inheritance the prose
  already declares — and at the phase that table assigns it.

The last one is the reason for the rest. A typo'd or wrong-phase code used
to pass CI silently, and for a skipped phase it would have stayed silent
indefinitely.

No fixture changes: all 25 existing cases satisfy the new checks unmodified.

Refs: #9
Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
…act clauses

Five of the ten TODO markers in this file, answered from behaviour already
implemented and gated in the platform. Issue #9 supplies each answer with its
enforcing call site; three of its rows change meaning on the way across,
because the issue is written in the platform's vocabulary and this contract
does not share it.

§5 Workload — a kind × field table. `endpoints` are for a SERVICE, `command`
is required on a JOB and a CRON, `schedule` is required on a CRON and rejected
everywhere else. The §5 TODO asked whether a misplaced field here is an error
or ignored, and §2 had already answered it for every other field: a misspelled
field is an error, never a silently ignored one. A `schedule` on a SERVICE
fails the same way and for the same reason.

Two rows differ from the platform's table deliberately:

- `command` stays permitted on a SERVICE and a WORKER, where it overrides the
  image's default command. That is meaningful, unlike a schedule, so the
  symmetry with `schedule` would be false symmetry.
- a SERVICE is NOT required to expose an endpoint. The platform requires one;
  adopting that would reject `examples/minimal.yaml` and the
  `structural-001-minimal-valid` fixture, so it is recorded in the prose as a
  candidate for a later major rather than taken now.

The platform's "input keys unique within a component" row is a no-op here:
`contract.inputs` is a mapping, so a repeated key is already ERR_DUPLICATE_KEY
in the parser phase.

§5.1 Source — image references must be pinned, split across two layers. The
grammar (a tag or an @sha256 digest is present) is structural. The
floating-tag blocklist is semantic, and stays out of the schema on purpose:
the list is curated and will grow, and growing a `pattern` makes a previously
valid document invalid. Held as prose plus a fixture, it can be extended in a
minor release.

§5.4 Health probes — `readiness` is required for a SERVICE exposing at least
one PUBLIC endpoint, and a private-only service is exempt. Probe defaults are
written down. A probe naming an endpoint that does not exist is a new semantic
code, ERR_UNKNOWN_ENDPOINT.

§5.5 Volumes — `mountPath` must be absolute. Volume overlap and size bounds
are stated as gaps rather than described aspirationally: neither project
constrains them, and a reader needs to be able to tell which silences are
decisions.

§6 Configuration contract — the `ui`/`suppliedBy` correspondence and the
`valueFrom`/`value` correspondence were both already asserted in the schema's
own descriptions and enforced by nothing. A generated input must be USER-
supplied and sensitive, both written explicitly, because `isSensitive`
defaults to false and that is the wrong answer for a minted secret.

Issue #9 frames the last §6 rule as a `params.*`/`self.*` template namespace.
There is no template language in this contract — an output is `valueFrom:
DERIVED|DECLARED` plus a static string — so it is restated in the contract's
own terms: an output's value is a function of the producing node alone and
must not depend on a value that node received over an inbound connection. That
is what makes an output referenceable. It is explicitly not a licence for a
cyclic graph; blueprint §4.2 requires acyclicity on its own grounds.

Prose only. Nothing in this commit changes what validates.

Refs: #9
Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
Seventeen structural cases and two semantic ones covering every rule the
preceding commit wrote into spec.md.

Deliberately red at this commit — that redness is the fail-before evidence.
Measured against the previous bundle, twelve of the seventeen structural
cases fail:

  006  expected to fail but validated cleanly  endpoints on a WORKER
  008  expected to fail but validated cleanly  JOB without command
  010  expected to fail but validated cleanly  CRON without schedule
  011  expected to fail but validated cleanly  schedule on a SERVICE
  012  expected to fail but validated cleanly  bare image name
  014  expected to fail but validated cleanly  GIT source without build
  015  expected to fail but validated cleanly  public SERVICE, no readiness
  017  expected to fail but validated cleanly  relative mountPath
  018  expected to fail but validated cleanly  generated input not sensitive
  019  expected to fail but validated cleanly  ui on a CONNECTION input
  020  expected to fail but validated cleanly  USER input without ui
  021  expected to fail but validated cleanly  DERIVED output with a value

The other five are green beforehand and are regression pins rather than proof
of a fix. Each exists because it is the case a careless rule would break:

  007  an empty `endpoints: {}` on a WORKER — forbidden bounds the contents,
       not whether the key may be written
  009  a well-formed CRON, so the two CRON rules cannot be satisfied by
       rejecting every CRON
  013  `localhost:5000/musher-dev/api@sha256:…` — the registry port is the
       case the pinning grammar has to get right, since a naive rule reads
       the port colon as a tag
  016  a PRIVATE-only SERVICE with no readiness probe, pinning the exemption
  022  the whole contract surface at once: a generated sensitive input, a
       DERIVED output, and a DECLARED one

The two semantic cases — a floating `:nightly` tag and a probe naming an
endpoint that does not exist — are skipped by this repository's adapter, which
implements the parser and structural phases only. They are not unchecked: the
preceding tools commit validates their metadata, clause anchor, and declared
codes whether or not the phase runs here.

Refs: #9
Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
Turns the preceding two commits green. Every rule here is one JSON Schema
2020-12 can carry, so it lands in the structural phase where CI actually runs
it, rather than as a semantic rule this repository would skip.

`ComponentWorkload` gains four conditionals under `allOf`:

- endpoints are capped at `maxProperties: 0` on a WORKER, JOB and CRON. The
  cap rather than `endpoints: false` so an explicit empty mapping stays legal
  — the same spelling the blueprint examples use for an empty connections
  block.
- `command` is required and non-empty on a JOB and a CRON.
- `schedule` is required on a CRON and `type: null` everywhere else, so an
  absent or explicitly-null schedule is fine and a real one is not.
- readiness is required for a SERVICE with at least one PUBLIC endpoint.
  Endpoints are a mapping and no keyword asserts "some member matches" over
  one, so the condition is the negation of "every member fails to match".
  A `$comment` says so; it is not a construction to re-derive at a glance.

Each branch carries no `type`, so `lint.ts` `checkClosedObjects` skips it
correctly — the object is closed once, by the schema declaring its properties.

`ComponentImageSource.ref` gains the pinning grammar. Lookahead-free so it
compiles under RE2 as well as ECMA-262, matching the precedent set by
`BlueprintNode.component`. The load-bearing part is that the tag colon is the
one after the final slash: without that, `localhost:5000/nginx` reads as an
image named `localhost` tagged `5000/nginx` and an unpinned reference behind
a ported registry validates.

`ComponentGitSource` requires `build`, and it is no longer nullable. The
"or null when defaults apply" the description promised was a default the
document could not see and the platform does not implement.

`ComponentInput` gains the `ui`/`suppliedBy` correspondence and the
generated-input rule. `suppliedBy` defaults to USER, so the USER branch is the
`else` — a default is invisible to a validator, and an input omitting
`suppliedBy` must still be caught. `isSensitive` is required explicitly for
the opposite reason: its default is false, which is the wrong answer for a
minted secret, so absence cannot be allowed to mean it.

`ComponentOutput` gains the `valueFrom`/`value` correspondence, and
`ComponentVolume.mountPath` an anchored `^/`. Both only assert what the
fields' own descriptions already claimed.

All eight examples still validate; `examples/minimal.yaml` survives because
the SERVICE-must-expose-an-endpoint rule was not adopted.

Refs: #9
Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
Issue #9 raised the one TODO in this repository that is a contradiction rather
than a gap: §4.2 mandates an acyclic connection graph, and the platform
deliberately permits cycles under ADR 0116, to support mutual service
discovery.

**The specification holds its position. The graph MUST be acyclic and
ERR_DEPENDENCY_CYCLE stays.** ADR 0001 §1 is the grounds — "Where an
implementation and the conformance suite disagree, the implementation is
defective" — and the consequence is not hidden: the platform gains a cycle
check that rejects a composition it accepts today.

The platform's argument is recorded in the clause rather than dismissed,
because it is correct as far as it goes. An output is a function of its own
node alone, so a resolver that computes every output before binding any edge
needs no topological order and does not fail on a cycle. §4.2 says so outright
and then says why the rule is required anyway: permitting cycles obliges every
implementation in every language to be that two-pass resolver in perpetuity,
forecloses any later rule that needs an order — an ordered rollout, a
health-gated start, a value that legitimately depends on an inbound edge — and
leaves every reader of a six-node graph working out for themselves whether it
terminates. One traversal is cheaper than the option it keeps open.

Cycle reporting is pinned so it is comparable across implementations: the
participating nodes as a closed walk from the lexicographically smallest node
in the cycle, anchored at that node's `connections`.

The rest of the file:

§3 Identity gains its three rules — slug equals the item directory name,
version agrees with the sibling listing, and every component document in the
item is referenced by some node. The last is new, from issue #9, and is what
stops an item silently accumulating last release's component file beside the
one in use. Its diagnostic anchors at `/spec/components`, because a JSON
Pointer addresses this document and the file it complains about is not in it.

§3.1 defines the item directory. Nothing defined it, and §4.1 was already
using "item root" as though something did — every identity rule, the reference
containment rule, and listing §5's media paths are all measured against it.
The section also draws the line §4.1 draws: a document handed over without a
directory has no item root, and an implementation in that position MUST NOT
report any of these rules rather than guess at one.

§4 gains the node-name grammar, the slug grammar reused. Uniqueness needs no
rule — `spec.components` is a mapping, so a repeat is ERR_DUPLICATE_KEY in the
parser phase. The `size` TODO stays, narrowed: the Compute Profile vocabulary
is not published in this repository and a reader outside the platform cannot
resolve `general.standard.small`, which is part of what closing it means.

§4.2 gains the `fromRole` and `fromOutput` resolution rules alongside the
cycle clause, and a narrowed TODO for output/input type compatibility, which
issue #9 does not answer.

§5 Parameters gains derivation and the merge rule. Two corrections to what was
there:

- "An empty `parameters` mapping is not the same as an absent one" was not
  true and could not be made true — nothing distinguishes the two states. Both
  now mean derive.
- issue #9's merge rule sorts by `(ordering, componentId)`. Neither field
  exists in this contract, so canonical order is redefined as lexicographic by
  node name, the only total order the document itself supplies.

The platform's first-wins discards a conflicting second declaration silently.
That is not blessed. An identical redeclaration is absorbed; a differing one
is ERR_CONFLICTING_INPUT_SCHEMA. Silent first-wins hands the second component
a value validated against the first component's rules, and the failure
surfaces at deploy time inside the consuming workload with nothing pointing
back at the two documents that disagreed.

Prose only. Nothing in this commit changes what validates.

Refs: #9
Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
…ules

  012  expected to fail but validated cleanly — `Web_Server` is a legal map
       key today, so the node-name grammar is the fail-before evidence.

The two semantic cases are the only rules from the preceding commit that a
single document can express. Everything else §3 added — slug against the
directory name, version against the sibling listing, the unreferenced
component document — needs a directory on disk, and the fixture contract is
one `case.yaml`. Those are deferred rather than approximated; see the
follow-up on extending the contract.

  semantic-001  a connection whose `fromRole` names no node. The whole graph
                is in this one document, so nothing external is needed.
  semantic-002  a two-node cycle, and the case the reporting rule exists for:
                `api` and `web` discover each other, and the walk must be
                reported from `api` because it is lexicographically smaller —
                otherwise which node a traversal happened to start from would
                leak into a normative diagnostic.

Both are skipped by this repository's adapter, which implements the parser and
structural phases only. Their metadata, clause anchors and declared codes are
checked regardless.

Refs: #9
Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
`spec.components` accepted any string as a node name. `propertyNames` now
holds it to `^[a-z][a-z0-9-]{0,61}[a-z0-9]$`, the grammar `metadata.slug`
already uses — one grammar for the names this family coins rather than two.

Turns structural-012 green. The diagnostic anchors at `/spec/components`
rather than at the offending key, which is what `propertyNames` gives; the key
itself is not addressable by a JSON Pointer when it is the thing being
rejected.

The other §3 and §4.2 rules from the prose commit are not expressible here.
Slug-versus-directory, version-versus-sibling, reference resolution and cycle
detection all need something outside the document, which is what makes them
semantic.

Refs: #9
Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
§3 mirrors blueprint §3 and measures against the item root that blueprint §3.1
now defines. One nuance the issue does not cover: the version rule needs a
sibling blueprint to compare against, and a `listingKind: COMPONENT` item need
not contain one. The rule is conditioned on there being a blueprint, and a
narrow TODO records what an item without one agrees with — not the same rule
with a different sibling, since an item may hold several component documents
each carrying its own version.

§5 splits the media rules by what they need.

Structural, because it is a grammar: relative, first segment exactly `media`,
every later segment beginning with a letter or a digit, extension in `.png`,
`.jpg`, `.jpeg`, `.webp`. The `media/` requirement is stronger than the
"relative to the listing document" this section used to claim, and it is the
rule that actually gets enforced — one fixed directory is what lets a
publisher copy an item's assets without walking its listing to discover them.

Semantic, because each needs the filesystem: existence, containment of the
resolved target, and basename uniqueness across the whole item.

`ERR_PATH_ESCAPE` survives the grammar rather than being made redundant by it.
`..` is now unspellable, so traversal is gone — but a symlink under `media/`
is a legal spelling resolving to an illegal target, and containment is a
property of the resolved location rather than of the string. Blueprint §4.1
draws the same line for `ERR_REFERENCE_ESCAPE`.

The basename rule is adopted from the platform with its provenance stated:
published assets are addressed by basename, so `media/desktop/overview.png`
and `media/mobile/overview.png` are one file. It is a real constraint on an
author and is better written down than discovered when the second screenshot
replaces the first.

§4 keeps both its TODOs, as issue #9 asks, but says what is already settled:
`summary` is plain text and must not be rendered as Markdown, `description` is
Markdown bounded at 20 000 characters. The Markdown-subset TODO is reframed as
what it is — an open security question about rendering untrusted input, naming
the three cases that need deciding — rather than a behaviour someone forgot to
document.

Dimension and file-size bounds are recorded as gaps, as in component §5.5.

Prose only. Nothing in this commit changes what validates.

Refs: #9
Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
  007  expected to fail but validated cleanly  ../../etc/passwd.png
  008  expected to fail but validated cleanly  assets/overview.png
  009  expected to fail but validated cleanly  media/overview.gif

010 is green beforehand and is the regression pin: a nested path under
`media/` and an extension in upper case. A JSON Schema pattern carries no
case-insensitive flag, so a careless alternation accepts `.png` and rejects
`.PNG`, and nothing else in the corpus would notice.

semantic-001 is the basename rule, which needs no directory on disk — two
paths in one document are enough to collide. The existence and containment
rules do need one and are deferred with the rest of the tree-shaped cases.

Refs: #9
Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
`icon` and `screenshots[].file` were unconstrained strings, so
`../../etc/passwd.png` was a structurally valid icon and the whole media
contract rested on a semantic phase nothing in this repository runs.

Both now carry the §5 grammar. Lookahead-free so it compiles under RE2 as well
as ECMA-262, and a segment must begin with a letter or a digit — the technique
`BlueprintNode.component` already uses to exclude `.` and `..` without a
negative lookahead. The extension alternation is spelled in character classes
because a JSON Schema pattern has nowhere to put a case-insensitive flag.

`icon` also gains the `maxLength: 512` its sibling `file` already had. It was
the only unbounded string path in the family.

Refs: #9
Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
The coverage note said semantic cases land "once the corresponding rules are
written into spec.md". They are written now, and five semantic cases landed
with them — but eight rules did not, and not because the prose is missing.

Every one of them is about a document's surroundings rather than its contents:
a directory with a name, a sibling document, a file on disk. A case is one
`case.yaml`, so none of them is expressible, and the shortfall would otherwise
read as prose still owed.

Extending the contract to a case tree is ADR-gated — GOVERNANCE.md lists
"Changing the conformance fixture contract" — so the note says what is
blocked and on what, rather than leaving the corpus looking arbitrarily
incomplete.

Refs: #9
Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
Two corrections to the clauses in this branch, found by checking the prose
against what the schema actually accepts rather than against what it was
meant to.

component §5 said a forbidden field "MAY be absent, an empty mapping, or an
explicit null". Only one of the three is true per field, and the schema is
right: `endpoints` is a mapping and rejects null, `schedule` is a nullable
block and rejects an empty mapping. Each field has one empty form and they are
not interchangeable. The clause now says which is which.

The §5 note on a SERVICE without endpoints claimed the rule "would reject
documents this specification currently accepts, which makes it a breaking
change rather than one v1 can absorb". The second half overstates: nothing is
released yet, so v1 could absorb it. It was not adopted, which is a different
claim, and the clause now makes that one.

blueprint §5.2 leaves the merge order comparable across implementations: §4
confines a node name to lowercase ASCII, so byte order and lexicographic order
coincide and no collation can change which node wins a key. Without that the
merge rule is reproducible only among implementations that happen to agree on
string comparison.

Refs: #9
Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
@justinmerrell
justinmerrell merged commit be77e19 into main Aug 9, 2026
4 checks passed
@justinmerrell
justinmerrell deleted the spec/fill-todos-issue-9 branch August 9, 2026 04:42
justinmerrell added a commit that referenced this pull request Aug 9, 2026
…nd issue #9 (#25)

* docs(conformance): ADR 0002, extend the fixture contract to a case tree

Eight of the twenty-four diagnostic codes the three spec.md files declare are
about a document's surroundings rather than its contents — a directory with a
name, a sibling document, a file on disk. A case was one `case.yaml`, so none
of them had a fixture, and they reached `main` as prose with CI green. That is
the failure mode CONTRIBUTING.md ground rule 2 exists to prevent.

GOVERNANCE.md lists "Changing the conformance fixture contract" among the
changes needing an ADR. This is that change.

A case directory MAY now carry a `tree/` instead of a `case.yaml`, with
`metadata.document` naming the file under test inside it. `tree/` is the parent
of the item root rather than the item root itself: ERR_SLUG_MISMATCH tests the
item directory's *name*, so a fixture for it needs a directory that has one.

`case.yaml` is not thereby the legacy form, and the ADR says so. It asserts that
the document has no item root — a real state, per blueprint §3.1, and the one an
API payload arrives in. Making `tree/` the only form would have traded one blind
spot for another.

Symlinks are declared in metadata rather than committed. ERR_PATH_ESCAPE is
reachable only by symlink now that listing §5's grammar makes `..` unspellable,
and a committed link does not survive a checkout without `core.symlinks`, is
invisible in a diff, and would ship inside a release tarball pointing outside
the archive.

The ADR also corrects tools/src/validator.ts, which asserted that the semantic
phase was out of scope "per ADR 0001". ADR 0001 §6 forbids *publishing* a
reference validator and is untouched; §7 describes tools/ as one non-normative
adapter. Implementing more phases inside it publishes nothing. The alternative
was a corpus in which every semantic clause was checked by nothing at all.

Refs: #9
Signed-off-by: Justin Merrell <merrelljustin@gmail.com>

* fix(tools): reject YAML anchors and aliases in the parser phase

Component §7 has said since bootstrap that the parser phase MUST reject anchors
and aliases. Nothing did. `parseDocument` set `uniqueKeys`, `merge: false` and
`strict`, none of which touches an alias — a document spelling `&b` / `*b`
parsed cleanly and the alias expanded.

The parser now walks the tree and rejects both. `maxAliasCount` would have
covered the alias and not the lone anchor, which is inert and rejected anyway:
an author who writes one is reaching for a feature this contract withholds, and
§2 already settled that a field which does nothing is an error rather than a
silence.

Two codes rather than one. `ERR_ANCHOR_OR_ALIAS` is new. `ERR_INVALID_YAML` is
not — `validator.ts` has emitted it since bootstrap and no spec.md declared it,
so a fixture citing it would have been rejected by the runner's own registry
check as an unknown code. Both are now in component §8, and the two say
different things to an author: one means the document is malformed, the other
means it is well-formed and uses something this contract does not have.

Also switches the parser from `parse` to `parseDocument`, which reports every
problem instead of throwing on the first and carries machine-readable error
codes. The previous implementation matched on message text — the one thing this
specification explicitly declares non-normative.

The parser lives in a new document.ts so that semantic.ts can read an item's
other documents without closing an import cycle.

Fail-before evidence: all six anchor/alias cases validate cleanly against
tools/ at be77e19 and fail after. The three malformed-YAML cases are green
beforehand and are the regression pin for the code registration.

This tightens the parser phase — a document with an alias validates today and
will not after. It is free while no tag exists and release PRs #1/#2/#3 are
open; after they merge it is a v2 directory plus a migration note.

Refs: #9
Signed-off-by: Justin Merrell <merrelljustin@gmail.com>

* feat(tools): implement the semantic phase and the case-tree runner

Every semantic clause PR #13 wrote — acyclicity, the floating-tag blocklist,
ERR_CONFLICTING_INPUT_SCHEMA, the media rules — was prose that nothing in CI
ever executed. Five fixtures existed and all five were skipped.

New semantic.ts, split the way the prose splits the rules rather than the way
this runner would find convenient.

An **in-document** rule is decided by reading the document: a floating image
tag, a probe naming an endpoint that is not declared, a `fromRole` naming no
node, a cycle, two screenshots sharing a basename.

An **item-scoped** rule is measured against the item root, and is reported only
when the caller supplies one. That is blueprint §3.1's rule, not a concession:
"an implementation handed a document rather than a directory MUST NOT report any
of them … a diagnostic it cannot substantiate is worse than a silence." It is
also why `check:examples` stays green — `examples/` is not an item root, and its
documents reference `./components/postgres.yaml` and `media/icon.png`, neither
of which exists beside them. PR #13's body flagged that as latent breakage;
this is the shape that makes it not one.

Cycle reporting follows §4.2's canonical form exactly: strongly connected
components, each reported as a closed walk from the lexicographically smallest
node, choosing the smallest successor at each step. Verified stable across the
three key orderings of one three-node cycle — the walk is a property of the
graph, not of the traversal, which is the whole point of pinning it.

conformance.ts gains the ADR 0002 case-tree form: `tree/` materialised to a
scratch directory with its declared symlinks created there, validated with the
item root set to the document's own directory. A case must declare exactly one
of `case.yaml` or `tree/`; declaring both leaves it ambiguous which one the
diagnostics describe.

`IMPLEMENTED_PHASES` gains `semantic`. `capability` stays out — it needs an
account, a region and a quota, which is a server.

Refs: #9
Signed-off-by: Justin Merrell <merrelljustin@gmail.com>

* fix(listing): correct the stale media-path description

`$defs.ListingSpec.description` still told readers that `icon` and
`screenshots[].file` are "paths relative to the listing document" — the exact
wording §5 now singles out as wrong: "`media/` is a stronger rule than 'relative
to the listing document'. That was this section's earlier wording, and it is not
what anything enforces."

PR #13 updated the two per-property descriptions and missed the containing
object's, so the schema contradicted itself and the prose in one file.

Refs: #9
Signed-off-by: Justin Merrell <merrelljustin@gmail.com>

* test(conformance): the item-scoped semantic cases, against ADR 0002

Thirteen new cases covering every diagnostic code that needed a directory to
exist. All are trees; none was expressible before.

blueprint 003–009 take the identity, reference and merge rules one at a time —
slug against the directory name, version against the sibling listing, a
component document nothing references, a reference resolving to nothing, a
reference escaping the item root, a `fromOutput` naming no output of the
component it resolves to, and two nodes declaring one input key with differing
schemas.

Three deserve their reasons stated:

  007  the reference is `../shared/postgres.yaml` and the document it lands on
       exists. A leading `../` is legal spelling — it is how a blueprint reaches
       a sibling directory — so only a containment check on the resolved path
       catches this one.

  009  `api` and `db` both declare `adminPassword`, STRING against INTEGER.
       `api` sorts first, so `api`'s declaration stands and `db`'s is reported.
       The case exists because silent first-wins is defensible until you write
       down which schema the second component's value was validated against.

  011  a three-node cycle written as queue, db, cache. semantic-002 cannot make
       this case: with two nodes, "smallest first" and "first declared" can
       coincide by luck. Here they do not, so an implementation reporting from
       wherever its traversal started produces the wrong walk and fails.

listing 002–005 mirror the identity rules and add the two media rules that need
the filesystem. 005 is the symlink case, declared in metadata rather than
committed per ADR 0002 §3.

blueprint 010 and listing 006 are the positive cases, and they are not padding.
Each satisfies every item-scoped rule of its family at once, which makes them
the regression pin for the class: a careless containment check or a merge rule
that forgets to absorb an identical redeclaration rejects a well-formed item,
and nothing else in the corpus would notice.

`ERR_UNKNOWN_COMPONENT` remains uncovered. It is `capability` — resolving a
published reference needs the catalog, and no phase a client runs may reach the
network.

Refs: #9
Signed-off-by: Justin Merrell <merrelljustin@gmail.com>

* docs(conformance): record coverage as a check rather than a claim

The runner enforced fixture → spec: every code a case declares must exist in a
reachable diagnostics table, at the right phase. Nothing enforced spec →
fixture, which is how ten codes reached `main` untested with CI green.

Two checks close it. Every `ERR_*` row in a family's own diagnostics table must
be exercised by an indexed case or appear in `UNCOVERED` with a reason — after
this branch that list holds one entry. And every case directory on disk must be
named by `cases.json`, since the runner reads the index rather than walking the
tree: an unindexed directory is not a failing fixture, it is an invisible one.

Both were verified by injecting a violation and watching the check fail.

The README's "Coverage status" described a limitation ADR 0002 removes, so it is
rewritten around what is now true: three phases covered, one code uncovered, and
a check that derives the list instead of a paragraph someone has to remember to
update. It also documents the `tree/` form, `document`, and `symlinks`.

CONTRIBUTING gains the obligation this creates: adding a diagnostic code to a
spec.md means adding a case for it.

Refs: #9
Signed-off-by: Justin Merrell <merrelljustin@gmail.com>

---------

Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
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