Skip to content

refactor(blueprint): select the component resolver by the form of the reference - #12

Merged
justinmerrell merged 7 commits into
mainfrom
feat/unify-blueprint-component-reference
Aug 9, 2026
Merged

refactor(blueprint): select the component resolver by the form of the reference#12
justinmerrell merged 7 commits into
mainfrom
feat/unify-blueprint-component-reference

Conversation

@justinmerrell

Copy link
Copy Markdown
Contributor

What changes

A blueprint node references its component with one field, component, whose
form selects the resolver — a value beginning ./ or ../ is a path
relative to the referencing document's directory, and a UUID is a published
component that additionally requires componentVersion. This replaces the free
string documented as "the stem of the sibling components/<name>.yaml file",
and blueprint/v1/spec.md §4 gains §4.1 stating the rules instead of the TODO
that called this "the single largest source of confusion in the current format".

Why

Closes #8.

The stem convention hard-codes components/<stem>.yaml, so a repository laid
out flat — musher/component-web.yaml, which is what musher-dev/examples
does — cannot be authored against this family at all. No value of
component: in the published schema can name those files. Those examples bind
$schema=…/component-create instead, an API request schema that declares only
metadata and spec; their specVersion and kind keys survive purely
because Pydantic's extra="ignore" discards them.

And the catalog dialect says component while the API dialects say
componentId + componentVersion for the same slot. Nothing — not grep, not a
schema, not a reader — connects them.

Every mature composition format solves this with one field whose form selects
the resolver, never two parallel keys: Terraform's source requires ./ for a
local path expressly "to distinguish from a module registry address", and its
version "may only be used when the source argument points to a module
listed in a registry". Docker Compose is the counterexample — permitting both
image and build produced an ambiguity that had to be patched with
pull_policy. component vs componentId is the Compose shape.

The ./ prefix is load-bearing, not decoration. The slug grammar
^[a-z][a-z0-9-]{0,61}[a-z0-9]$ also matches a UUID beginning with a hex
letter (e29b8400-e29b-41d4-a716-446655440000), so a bare slug and a published
id are the same string. Verified against the built bundle: that value plus a
componentVersion validates as a published reference, and without one it fails
— unambiguously read as published, never as a slug.

Decisions on the issue's open questions

# Question Decision
1 metadata.slug on component documents Stays forbidden. ComponentMetadata is {version}-only and closed. Under a path reference the blueprint no longer needs a slug to locate anything, and the API can derive the lineage from the path. No component-family change here — it has its own release train.
2 Dialect 3 (PUT /v1/blueprints/{id}/spec) API-owned, out of scope. §1 and §3 now state that metadata.id/rowVersion are transport concerns and MUST NOT appear on a repo-authored document. No schema change — the envelope already rejects them.
3 Dialect 5 (musher.yaml) Superseded by a repo-local blueprint + component pair, which is viable precisely because the local form now exists. Recorded in §1; the CLI change lands in musher-cli.

Compatibility

  • Breaking — a previously valid document now fails.

Flagged honestly: component: postgres validated yesterday and does not today.

v1 is unreleased, so there is no baseline to break. No tags, no releases,
all three families at 0.0.0 in .github/release-please/manifest.json, and all
three spec.md files read Status: Draft (pre-stable). The §3 guarantee is
anchored to v1.0.0 — "A document that validated against v1.0.0 MUST validate
against every later v1.x.y" — and v1.0.0 does not exist. This follows the
same reasoning accepted for #10 and #11.

Important

Do not merge release PR #2 (chore(main): release blueprint 1.0.0) before
this lands.
Merging it tags blueprint/v1.0.0 and freezes today's node
shape, after which this change requires a specifications/blueprint/v2/
directory.

Worth noting for sequencing: GOVERNANCE.md still has no written pre-stable
exemption, so this is the third PR to argue it per-PR and #9 will be the fourth.
That is the ADR to open, and it is listed below rather than smuggled in here.

Design notes worth reviewing

if/then/else, not the two-branch oneOf the issue proposed. A oneOf
branch cannot see the base schema's sibling properties, so a branch that
closed itself would reject size, connections and advanced — and
lint.ts checkClosedObjects requires every type: "object" schema declaring
properties to close. The if/then/else subschemas carry no type, so the
rule correctly skips them and the node stays a single closed record. Stating the
local-form prohibition as componentVersion: false rather than
not: { required: [...] } also anchors the diagnostic at
/spec/components/<node>/componentVersion instead of at the node — the path is
normative, the message is not.

No new diagnostic codes were needed for the structural rules. Everything maps
onto the existing registry through validator.ts's generic keyword mapping, so
that file is untouched and no downstream adapter needs a path-sniffing special
case.

The patterns are lookahead-free, so they compile under RE2 as well as
ECMA-262 — musher-cli is Go. A path segment must begin with a letter or digit,
which is how . and .. are excluded as interior segments without a negative
lookahead. That leaves one spelling per target, so the semantic containment check
does not have to normalise before comparing.

Three rules the issue did not state:

  • Phase assignment follows from component §7 forbidding network access before
    capability. A local reference resolves offline in semantic; a published one
    cannot, so it belongs to capability. §6 previously said reference resolution
    lives in semantic and is corrected.
  • A context with no filesystem location — a document submitted over an API —
    MUST reject a local reference rather than assume a base directory. That seam
    between the repo dialect and the API dialect is where the original ambiguity
    came from.
  • Containment is a property of the resolved location, not the spelling, so it
    stays semantic even though the grammar now rejects interior traversal.

New codes: ERR_COMPONENT_NOT_FOUND and ERR_REFERENCE_ESCAPE (semantic),
ERR_UNKNOWN_COMPONENT (capability).

Verification

task check green: lint, drift, 8 examples, 25 conformance cases (was 19),
format, types, shellcheck, actionlint. The catalog freshness gate and
task site:build both pass.

Fail-before/pass-after, measured against the previous bundle:

Fixture Against the pre-change schema
structural-006-local-component-reference passes before — see below
structural-007-published-component-reference expected to pass but failedERR_UNKNOWN_FIELD @ /spec/components/db
structural-008-bare-component-stem expected to fail but validated cleanly
structural-009-version-pinned-on-local-reference declared ERR_INVALID_VALUE at /spec/components/db/componentVersion was not produced
structural-010-published-reference-without-version expected to fail but validated cleanly
structural-011-traversal-inside-local-reference expected to fail but validated cleanly

structural-006 is a regression pin, not proof of a fix, and its commit
message says so — a local path was already a legal free string under the old
maxLength: 128. It is included as the positive fixture CONTRIBUTING requires
and as a lock on the grammar's breadth (nested segments, .yml, flat siblings).

The five pre-existing blueprint fixtures were migrated to the local form in
their own commit. Only structural-001 is forced by CI — the other four are
failing cases whose declared diagnostics are still produced — but a fixture for
"wrong kind" that also carries an unrelated invalid reference stops isolating
the rule it names. ./components/postgres.yaml is valid under both the old and
new schema, so that commit is green on its own and history stays bisectable.

Deferred: semantic fixtures. A conformance case is a single case.yaml,
so it cannot express "the referenced file does not exist" or "the path escapes
the item root" — both need a file tree. That is ADR 0001 §7's definition of the
fixture format, not the runner's IMPLEMENTED_PHASES, so extending it needs an
ADR rather than a drive-by convention. The rules and codes are normative in
spec.md regardless; only the fixtures wait.

Also in this PR

catalog.json's component fileMatch was **/components/*.yaml — the same
layout assumption being retired, and the reason a flat-layout document gets no
editor binding. It now also covers component.yaml and component-*.yaml. The
directory pattern stays: fileMatch is an editor discovery heuristic, not a
normative rule, and removing it would break binding for existing catalog authors
for no gain. README.md's snippet mirrors it.

One note on commit types

Commit types are refactor/test on the two commits touching
specifications/blueprint/v1/, deliberately. docs is not hidden in
.github/release-please/config.json — it has a "Specification prose" section —
so it would add a changelog entry to the open release PR while the remaining
spec.md TODOs still keep v1 pre-stable. The conformance, tooling and README
commits fall outside every release-please package path, so their type is inert.

Checklist

  • task check passes locally
  • schemas/dist/ regenerated with task bundle and committed (never edited by hand)
  • Conformance fixtures added for every behavioural change, each citing a clause
  • Normative prose updated in the affected spec.md — schema descriptions are explanatory, not normative
  • Commit messages are Conventional and correctly scoped
  • Commits are DCO signed off (git commit -s)

Follow-ups (not in this PR)

  1. ADR 0002 — pre-stable exemption, so Fill the spec.md TODOs from implemented platform behaviour — and settle the cycle-detection contradiction #9 does not argue it a fourth time.
  2. Multi-document conformance fixtures, unblocking the semantic cases for
    ERR_COMPONENT_NOT_FOUND and ERR_REFERENCE_ESCAPE.
  3. musher-cli — retire musher.yaml. ADR 0001 §2's sentence on it is
    immutable, so formally recording this needs a superseding ADR.
  4. musher-dev/platform — collapse componentId/componentVersion onto the
    unified field; give request models a strict base (carried over from fix: enforce the §2 unknown-property rule below the envelope #10).
  5. musher-dev/examples — migrate to the local form and rebind $schema to
    the published blueprint/v1 bundle.
  6. toolscheckClosedObjects skips if/then/else only because it
    keys on an explicit type: "object". This design depends on that; the
    exemption should be by keyword name so it is intentional rather than
    incidental.

🤖 Generated with Claude Code

… reference

None of these five cases is about the component reference — they pin duplicate
keys, the kind discriminator, unknown fields, and the spec version. Each of
them nonetheless carried `component: postgres`, so each would start failing
for a reason it does not name once the reference form is discriminated.

`./components/postgres.yaml` is valid under both the current schema (a free
string) and the one that follows, so this commit is green on its own and the
history stays bisectable.

Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
Six cases covering both accepted forms and the four ways a reference can be
malformed: a bare stem, a version pinned on a local reference, a published
reference with no version, and a traversal spelled into an interior segment.

Deliberately red at this commit — that redness is the fail-before evidence.
Measured against the previous bundle:

  006  passes before — regression pin, not proof of a fix
  007  expected to pass but failed: ERR_UNKNOWN_FIELD @ /spec/components/db
  008  expected to fail but validated cleanly
  009  declared ERR_INVALID_VALUE at /spec/components/db/componentVersion
       was not produced
  010  expected to fail but validated cleanly
  011  expected to fail but validated cleanly

006 is the only one green beforehand, because a local path is already a legal
free string under the current maxLength-128 constraint.

Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
`component` was a free string documented as the stem of a sibling
`components/<stem>.yaml`. That binding cannot name a document in a flat
directory, so a repository laid out like `musher/component-web.yaml` could not
be authored against this family at all — and it shares no field name with the
`componentId` + `componentVersion` pair the API uses for the same slot.

One field now carries both, and its form selects the resolver, as Terraform's
`source` does. A value beginning `./` or `../` is a path relative to this
document's directory; a UUID is a published component and requires
`componentVersion`. The prefix is load-bearing rather than decorative: the slug
grammar `^[a-z][a-z0-9-]{0,61}[a-z0-9]$` also matches a UUID beginning with a
hex letter, so without it no validator could tell the two apart.

Expressed as `if`/`then`/`else` on the single closed node rather than the
two-branch `oneOf` the issue proposed. A `oneOf` branch cannot see the base
schema's sibling properties, so closing a branch would reject `size`,
`connections` and `advanced`, and `lint.ts` `checkClosedObjects` requires every
`type: "object"` schema declaring properties to close. The branches here carry
no `type`, so the rule correctly skips them. Stating the local-form prohibition
as `componentVersion: false` rather than `not: { required: [...] }` also
anchors the diagnostic at the offending key instead of at the node.

Both patterns are lookahead-free so they compile under RE2, which the Go CLI
needs. A path segment must begin with a letter or digit, which excludes `.` and
`..` as interior segments without a negative lookahead — one spelling per
target, and containment does not have to normalise before comparing.

Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
Closes the §4 TODO this family called "the single largest source of confusion
in the current format". §4.1 now states both forms, the resolution base, the
`componentVersion` presence rules, and the reserved `<publisher>/<slug>` form.
Connections becomes §4.2; its anchor is a slug, not a number, so the fixtures
citing `#connections` are unaffected.

Three rules worth calling out, none of which the issue stated:

- Phase assignment follows from component §7 forbidding network access before
  `capability`. A local reference resolves offline in `semantic`; a published
  one cannot, so it belongs to `capability`. §6 said reference resolution lives
  in `semantic` and is corrected.
- A context with no filesystem location — a document submitted over an API —
  MUST reject a local reference rather than assume a base directory. That seam
  between the repo dialect and the API dialect is where the ambiguity this
  change removes originally came from.
- Containment is a property of the resolved location, not of the spelling, so
  it stays `semantic` even though the grammar now rejects interior traversal.

Registers `ERR_COMPONENT_NOT_FOUND` and `ERR_REFERENCE_ESCAPE` (`semantic`) and
`ERR_UNKNOWN_COMPONENT` (`capability`). §1 gains an out-of-scope list carrying
two decisions from the issue: `metadata.id`/`rowVersion` are API transport and
are restated as forbidden in §3, and the `kind: App` shape read by
`musher deploy` is superseded by a repo-local blueprint plus its components —
which this reference form is what makes expressible.

Type is `refactor`, not `docs`: `docs` is not hidden in the release-please
changelog config, and blueprint v1 should not gain a changelog entry while its
remaining TODOs keep it pre-stable.

Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
`minimal.yaml` and `web-and-database.yaml` take the local form, and the latter
now names one document under `components/` and one flat sibling, so the
examples show that the reference imposes no directory layout. A new
`published-reference.yaml` covers the UUID form, which otherwise shipped
undemonstrated.

The referenced paths do not exist on disk and cannot: `examples.ts` validates
every YAML in the directory against the blueprint bundle, so a real component
document placed beside them would be validated as a blueprint and fail. The
comment headers say the paths are illustrative and that examples are checked
structurally only.

Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
`**/components/*.yaml` encoded the same layout assumption the blueprint
reference form just dropped, so a component document in a flat directory got no
editor binding — the exact layout the new form exists to permit. The patterns
now also cover `component.yaml` and `component-*.yaml`.

`fileMatch` is an editor discovery heuristic, not a normative rule, so the
directory pattern stays: removing it would break binding for every existing
catalog author for no gain.

Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
The VS Code snippet repeated the `components/` glob that `catalog.json` no
longer relies on alone. `yaml.schemas` accepts an array, so it mirrors the
generated catalog instead of drifting from it.

Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
@justinmerrell justinmerrell changed the title refactor(blueprint)!: select the component resolver by the form of the reference refactor(blueprint): select the component resolver by the form of the reference Aug 9, 2026
@justinmerrell
justinmerrell merged commit 19daeb0 into main Aug 9, 2026
5 checks passed
@justinmerrell
justinmerrell deleted the feat/unify-blueprint-component-reference branch August 9, 2026 03:59
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.

Five dialects claim specVersion: v1 — unify the blueprint node component reference

1 participant