From f0b5eba5ab4de5b929e3ad9a9ee8431471a8cb86 Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 9 Aug 2026 02:42:03 +0000 Subject: [PATCH 1/5] fix(component): reject unknown properties below the envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Signed-off-by: Justin Merrell --- .../v1/schemas/dist/component.schema.json | 21 +++++++++++++++++++ .../v1/schemas/src/component.schema.json | 21 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/specifications/component/v1/schemas/dist/component.schema.json b/specifications/component/v1/schemas/dist/component.schema.json index 9d3a93c..c3a8a28 100644 --- a/specifications/component/v1/schemas/dist/component.schema.json +++ b/specifications/component/v1/schemas/dist/component.schema.json @@ -37,6 +37,7 @@ "$defs": { "ComponentBuildRequest": { "title": "ComponentBuildRequest", + "additionalProperties": false, "properties": { "arguments": { "title": "Arguments", @@ -89,6 +90,7 @@ }, "ComponentConfigRefEnvVarValueRequest": { "title": "ComponentConfigRefEnvVarValueRequest", + "additionalProperties": false, "properties": { "configKey": { "title": "Configkey", @@ -113,6 +115,7 @@ }, "ComponentContractRequest": { "title": "ComponentContractRequest", + "additionalProperties": false, "properties": { "inputs": { "title": "Inputs", @@ -135,6 +138,7 @@ }, "ComponentEndpointRequest": { "title": "ComponentEndpointRequest", + "additionalProperties": false, "properties": { "containerPort": { "title": "Containerport", @@ -175,6 +179,7 @@ "ComponentEnvVarRequest": { "title": "ComponentEnvVarRequest", "description": "One environment-variable entry (Kubernetes-style named entry).", + "additionalProperties": false, "properties": { "key": { "title": "Key", @@ -212,6 +217,7 @@ }, "ComponentGitRefRequest": { "title": "ComponentGitRefRequest", + "additionalProperties": false, "properties": { "name": { "title": "Name", @@ -236,6 +242,7 @@ }, "ComponentGitSourceRequest": { "title": "ComponentGitSourceRequest", + "additionalProperties": false, "properties": { "build": { "description": "How the repository is built into an image, or null when defaults apply.", @@ -282,6 +289,7 @@ "ComponentHealthProbesRequest": { "title": "ComponentHealthProbesRequest", "description": "Staged health probes (ADR 0093): startup, readiness, liveness.", + "additionalProperties": false, "properties": { "liveness": { "description": "Aliveness gate; failure restarts the container. Optional.", @@ -324,6 +332,7 @@ }, "ComponentImageSourceRequest": { "title": "ComponentImageSourceRequest", + "additionalProperties": false, "properties": { "ref": { "title": "Ref", @@ -345,6 +354,7 @@ }, "ComponentInputRequest": { "title": "ComponentInputRequest", + "additionalProperties": false, "properties": { "description": { "title": "Description", @@ -441,6 +451,7 @@ }, "ComponentInputTargetRequest": { "title": "ComponentInputTargetRequest", + "additionalProperties": false, "properties": { "envVarKey": { "title": "Envvarkey", @@ -459,6 +470,7 @@ "ComponentInputUiRequest": { "title": "ComponentInputUiRequest", "description": "Presentation metadata for a USER-supplied input.\n\nThe install-form control is derived from the input's schema (enum →\nselect, BOOLEAN → toggle, EMAIL format → email field, sensitive →\nmasked field) — only the label needs declaring.", + "additionalProperties": false, "properties": { "label": { "title": "Label", @@ -473,6 +485,7 @@ }, "ComponentLiteralEnvVarValueRequest": { "title": "ComponentLiteralEnvVarValueRequest", + "additionalProperties": false, "properties": { "isSensitive": { "title": "Issensitive", @@ -500,6 +513,7 @@ }, "ComponentOutputRequest": { "title": "ComponentOutputRequest", + "additionalProperties": false, "properties": { "description": { "title": "Description", @@ -550,6 +564,7 @@ }, "ComponentProbeRequest": { "title": "ComponentProbeRequest", + "additionalProperties": false, "properties": { "endpoint": { "title": "Endpoint", @@ -607,6 +622,7 @@ }, "ComponentScheduleRequest": { "title": "ComponentScheduleRequest", + "additionalProperties": false, "properties": { "cron": { "title": "Cron", @@ -621,6 +637,7 @@ }, "ComponentSpecRequest": { "title": "ComponentSpecRequest", + "additionalProperties": false, "properties": { "contract": { "description": "The composition contract: typed inputs the component needs and outputs it publishes.", @@ -638,6 +655,7 @@ }, "ComponentValueGeneratorRequest": { "title": "ComponentValueGeneratorRequest", + "additionalProperties": false, "properties": { "byteLength": { "title": "Bytelength", @@ -676,6 +694,7 @@ "ComponentValueSchemaRequest": { "title": "ComponentValueSchemaRequest", "description": "Data contract of an input or output value (JSON-Schema subset).", + "additionalProperties": false, "properties": { "default": { "title": "Default", @@ -776,6 +795,7 @@ }, "ComponentVolumeRequest": { "title": "ComponentVolumeRequest", + "additionalProperties": false, "properties": { "accessMode": { "title": "Accessmode", @@ -813,6 +833,7 @@ }, "ComponentWorkloadRequest": { "title": "ComponentWorkloadRequest", + "additionalProperties": false, "properties": { "command": { "title": "Command", diff --git a/specifications/component/v1/schemas/src/component.schema.json b/specifications/component/v1/schemas/src/component.schema.json index 2e7cefc..f47f4e5 100644 --- a/specifications/component/v1/schemas/src/component.schema.json +++ b/specifications/component/v1/schemas/src/component.schema.json @@ -36,6 +36,7 @@ "type": "object", "$defs": { "ComponentBuildRequest": { + "additionalProperties": false, "properties": { "arguments": { "additionalProperties": { @@ -88,6 +89,7 @@ "type": "object" }, "ComponentConfigRefEnvVarValueRequest": { + "additionalProperties": false, "properties": { "configKey": { "description": "Key of the organization's saved configuration value, resolved server-side at snapshot compute. The component never holds plaintext; the entry's own key may alias it.", @@ -112,6 +114,7 @@ "type": "object" }, "ComponentContractRequest": { + "additionalProperties": false, "properties": { "inputs": { "additionalProperties": { @@ -134,6 +137,7 @@ "type": "object" }, "ComponentEndpointRequest": { + "additionalProperties": false, "properties": { "containerPort": { "description": "Port the workload listens on inside the container.", @@ -173,6 +177,7 @@ "x-additionalPropertiesName": "endpointName" }, "ComponentEnvVarRequest": { + "additionalProperties": false, "description": "One environment-variable entry (Kubernetes-style named entry).", "properties": { "key": { @@ -211,6 +216,7 @@ "type": "object" }, "ComponentGitRefRequest": { + "additionalProperties": false, "properties": { "name": { "description": "The branch name or commit SHA to build from.", @@ -235,6 +241,7 @@ "type": "object" }, "ComponentGitSourceRequest": { + "additionalProperties": false, "properties": { "build": { "anyOf": [ @@ -280,6 +287,7 @@ "type": "object" }, "ComponentHealthProbesRequest": { + "additionalProperties": false, "description": "Staged health probes (ADR 0093): startup, readiness, liveness.", "properties": { "liveness": { @@ -323,6 +331,7 @@ "type": "object" }, "ComponentImageSourceRequest": { + "additionalProperties": false, "properties": { "ref": { "description": "OCI image reference the workload runs from.", @@ -344,6 +353,7 @@ "type": "object" }, "ComponentInputRequest": { + "additionalProperties": false, "properties": { "description": { "anyOf": [ @@ -440,6 +450,7 @@ "x-additionalPropertiesName": "inputName" }, "ComponentInputTargetRequest": { + "additionalProperties": false, "properties": { "envVarKey": { "description": "Environment variable the resolved value is bound into at runtime materialisation (e.g. ADMIN_EMAIL). Uppercase letters, digits, and underscores; must not start with a digit.", @@ -457,6 +468,7 @@ "type": "object" }, "ComponentInputUiRequest": { + "additionalProperties": false, "description": "Presentation metadata for a USER-supplied input.\n\nThe install-form control is derived from the input's schema (enum →\nselect, BOOLEAN → toggle, EMAIL format → email field, sensitive →\nmasked field) — only the label needs declaring.", "properties": { "label": { @@ -472,6 +484,7 @@ "type": "object" }, "ComponentLiteralEnvVarValueRequest": { + "additionalProperties": false, "properties": { "isSensitive": { "default": false, @@ -499,6 +512,7 @@ "type": "object" }, "ComponentOutputRequest": { + "additionalProperties": false, "properties": { "description": { "anyOf": [ @@ -549,6 +563,7 @@ "x-additionalPropertiesName": "outputName" }, "ComponentProbeRequest": { + "additionalProperties": false, "properties": { "endpoint": { "anyOf": [ @@ -606,6 +621,7 @@ "type": "object" }, "ComponentScheduleRequest": { + "additionalProperties": false, "properties": { "cron": { "description": "Cron expression on which a CRON workload runs.", @@ -620,6 +636,7 @@ "type": "object" }, "ComponentSpecRequest": { + "additionalProperties": false, "properties": { "contract": { "$ref": "#/$defs/ComponentContractRequest", @@ -637,6 +654,7 @@ "type": "object" }, "ComponentValueGeneratorRequest": { + "additionalProperties": false, "properties": { "byteLength": { "default": 32, @@ -674,6 +692,7 @@ "type": "object" }, "ComponentValueSchemaRequest": { + "additionalProperties": false, "description": "Data contract of an input or output value (JSON-Schema subset).", "properties": { "default": { @@ -775,6 +794,7 @@ "type": "object" }, "ComponentVolumeRequest": { + "additionalProperties": false, "properties": { "accessMode": { "default": "READ_WRITE_ONCE", @@ -812,6 +832,7 @@ "x-additionalPropertiesName": "volumeName" }, "ComponentWorkloadRequest": { + "additionalProperties": false, "properties": { "command": { "anyOf": [ From c3af194cfcbe26eb1755f11383b6f5d8f999e02a Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 9 Aug 2026 02:42:27 +0000 Subject: [PATCH 2/5] fix(blueprint): reject unknown properties below the envelope 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) Signed-off-by: Justin Merrell --- .../blueprint/v1/schemas/dist/blueprint.schema.json | 6 ++++++ .../blueprint/v1/schemas/src/blueprint.schema.json | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/specifications/blueprint/v1/schemas/dist/blueprint.schema.json b/specifications/blueprint/v1/schemas/dist/blueprint.schema.json index bb6d918..f9722f0 100644 --- a/specifications/blueprint/v1/schemas/dist/blueprint.schema.json +++ b/specifications/blueprint/v1/schemas/dist/blueprint.schema.json @@ -38,6 +38,7 @@ "BlueprintComputeConstraintsRequest": { "title": "BlueprintComputeConstraintsRequest", "description": "Advanced, optional compute pins (ADR 0041) honored by Placement (ADR 0095).", + "additionalProperties": false, "properties": { "acceleratorInterconnect": { "title": "Acceleratorinterconnect", @@ -154,6 +155,7 @@ "BlueprintConnectionRefRequest": { "title": "BlueprintConnectionRefRequest", "description": "A consumer-anchored wire (map value; keyed by the consumer input key).\n\nThe value names only the producer end (``fromRole`` / ``fromOutput``); the\nconsumer end is derived from the enclosing node (``toRole`` = the node name)\nand the map key (``toInput``). One wire per consumer input (inbound-uniqueness,\nenforced by the publish gate).", + "additionalProperties": false, "properties": { "fromOutput": { "title": "Fromoutput", @@ -175,6 +177,7 @@ }, "BlueprintParameterGeneratorRequest": { "title": "BlueprintParameterGeneratorRequest", + "additionalProperties": false, "properties": { "byteLength": { "title": "Bytelength", @@ -213,6 +216,7 @@ "BlueprintParameterRequest": { "title": "BlueprintParameterRequest", "description": "An install-form parameter composed at the graph layer (map value; keyed by key).", + "additionalProperties": false, "properties": { "description": { "title": "Description", @@ -264,6 +268,7 @@ "BlueprintParameterUiRequest": { "title": "BlueprintParameterUiRequest", "description": "Presentation metadata for an install-form parameter.", + "additionalProperties": false, "properties": { "label": { "title": "Label", @@ -279,6 +284,7 @@ "BlueprintParameterValueSchemaRequest": { "title": "BlueprintParameterValueSchemaRequest", "description": "Data contract of an install-form parameter value (JSON-Schema subset).", + "additionalProperties": false, "properties": { "default": { "title": "Default", diff --git a/specifications/blueprint/v1/schemas/src/blueprint.schema.json b/specifications/blueprint/v1/schemas/src/blueprint.schema.json index 66b1416..25d1dd9 100644 --- a/specifications/blueprint/v1/schemas/src/blueprint.schema.json +++ b/specifications/blueprint/v1/schemas/src/blueprint.schema.json @@ -36,6 +36,7 @@ "type": "object", "$defs": { "BlueprintComputeConstraintsRequest": { + "additionalProperties": false, "description": "Advanced, optional compute pins (ADR 0041) honored by Placement (ADR 0095).", "properties": { "acceleratorInterconnect": { @@ -152,6 +153,7 @@ "type": "object" }, "BlueprintConnectionRefRequest": { + "additionalProperties": false, "description": "A consumer-anchored wire (map value; keyed by the consumer input key).\n\nThe value names only the producer end (``fromRole`` / ``fromOutput``); the\nconsumer end is derived from the enclosing node (``toRole`` = the node name)\nand the map key (``toInput``). One wire per consumer input (inbound-uniqueness,\nenforced by the publish gate).", "properties": { "fromOutput": { @@ -174,6 +176,7 @@ "x-additionalPropertiesName": "inputKey" }, "BlueprintParameterGeneratorRequest": { + "additionalProperties": false, "properties": { "byteLength": { "default": 32, @@ -211,6 +214,7 @@ "type": "object" }, "BlueprintParameterRequest": { + "additionalProperties": false, "description": "An install-form parameter composed at the graph layer (map value; keyed by key).", "properties": { "description": { @@ -262,6 +266,7 @@ "x-additionalPropertiesName": "parameterKey" }, "BlueprintParameterUiRequest": { + "additionalProperties": false, "description": "Presentation metadata for an install-form parameter.", "properties": { "label": { @@ -277,6 +282,7 @@ "type": "object" }, "BlueprintParameterValueSchemaRequest": { + "additionalProperties": false, "description": "Data contract of an install-form parameter value (JSON-Schema subset).", "properties": { "default": { From ec54c0954af071ac37f480649812ec5f9813648f Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 9 Aug 2026 02:42:59 +0000 Subject: [PATCH 3/5] test(conformance): pin nested unknown-field rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Signed-off-by: Justin Merrell --- conformance/blueprint/v1/cases.json | 5 +++++ .../005-nested-unknown-field/case.yaml | 22 +++++++++++++++++++ .../005-nested-unknown-field/diagnostics.json | 6 +++++ .../005-nested-unknown-field/metadata.json | 7 ++++++ conformance/component/v1/cases.json | 5 +++++ .../005-nested-unknown-field/case.yaml | 17 ++++++++++++++ .../005-nested-unknown-field/diagnostics.json | 6 +++++ .../005-nested-unknown-field/metadata.json | 7 ++++++ conformance/listing/v1/cases.json | 5 +++++ .../005-nested-unknown-field/case.yaml | 17 ++++++++++++++ .../005-nested-unknown-field/diagnostics.json | 6 +++++ .../005-nested-unknown-field/metadata.json | 7 ++++++ 12 files changed, 110 insertions(+) create mode 100644 conformance/blueprint/v1/structural/005-nested-unknown-field/case.yaml create mode 100644 conformance/blueprint/v1/structural/005-nested-unknown-field/diagnostics.json create mode 100644 conformance/blueprint/v1/structural/005-nested-unknown-field/metadata.json create mode 100644 conformance/component/v1/structural/005-nested-unknown-field/case.yaml create mode 100644 conformance/component/v1/structural/005-nested-unknown-field/diagnostics.json create mode 100644 conformance/component/v1/structural/005-nested-unknown-field/metadata.json create mode 100644 conformance/listing/v1/structural/005-nested-unknown-field/case.yaml create mode 100644 conformance/listing/v1/structural/005-nested-unknown-field/diagnostics.json create mode 100644 conformance/listing/v1/structural/005-nested-unknown-field/metadata.json diff --git a/conformance/blueprint/v1/cases.json b/conformance/blueprint/v1/cases.json index 9315415..0613a80 100644 --- a/conformance/blueprint/v1/cases.json +++ b/conformance/blueprint/v1/cases.json @@ -26,6 +26,11 @@ "id": "structural-004-unsupported-spec-version", "phase": "structural", "path": "structural/004-unsupported-spec-version" + }, + { + "id": "structural-005-nested-unknown-field", + "phase": "structural", + "path": "structural/005-nested-unknown-field" } ] } diff --git a/conformance/blueprint/v1/structural/005-nested-unknown-field/case.yaml b/conformance/blueprint/v1/structural/005-nested-unknown-field/case.yaml new file mode 100644 index 0000000..e0ab8c2 --- /dev/null +++ b/conformance/blueprint/v1/structural/005-nested-unknown-field/case.yaml @@ -0,0 +1,22 @@ +# A misspelled optional field below the envelope. `lable` is a typo for +# `label`, three levels down inside a parameter's presentation block. +# Rejection must not depend on how deeply the typo is nested. +specVersion: v1 +kind: BLUEPRINT +metadata: + slug: web-and-database + version: 1 +spec: + components: + db: + component: postgres + size: general.standard.small + connections: {} + parameters: + siteTitle: + schema: + type: STRING + default: My Application + isRequired: false + ui: + lable: Site title diff --git a/conformance/blueprint/v1/structural/005-nested-unknown-field/diagnostics.json b/conformance/blueprint/v1/structural/005-nested-unknown-field/diagnostics.json new file mode 100644 index 0000000..cd475ff --- /dev/null +++ b/conformance/blueprint/v1/structural/005-nested-unknown-field/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_UNKNOWN_FIELD", + "path": "/spec/parameters/siteTitle/ui" + } +] diff --git a/conformance/blueprint/v1/structural/005-nested-unknown-field/metadata.json b/conformance/blueprint/v1/structural/005-nested-unknown-field/metadata.json new file mode 100644 index 0000000..07075ed --- /dev/null +++ b/conformance/blueprint/v1/structural/005-nested-unknown-field/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-005-nested-unknown-field", + "phase": "structural", + "expected": "fail", + "clause": "specifications/blueprint/v1/spec.md#envelope", + "summary": "An undefined property nested below the envelope is rejected, not silently dropped." +} diff --git a/conformance/component/v1/cases.json b/conformance/component/v1/cases.json index 92e9802..f872981 100644 --- a/conformance/component/v1/cases.json +++ b/conformance/component/v1/cases.json @@ -26,6 +26,11 @@ "id": "structural-004-unsupported-spec-version", "phase": "structural", "path": "structural/004-unsupported-spec-version" + }, + { + "id": "structural-005-nested-unknown-field", + "phase": "structural", + "path": "structural/005-nested-unknown-field" } ] } diff --git a/conformance/component/v1/structural/005-nested-unknown-field/case.yaml b/conformance/component/v1/structural/005-nested-unknown-field/case.yaml new file mode 100644 index 0000000..955337a --- /dev/null +++ b/conformance/component/v1/structural/005-nested-unknown-field/case.yaml @@ -0,0 +1,17 @@ +# A misspelled optional field below the envelope. `initialDelaySecond` is +# singular; the defined property is `initialDelaySeconds`. Rejection must not +# depend on how deeply the typo is nested. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + health: + readiness: + path: /healthz + initialDelaySecond: 30 diff --git a/conformance/component/v1/structural/005-nested-unknown-field/diagnostics.json b/conformance/component/v1/structural/005-nested-unknown-field/diagnostics.json new file mode 100644 index 0000000..fdda1e6 --- /dev/null +++ b/conformance/component/v1/structural/005-nested-unknown-field/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_UNKNOWN_FIELD", + "path": "/spec/workload/health/readiness" + } +] diff --git a/conformance/component/v1/structural/005-nested-unknown-field/metadata.json b/conformance/component/v1/structural/005-nested-unknown-field/metadata.json new file mode 100644 index 0000000..53dfc6f --- /dev/null +++ b/conformance/component/v1/structural/005-nested-unknown-field/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-005-nested-unknown-field", + "phase": "structural", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#envelope", + "summary": "An undefined property nested below the envelope is rejected, not silently dropped." +} diff --git a/conformance/listing/v1/cases.json b/conformance/listing/v1/cases.json index 209cfda..890c422 100644 --- a/conformance/listing/v1/cases.json +++ b/conformance/listing/v1/cases.json @@ -26,6 +26,11 @@ "id": "structural-004-unsupported-spec-version", "phase": "structural", "path": "structural/004-unsupported-spec-version" + }, + { + "id": "structural-005-nested-unknown-field", + "phase": "structural", + "path": "structural/005-nested-unknown-field" } ] } diff --git a/conformance/listing/v1/structural/005-nested-unknown-field/case.yaml b/conformance/listing/v1/structural/005-nested-unknown-field/case.yaml new file mode 100644 index 0000000..6668a07 --- /dev/null +++ b/conformance/listing/v1/structural/005-nested-unknown-field/case.yaml @@ -0,0 +1,17 @@ +# A misspelled optional field below the envelope. `captions` is a typo for +# `caption`, inside an array element. Rejection must not depend on how deeply +# the typo is nested, nor on whether it sits inside an array. +specVersion: v1 +kind: LISTING +metadata: + slug: postgres + version: 1 +spec: + listingKind: COMPONENT + displayName: PostgreSQL + summary: The open-source relational database + category: INFRASTRUCTURE + lifecycleStage: STABLE + screenshots: + - file: media/overview.png + captions: Connection details after deployment diff --git a/conformance/listing/v1/structural/005-nested-unknown-field/diagnostics.json b/conformance/listing/v1/structural/005-nested-unknown-field/diagnostics.json new file mode 100644 index 0000000..5000b3b --- /dev/null +++ b/conformance/listing/v1/structural/005-nested-unknown-field/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_UNKNOWN_FIELD", + "path": "/spec/screenshots/0" + } +] diff --git a/conformance/listing/v1/structural/005-nested-unknown-field/metadata.json b/conformance/listing/v1/structural/005-nested-unknown-field/metadata.json new file mode 100644 index 0000000..7735374 --- /dev/null +++ b/conformance/listing/v1/structural/005-nested-unknown-field/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-005-nested-unknown-field", + "phase": "structural", + "expected": "fail", + "clause": "specifications/listing/v1/spec.md#envelope", + "summary": "An undefined property nested below the envelope is rejected, not silently dropped." +} From 924687d7c9b17b640e5a0c9fe56444ee40c3822a Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 9 Aug 2026 02:43:12 +0000 Subject: [PATCH 4/5] feat(tools): require object schemas to close additionalProperties 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) Signed-off-by: Justin Merrell --- tools/src/bundle.ts | 2 +- tools/src/lint.ts | 25 ++++++++++++++++++++++++- tools/src/spec.ts | 24 +++++++++++++++++++----- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/tools/src/bundle.ts b/tools/src/bundle.ts index b495da2..026ef92 100644 --- a/tools/src/bundle.ts +++ b/tools/src/bundle.ts @@ -99,7 +99,7 @@ function conceptToDefName(concept: string): string { /** Refuse to emit a bundle that would make a validator reach over the network. */ function assertSelfContained(bundle: { [k: string]: Json }, family: Family): void { const defs = isObject(bundle.$defs) ? bundle.$defs : {} - for (const node of walkObjects(bundle)) { + for (const { node } of walkObjects(bundle)) { const ref = node.$ref if (typeof ref !== 'string') continue if (!ref.startsWith('#/$defs/')) { diff --git a/tools/src/lint.ts b/tools/src/lint.ts index 9bb1033..c23e032 100644 --- a/tools/src/lint.ts +++ b/tools/src/lint.ts @@ -67,6 +67,7 @@ function main(): void { checkDialect(doc, rel, failures) checkId(doc, rel, family.name, family.major, seenIds, failures) checkRefs(doc, rel, failures) + checkClosedObjects(doc, rel, failures) checkDefsNames(doc, rel, failures) checkMetaValid(doc, rel, failures) } @@ -122,7 +123,7 @@ function checkId( */ function checkRefs(doc: { [k: string]: Json }, rel: string, failures: Failures): void { const defs = isObject(doc.$defs) ? doc.$defs : {} - for (const node of walkObjects(doc)) { + for (const { node } of walkObjects(doc)) { for (const keyword of ['$ref', '$dynamicRef'] as const) { const ref = node[keyword] if (typeof ref !== 'string') continue @@ -143,6 +144,28 @@ function checkRefs(doc: { [k: string]: Json }, rel: string, failures: Failures): } } +/** + * Every object schema must close itself. `spec.md` §2 requires unknown + * properties to be rejected "at every level", and JSON Schema only does that + * where `additionalProperties: false` is written down — an omission validates a + * typo'd optional key clean and silently falls back to the default. + * + * Only schemas that declare `properties` are records. A schema using + * `additionalProperties` as a map value schema (`inputs`, `endpoints`, + * `arguments`) declares no `properties` and is deliberately open, because its + * keys are chosen by the document author. + */ +function checkClosedObjects(doc: { [k: string]: Json }, rel: string, failures: Failures): void { + for (const { node, pointer } of walkObjects(doc)) { + if (node.type !== 'object' || !isObject(node.properties)) continue + if (node.additionalProperties === false) continue + failures.add( + `${rel}: ${pointer || ''} declares properties but not ` + + '"additionalProperties": false — unknown properties MUST be rejected at every level', + ) + } +} + function checkDefsNames(doc: { [k: string]: Json }, rel: string, failures: Failures): void { if (!isObject(doc.$defs)) return for (const name of Object.keys(doc.$defs)) { diff --git a/tools/src/spec.ts b/tools/src/spec.ts index 5ddd99f..5d29ae9 100644 --- a/tools/src/spec.ts +++ b/tools/src/spec.ts @@ -143,15 +143,29 @@ export function canonicalJson(value: Json): string { return `${JSON.stringify(canonicalize(value), null, 2)}\n` } -/** Walk every nested object in a schema document, root included. */ -export function* walkObjects(node: Json): Generator<{ [k: string]: Json }> { +/** Escape a single JSON Pointer reference token (RFC 6901 §3). */ +function escapePointerToken(token: string): string { + return token.replace(/~/g, '~0').replace(/\//g, '~1') +} + +/** + * Walk every nested object in a schema document, root included, pairing each + * with the JSON Pointer that locates it. The pointer is what lets a diagnostic + * name the offending subschema instead of describing it. + */ +export function* walkObjects( + node: Json, + pointer = '', +): Generator<{ node: { [k: string]: Json }; pointer: string }> { if (Array.isArray(node)) { - for (const item of node) yield* walkObjects(item) + for (const [index, item] of node.entries()) yield* walkObjects(item, `${pointer}/${index}`) return } if (!isObject(node)) return - yield node - for (const key of Object.keys(node)) yield* walkObjects(node[key] as Json) + yield { node, pointer } + for (const key of Object.keys(node)) { + yield* walkObjects(node[key] as Json, `${pointer}/${escapePointerToken(key)}`) + } } export function relativeToRepo(path: string): string { From ed1b6c46c0425f855a62796c72491ca7c48e84e2 Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 9 Aug 2026 02:43:35 +0000 Subject: [PATCH 5/5] docs(component): name ERR_UNKNOWN_FIELD in the envelope clause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Signed-off-by: Justin Merrell --- specifications/component/v1/spec.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/specifications/component/v1/spec.md b/specifications/component/v1/spec.md index 9310af0..697cee5 100644 --- a/specifications/component/v1/spec.md +++ b/specifications/component/v1/spec.md @@ -46,8 +46,10 @@ spec: { … } | `metadata` | REQUIRED. Identity. | | `spec` | REQUIRED. The definition itself. | -Unknown properties MUST be rejected at every level. A misspelled field is an -error, never a silently ignored one. +Unknown properties MUST be rejected with `ERR_UNKNOWN_FIELD` at every level, not +only at the root of the envelope. A misspelled field is an error, never a +silently ignored one — including when the misspelled field is optional, where +ignoring it would silently substitute the default. A validator encountering a `specVersion` it does not support MUST reject the document with `ERR_UNSUPPORTED_SPEC_VERSION` and MUST NOT attempt a best-effort