refactor(blueprint): select the component resolver by the form of the reference - #12
Merged
Merged
Conversation
… 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>
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
A blueprint node references its component with one field,
component, whoseform selects the resolver — a value beginning
./or../is a pathrelative to the referencing document's directory, and a UUID is a published
component that additionally requires
componentVersion. This replaces the freestring documented as "the stem of the sibling
components/<name>.yamlfile",and
blueprint/v1/spec.md§4 gains §4.1 stating the rules instead of the TODOthat 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 laidout flat —
musher/component-web.yaml, which is whatmusher-dev/examplesdoes — 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-createinstead, an API request schema that declares onlymetadataandspec; theirspecVersionandkindkeys survive purelybecause Pydantic's
extra="ignore"discards them.And the catalog dialect says
componentwhile the API dialects saycomponentId+componentVersionfor the same slot. Nothing — not grep, not aschema, 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
sourcerequires./for alocal path expressly "to distinguish from a module registry address", and its
version"may only be used when thesourceargument points to a modulelisted in a registry". Docker Compose is the counterexample — permitting both
imageandbuildproduced an ambiguity that had to be patched withpull_policy.componentvscomponentIdis 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 hexletter (
e29b8400-e29b-41d4-a716-446655440000), so a bare slug and a publishedid are the same string. Verified against the built bundle: that value plus a
componentVersionvalidates as a published reference, and without one it fails— unambiguously read as published, never as a slug.
Decisions on the issue's open questions
metadata.slugon component documentsComponentMetadatais{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.PUT /v1/blueprints/{id}/spec)metadata.id/rowVersionare transport concerns and MUST NOT appear on a repo-authored document. No schema change — the envelope already rejects them.musher.yaml)blueprint+componentpair, which is viable precisely because the local form now exists. Recorded in §1; the CLI change lands inmusher-cli.Compatibility
Flagged honestly:
component: postgresvalidated yesterday and does not today.v1is unreleased, so there is no baseline to break. No tags, no releases,all three families at
0.0.0in.github/release-please/manifest.json, and allthree
spec.mdfiles readStatus: Draft (pre-stable). The §3 guarantee isanchored to
v1.0.0— "A document that validated againstv1.0.0MUST validateagainst every later
v1.x.y" — andv1.0.0does not exist. This follows thesame reasoning accepted for #10 and #11.
Important
Do not merge release PR #2 (
chore(main): release blueprint 1.0.0) beforethis lands. Merging it tags
blueprint/v1.0.0and freezes today's nodeshape, after which this change requires a
specifications/blueprint/v2/directory.
Worth noting for sequencing:
GOVERNANCE.mdstill has no written pre-stableexemption, 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-branchoneOfthe issue proposed. AoneOfbranch cannot see the base schema's sibling
properties, so a branch thatclosed itself would reject
size,connectionsandadvanced— andlint.tscheckClosedObjectsrequires everytype: "object"schema declaringproperties to close. The
if/then/elsesubschemas carry notype, so therule correctly skips them and the node stays a single closed record. Stating the
local-form prohibition as
componentVersion: falserather thannot: { required: [...] }also anchors the diagnostic at/spec/components/<node>/componentVersioninstead of at the node — the path isnormative, 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, sothat 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-cliis Go. A path segment must begin with a letter or digit,which is how
.and..are excluded as interior segments without a negativelookahead. 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:
capability. A local reference resolves offline insemantic; a published onecannot, so it belongs to
capability. §6 previously said reference resolutionlives in
semanticand is corrected.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.
stays
semanticeven though the grammar now rejects interior traversal.New codes:
ERR_COMPONENT_NOT_FOUNDandERR_REFERENCE_ESCAPE(semantic),ERR_UNKNOWN_COMPONENT(capability).Verification
task checkgreen: lint, drift, 8 examples, 25 conformance cases (was 19),format, types, shellcheck, actionlint. The catalog freshness gate and
task site:buildboth pass.Fail-before/pass-after, measured against the previous bundle:
structural-006-local-component-referencestructural-007-published-component-referenceexpected to pass but failed—ERR_UNKNOWN_FIELD @ /spec/components/dbstructural-008-bare-component-stemexpected to fail but validated cleanlystructural-009-version-pinned-on-local-referenceERR_INVALID_VALUE at /spec/components/db/componentVersionwas not producedstructural-010-published-reference-without-versionexpected to fail but validated cleanlystructural-011-traversal-inside-local-referenceexpected to fail but validated cleanlystructural-006is a regression pin, not proof of a fix, and its commitmessage says so — a local path was already a legal free string under the old
maxLength: 128. It is included as the positive fixture CONTRIBUTING requiresand 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-001is forced by CI — the other four arefailing 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.yamlis valid under both the old andnew schema, so that commit is green on its own and history stays bisectable.
Deferred:
semanticfixtures. A conformance case is a singlecase.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 anADR rather than a drive-by convention. The rules and codes are normative in
spec.mdregardless; only the fixtures wait.Also in this PR
catalog.json's componentfileMatchwas**/components/*.yaml— the samelayout assumption being retired, and the reason a flat-layout document gets no
editor binding. It now also covers
component.yamlandcomponent-*.yaml. Thedirectory pattern stays:
fileMatchis an editor discovery heuristic, not anormative 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/teston the two commits touchingspecifications/blueprint/v1/, deliberately.docsis 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.mdTODOs still keep v1 pre-stable. The conformance, tooling and READMEcommits fall outside every release-please package path, so their type is inert.
Checklist
task checkpasses locallyschemas/dist/regenerated withtask bundleand committed (never edited by hand)clausespec.md— schemadescriptions are explanatory, not normativegit commit -s)Follow-ups (not in this PR)
semanticcases forERR_COMPONENT_NOT_FOUNDandERR_REFERENCE_ESCAPE.musher-cli— retiremusher.yaml. ADR 0001 §2's sentence on it isimmutable, so formally recording this needs a superseding ADR.
musher-dev/platform— collapsecomponentId/componentVersiononto theunified field; give request models a strict base (carried over from fix: enforce the §2 unknown-property rule below the envelope #10).
musher-dev/examples— migrate to the local form and rebind$schematothe published
blueprint/v1bundle.tools—checkClosedObjectsskipsif/then/elseonly because itkeys on an explicit
type: "object". This design depends on that; theexemption should be by keyword name so it is intentional rather than
incidental.
🤖 Generated with Claude Code