Skip to content

fix: enforce the §2 unknown-property rule below the envelope - #10

Merged
justinmerrell merged 5 commits into
mainfrom
fix/enforce-unknown-property-rule
Aug 9, 2026
Merged

fix: enforce the §2 unknown-property rule below the envelope#10
justinmerrell merged 5 commits into
mainfrom
fix/enforce-unknown-property-rule

Conversation

@justinmerrell

Copy link
Copy Markdown
Contributor

What changes

additionalProperties: false now appears on every record-shaped $defs in all three families, so the published bundles enforce the §2 rule that unknown properties are rejected at every level rather than only at the root of the envelope. 27 defs were closed — 21 in component, 6 in blueprint; listing needed no change because all four of its defs were already strict. A new lint rule stops the gap reopening, and structural-005-nested-unknown-field fixtures pin the behaviour in each family.

Map-shaped schemas are deliberately left open: contract.inputs/outputs, workload.endpoints/volumes, build.arguments, and blueprint's components/parameters/connections use additionalProperties as a map value schema and their keys are chosen by the document author.

Why

Closes #6.

specifications/component/v1/spec.md §2 has always required this:

Unknown properties MUST be rejected at every level. A misspelled field is an error, never a silently ignored one.

The bundles did not implement it — the constraint was present on only 5 of 35 $defs. The split is an artifact of the bootstrap import (ADR 0001 → "Debt accepted at bootstrap"), not a decision: platform Seed* models set Pydantic extra="forbid", while reused *Request wire models inherit the default extra="ignore". listing was fully strict only because all four of its defs happen to be Seed* models.

The practical effect was silent substitution of defaults. This validated clean before this PR:

health:
  readiness:
    path: /healthz
    initialDelaySecond: 30   # typo — singular

The probe then ran with the default 10s delay, so a slow-booting service was marked unready and restart-looped with no diagnostic pointing at the document. The higher-stakes instances were in ComponentInputRequest, where a misspelled isRequired silently made a required input optional and a misspelled suppliedBy silently changed where a value came from.

Fixed spec-side rather than by re-importing from musher-dev/platform: schemas/src/ is authored here, check:drift only compares src→dist, and ADR 0001 follow-up #2 is to invert the generator so the platform verifies against the published bundle. A follow-up on the platform to give request-body models a strict base is still worth filing.

Compatibility

  • Breaking — a previously valid document now fails.

Flagging this honestly rather than filing it under "Correction", since documents that validated yesterday will fail today.

Two things that should inform the review:

The normative contract does not change. §2 already required rejection at every level, and spec.md is normative while schema descriptions are explanatory. What changes is that the bundle finally implements the prose. Any document this newly rejects was already non-conforming; it was being accepted by a schema that failed to encode its own specification.

v1 is pre-stable and nothing has shipped. No tags, no releases, release-please manifest all 0.0.0, and all three spec.md files declare 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 yet, so there is no consumer the guarantee currently protects. The only published surface is the moving v1 alias, which README and site.ts both describe as not a stable target.

Worth noting for sequencing: the compatibility rules in GOVERNANCE.md have no written pre-stable exemption, even though ADR 0001 and spec.md §5/§10 repeatedly schedule breaking work "before v1 is declared stable". Issues #8 and #9 are also tightenings and will raise the same question. If it should be written down rather than argued per-PR, that is the ADR to open.

This should land before release PRs #1#3. Merging any of them tags 1.0.0 and makes that family's validation surface immutable.

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 (the scope drives release-please)
  • Commits are DCO signed off (git commit -s)

Verification

task ci:test: 3 bundles match their sources, 7 examples validate, 18 conformance cases pass. Format, types, shellcheck and actionlint clean. The catalog staleness gate is clean.

Fail-before/pass-after, with the schema changes reverted and the new fixtures left in place:

Fixture Against pre-change schemas
component/structural-005 expected to fail but validated cleanly
blueprint/structural-005 declared diagnostic ERR_UNKNOWN_FIELD at /spec/parameters/siteTitle/ui was not produced
listing/structural-005 passes — listing was already strict

The listing fixture is a regression pin, not proof of a fix, and its commit message says so. It is included for parity across families.

The new lint rule was confirmed to bite by deleting one closure: it failed with /$defs/ComponentProbeRequest declares properties but not "additionalProperties": false. Each of the five commits was checked out individually and passes task ci:test on its own.

The component fixture is the issue's own reproduction, so its green run is the closing evidence for #6.

justinmerrell and others added 5 commits August 9, 2026 02:42
spec.md §2 requires unknown properties to be rejected at every level, but
additionalProperties: false was present on only SeedComponentMetadata. The 21
Component*Request defs were open, so a misspelled optional key anywhere below
the envelope validated clean and fell back to its default.

The gap is an artifact of the bootstrap import (ADR 0001): platform Seed*
models set Pydantic extra="forbid"; reused *Request wire models inherit the
default extra="ignore".

Map-shaped schemas (contract inputs/outputs, workload endpoints/volumes, build
arguments) are untouched — their keys are chosen by the document author.

Refs #6

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
The six Blueprint*Request defs carried no additionalProperties, so a misspelled
optional key inside a parameter or a connection ref validated clean. The Seed*
defs were already closed, which is why the first level below the envelope
appeared strict while everything under it was not.

Refs #6

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
structural/003-unknown-field only injects a key at the root, which is why the
gap below the envelope survived the bootstrap import. structural-005 injects
the typo deeper: a probe field on component, a parameter's ui block on
blueprint, and an array element on listing.

Against the pre-change schemas the component case validated cleanly and the
blueprint case produced no ERR_UNKNOWN_FIELD. The listing case passes either
way — listing was already fully strict, so it is a regression pin rather than
proof of a fix.

Refs #6

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
Nothing checked this before, which is how 27 open defs survived the import.
Any subschema declaring type: object and properties must now also declare
additionalProperties: false.

Map-shaped schemas declare no properties and are unaffected, so the rule needs
no exemption list. walkObjects now carries a JSON Pointer so the diagnostic can
name the offending subschema.

Refs #6

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
All four unknown-field fixtures cite #envelope, but §2 never named the code
they assert — the binding came only from the §8 registry. State it inline, as
the specVersion sentence two lines down already does, and say explicitly that
the rule covers optional fields, where ignoring a typo substitutes the default.

Refs #6

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
@justinmerrell
justinmerrell merged commit cdc3cc4 into main Aug 9, 2026
4 checks passed
@justinmerrell
justinmerrell deleted the fix/enforce-unknown-property-rule branch August 9, 2026 02:53
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.

Schema does not enforce the §2 unknown-property rule below the envelope

1 participant