From a874f79bc83980c645b4f9d01a9182fb8528394f Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Wed, 19 Aug 2026 11:59:12 +0000 Subject: [PATCH 1/3] feat(component)!: replace INTEGER with NUMBER and add JSON to the value vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The value-shape vocabulary was published nowhere. Component §6.1 and §6.2 discussed `schema` and `type` at length without ever saying what `type` may contain, while every neighbouring vocabulary — `suppliedBy`, `platformDefault.source`, `valueFrom` — got a prose table. The only statement of membership was a schema `description` string, which CONTRIBUTING ground rule 1 makes informative. The meaning of `type` was defined in a field this repository declares non-normative. That is survivable for three self-evident primitives. It stops being survivable when a member arrives whose meaning is not self-evident, and `JSON` is that member: every value here is carried as text, so a JSON value's string form is an encoded document, and one value has many spellings. New §6.3 Value schemas states the vocabulary, the string transport, and three requirement IDs. `INTEGER` goes because `NUMBER` is JSON's own numeric kind and covers whole numbers and reals alike; an author who needs whole numbers writes `pattern: '^-?[0-9]+$'` rather than reaching for a second type. Keeping both would have obliged the contract to say whether `5432.0` is an INTEGER and whether an INTEGER output satisfies a NUMBER input under §4.2's no-widening rule — two near-synonyms that make independently authored components reject each other over a difference no consumer can observe. `pattern` and `enum` are forbidden on a JSON value: both decide membership on the spelling, so either would accept one author's formatter and reject another's. `format` is confined to STRING, which its own description already claimed and nothing enforced. Six fixtures arrive with it. structural/039 passed before this commit and fails after, which is what makes the withdrawal executable rather than declared. structural/036 is migrated for the same reason in reverse. Filed as issue #54 with the divergence inverted — it cited a §10 that does not exist here, and the rename it asked for would have broken CI in exactly the two ways it described. See ADR 0013. BREAKING CHANGE: `INTEGER` is withdrawn from `schema.type`; use `NUMBER`, with `pattern: '^-?[0-9]+$'` where whole numbers are required. `format` MUST now be null where `type` is not `STRING`. ADR 0005 §1's pre-publication window applies: no `v` directory and no migration note, since no version has been tagged. Signed-off-by: Justin Merrell --- .../tree/acme-wiki/blueprint.yaml | 2 +- .../tree/acme-wiki/components/postgres.yaml | 2 +- .../tree/acme-wiki/blueprint.yaml | 2 +- .../tree/acme-wiki/components/postgres.yaml | 2 +- conformance/component/v1/cases.json | 30 +++++++++ .../036-public-port-on-udp-endpoint/case.yaml | 2 +- .../039-integer-value-type/case.yaml | 26 +++++++ .../039-integer-value-type/diagnostics.json | 6 ++ .../039-integer-value-type/metadata.json | 8 +++ .../040-number-value-type/case.yaml | 34 ++++++++++ .../040-number-value-type/metadata.json | 13 ++++ .../structural/041-json-value-type/case.yaml | 34 ++++++++++ .../041-json-value-type/metadata.json | 12 ++++ .../042-json-value-with-pattern/case.yaml | 28 ++++++++ .../diagnostics.json | 6 ++ .../042-json-value-with-pattern/metadata.json | 8 +++ .../043-json-value-with-enum/case.yaml | 31 +++++++++ .../043-json-value-with-enum/diagnostics.json | 6 ++ .../043-json-value-with-enum/metadata.json | 8 +++ .../case.yaml | 28 ++++++++ .../diagnostics.json | 6 ++ .../metadata.json | 8 +++ docs/traceability.md | 5 +- .../v1/schemas/dist/component.schema.json | 60 +++++++++++++++-- .../v1/schemas/src/component.schema.json | 60 +++++++++++++++-- specifications/component/v1/spec.md | 67 +++++++++++++++++++ 26 files changed, 476 insertions(+), 18 deletions(-) create mode 100644 conformance/component/v1/structural/039-integer-value-type/case.yaml create mode 100644 conformance/component/v1/structural/039-integer-value-type/diagnostics.json create mode 100644 conformance/component/v1/structural/039-integer-value-type/metadata.json create mode 100644 conformance/component/v1/structural/040-number-value-type/case.yaml create mode 100644 conformance/component/v1/structural/040-number-value-type/metadata.json create mode 100644 conformance/component/v1/structural/041-json-value-type/case.yaml create mode 100644 conformance/component/v1/structural/041-json-value-type/metadata.json create mode 100644 conformance/component/v1/structural/042-json-value-with-pattern/case.yaml create mode 100644 conformance/component/v1/structural/042-json-value-with-pattern/diagnostics.json create mode 100644 conformance/component/v1/structural/042-json-value-with-pattern/metadata.json create mode 100644 conformance/component/v1/structural/043-json-value-with-enum/case.yaml create mode 100644 conformance/component/v1/structural/043-json-value-with-enum/diagnostics.json create mode 100644 conformance/component/v1/structural/043-json-value-with-enum/metadata.json create mode 100644 conformance/component/v1/structural/044-format-on-a-non-string-value/case.yaml create mode 100644 conformance/component/v1/structural/044-format-on-a-non-string-value/diagnostics.json create mode 100644 conformance/component/v1/structural/044-format-on-a-non-string-value/metadata.json diff --git a/conformance/blueprint/v1/semantic/009-conflicting-input-schema/tree/acme-wiki/blueprint.yaml b/conformance/blueprint/v1/semantic/009-conflicting-input-schema/tree/acme-wiki/blueprint.yaml index 32e9375..de11ccf 100644 --- a/conformance/blueprint/v1/semantic/009-conflicting-input-schema/tree/acme-wiki/blueprint.yaml +++ b/conformance/blueprint/v1/semantic/009-conflicting-input-schema/tree/acme-wiki/blueprint.yaml @@ -1,5 +1,5 @@ # Both components declare `adminPassword`, and they disagree about what it is: -# a STRING for `api`, an INTEGER for `db`. Silent first-wins would hand `db` a +# a STRING for `api`, a NUMBER for `db`. Silent first-wins would hand `db` a # value validated against `api`'s rules and fail at deploy time, a long way from # either document. # diff --git a/conformance/blueprint/v1/semantic/009-conflicting-input-schema/tree/acme-wiki/components/postgres.yaml b/conformance/blueprint/v1/semantic/009-conflicting-input-schema/tree/acme-wiki/components/postgres.yaml index 28b0a7a..754b8c1 100644 --- a/conformance/blueprint/v1/semantic/009-conflicting-input-schema/tree/acme-wiki/components/postgres.yaml +++ b/conformance/blueprint/v1/semantic/009-conflicting-input-schema/tree/acme-wiki/components/postgres.yaml @@ -17,7 +17,7 @@ spec: inputs: adminPassword: schema: - type: INTEGER + type: NUMBER isSensitive: true suppliedBy: USER ui: diff --git a/conformance/blueprint/v1/semantic/012-connection-type-mismatch/tree/acme-wiki/blueprint.yaml b/conformance/blueprint/v1/semantic/012-connection-type-mismatch/tree/acme-wiki/blueprint.yaml index 06b5a3e..272d048 100644 --- a/conformance/blueprint/v1/semantic/012-connection-type-mismatch/tree/acme-wiki/blueprint.yaml +++ b/conformance/blueprint/v1/semantic/012-connection-type-mismatch/tree/acme-wiki/blueprint.yaml @@ -1,4 +1,4 @@ -# `db` publishes an INTEGER port; `web` asks for a STRING. Both ends resolve — +# `db` publishes a NUMBER port; `web` asks for a STRING. Both ends resolve — # the node, the output and the input all exist — so nothing above §4.2's # compatibility rule fires. Without it the mismatch reaches the consuming # workload at deploy time with nothing pointing back at either document. diff --git a/conformance/blueprint/v1/semantic/012-connection-type-mismatch/tree/acme-wiki/components/postgres.yaml b/conformance/blueprint/v1/semantic/012-connection-type-mismatch/tree/acme-wiki/components/postgres.yaml index 4e7060d..1f0b613 100644 --- a/conformance/blueprint/v1/semantic/012-connection-type-mismatch/tree/acme-wiki/components/postgres.yaml +++ b/conformance/blueprint/v1/semantic/012-connection-type-mismatch/tree/acme-wiki/components/postgres.yaml @@ -18,6 +18,6 @@ spec: outputs: port: schema: - type: INTEGER + type: NUMBER valueFrom: DECLARED value: '5432' diff --git a/conformance/component/v1/cases.json b/conformance/component/v1/cases.json index c32d09d..c71c236 100644 --- a/conformance/component/v1/cases.json +++ b/conformance/component/v1/cases.json @@ -257,6 +257,36 @@ "phase": "structural", "path": "structural/038-probe-defaults-are-effective" }, + { + "id": "structural-039-integer-value-type", + "phase": "structural", + "path": "structural/039-integer-value-type" + }, + { + "id": "structural-040-number-value-type", + "phase": "structural", + "path": "structural/040-number-value-type" + }, + { + "id": "structural-041-json-value-type", + "phase": "structural", + "path": "structural/041-json-value-type" + }, + { + "id": "structural-042-json-value-with-pattern", + "phase": "structural", + "path": "structural/042-json-value-with-pattern" + }, + { + "id": "structural-043-json-value-with-enum", + "phase": "structural", + "path": "structural/043-json-value-with-enum" + }, + { + "id": "structural-044-format-on-a-non-string-value", + "phase": "structural", + "path": "structural/044-format-on-a-non-string-value" + }, { "id": "semantic-001-floating-tag-image-reference", "phase": "semantic", diff --git a/conformance/component/v1/structural/036-public-port-on-udp-endpoint/case.yaml b/conformance/component/v1/structural/036-public-port-on-udp-endpoint/case.yaml index 9ee2bc6..33a5745 100644 --- a/conformance/component/v1/structural/036-public-port-on-udp-endpoint/case.yaml +++ b/conformance/component/v1/structural/036-public-port-on-udp-endpoint/case.yaml @@ -21,7 +21,7 @@ spec: inputs: gamePort: schema: - type: INTEGER + type: NUMBER ui: label: Game server port platformDefault: diff --git a/conformance/component/v1/structural/039-integer-value-type/case.yaml b/conformance/component/v1/structural/039-integer-value-type/case.yaml new file mode 100644 index 0000000..c612afa --- /dev/null +++ b/conformance/component/v1/structural/039-integer-value-type/case.yaml @@ -0,0 +1,26 @@ +# INTEGER was a member of this vocabulary and is not one now. The case exists to +# make the withdrawal executable rather than declared: it passed before NUMBER +# replaced INTEGER, and fails after. See ADR 0013. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + endpoints: + web: + containerPort: 8080 + protocol: HTTP + visibility: PRIVATE + contract: + inputs: + replicaCount: + schema: + type: INTEGER + ui: + label: Replica count + outputs: {} diff --git a/conformance/component/v1/structural/039-integer-value-type/diagnostics.json b/conformance/component/v1/structural/039-integer-value-type/diagnostics.json new file mode 100644 index 0000000..bf6f765 --- /dev/null +++ b/conformance/component/v1/structural/039-integer-value-type/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_INVALID_VALUE", + "path": "/spec/contract/inputs/replicaCount/schema/type" + } +] diff --git a/conformance/component/v1/structural/039-integer-value-type/metadata.json b/conformance/component/v1/structural/039-integer-value-type/metadata.json new file mode 100644 index 0000000..67703e0 --- /dev/null +++ b/conformance/component/v1/structural/039-integer-value-type/metadata.json @@ -0,0 +1,8 @@ +{ + "id": "structural-039-integer-value-type", + "phase": "structural", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#value-schema", + "requirements": ["COMP-VAL-001"], + "summary": "A value schema declaring the withdrawn INTEGER shape is rejected." +} diff --git a/conformance/component/v1/structural/040-number-value-type/case.yaml b/conformance/component/v1/structural/040-number-value-type/case.yaml new file mode 100644 index 0000000..faa35ae --- /dev/null +++ b/conformance/component/v1/structural/040-number-value-type/case.yaml @@ -0,0 +1,34 @@ +# NUMBER is JSON's own numeric kind, so it covers whole numbers and reals alike. +# An author who needs whole numbers writes the constraint rather than reaching +# for a second type, and this is what that looks like. The output's `value` is a +# quoted string because every value is carried as text — the shape names the +# string form, not a host type. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: postgres:17.10-alpine + endpoints: + primary: + containerPort: 5432 + protocol: TCP + visibility: PRIVATE + contract: + inputs: + port: + schema: + type: NUMBER + pattern: '^-?[0-9]+$' + ui: + label: Listen port + outputs: + port: + schema: + type: NUMBER + valueFrom: DECLARED + value: '5432' diff --git a/conformance/component/v1/structural/040-number-value-type/metadata.json b/conformance/component/v1/structural/040-number-value-type/metadata.json new file mode 100644 index 0000000..6e479dd --- /dev/null +++ b/conformance/component/v1/structural/040-number-value-type/metadata.json @@ -0,0 +1,13 @@ +{ + "id": "structural-040-number-value-type", + "phase": "structural", + "expected": "pass", + "clause": "specifications/component/v1/spec.md#value-schema", + "requirements": ["COMP-VAL-001"], + "summary": "A NUMBER value constrained to whole numbers by a pattern validates.", + "effective": { + "/spec/contract/inputs/port/schema/format": null, + "/spec/contract/inputs/port/schema/isSensitive": false, + "/spec/contract/inputs/port/schema/semanticType": null + } +} diff --git a/conformance/component/v1/structural/041-json-value-type/case.yaml b/conformance/component/v1/structural/041-json-value-type/case.yaml new file mode 100644 index 0000000..d090b42 --- /dev/null +++ b/conformance/component/v1/structural/041-json-value-type/case.yaml @@ -0,0 +1,34 @@ +# A JSON value's string form is a JSON document, so its `default` is a string +# whose content is that document — quoted, or the YAML profile would read it as +# a flow mapping and reject it against `type: string`. Nothing in any phase +# parses it: the contract does not check a value against the shape beside it, +# and that is as true of JSON as of every other member. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + endpoints: + web: + containerPort: 8080 + protocol: HTTP + visibility: PRIVATE + contract: + inputs: + settings: + schema: + type: JSON + default: '{"logLevel":"info"}' + ui: + label: Advanced settings + outputs: + topology: + schema: + type: JSON + valueFrom: DECLARED + value: '{"shards":1}' diff --git a/conformance/component/v1/structural/041-json-value-type/metadata.json b/conformance/component/v1/structural/041-json-value-type/metadata.json new file mode 100644 index 0000000..c1f4468 --- /dev/null +++ b/conformance/component/v1/structural/041-json-value-type/metadata.json @@ -0,0 +1,12 @@ +{ + "id": "structural-041-json-value-type", + "phase": "structural", + "expected": "pass", + "clause": "specifications/component/v1/spec.md#value-schema", + "requirements": ["COMP-VAL-001"], + "summary": "A JSON value carries its default as JSON text.", + "effective": { + "/spec/contract/inputs/settings/schema/pattern": null, + "/spec/contract/inputs/settings/schema/format": null + } +} diff --git a/conformance/component/v1/structural/042-json-value-with-pattern/case.yaml b/conformance/component/v1/structural/042-json-value-with-pattern/case.yaml new file mode 100644 index 0000000..40fae09 --- /dev/null +++ b/conformance/component/v1/structural/042-json-value-with-pattern/case.yaml @@ -0,0 +1,28 @@ +# One JSON value has many spellings, and a regular expression decides membership +# on the spelling. A rule written with one would accept one author's formatter +# and reject another's, so the pairing is rejected outright rather than left to +# mean whatever each implementation's serializer happens to emit. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + endpoints: + web: + containerPort: 8080 + protocol: HTTP + visibility: PRIVATE + contract: + inputs: + settings: + schema: + type: JSON + pattern: '^\{.*\}$' + ui: + label: Advanced settings + outputs: {} diff --git a/conformance/component/v1/structural/042-json-value-with-pattern/diagnostics.json b/conformance/component/v1/structural/042-json-value-with-pattern/diagnostics.json new file mode 100644 index 0000000..ae631c1 --- /dev/null +++ b/conformance/component/v1/structural/042-json-value-with-pattern/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_INVALID_TYPE", + "path": "/spec/contract/inputs/settings/schema/pattern" + } +] diff --git a/conformance/component/v1/structural/042-json-value-with-pattern/metadata.json b/conformance/component/v1/structural/042-json-value-with-pattern/metadata.json new file mode 100644 index 0000000..f539395 --- /dev/null +++ b/conformance/component/v1/structural/042-json-value-with-pattern/metadata.json @@ -0,0 +1,8 @@ +{ + "id": "structural-042-json-value-with-pattern", + "phase": "structural", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#value-schema", + "requirements": ["COMP-VAL-002"], + "summary": "A regular expression on a JSON value is rejected." +} diff --git a/conformance/component/v1/structural/043-json-value-with-enum/case.yaml b/conformance/component/v1/structural/043-json-value-with-enum/case.yaml new file mode 100644 index 0000000..a3658af --- /dev/null +++ b/conformance/component/v1/structural/043-json-value-with-enum/case.yaml @@ -0,0 +1,31 @@ +# The companion to 042, and a separate case because the two halves of the rule +# carry different codes. An empty `enum` is the unconstrained state, so the +# restriction is that the list stays empty rather than that the field is absent. +# The two members below are the same JSON value spelled two ways, which is the +# whole argument in one document. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + endpoints: + web: + containerPort: 8080 + protocol: HTTP + visibility: PRIVATE + contract: + inputs: + settings: + schema: + type: JSON + enum: + - '{"a":1}' + - '{ "a" : 1 }' + ui: + label: Advanced settings + outputs: {} diff --git a/conformance/component/v1/structural/043-json-value-with-enum/diagnostics.json b/conformance/component/v1/structural/043-json-value-with-enum/diagnostics.json new file mode 100644 index 0000000..d605b15 --- /dev/null +++ b/conformance/component/v1/structural/043-json-value-with-enum/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_INVALID_VALUE", + "path": "/spec/contract/inputs/settings/schema/enum" + } +] diff --git a/conformance/component/v1/structural/043-json-value-with-enum/metadata.json b/conformance/component/v1/structural/043-json-value-with-enum/metadata.json new file mode 100644 index 0000000..58a5229 --- /dev/null +++ b/conformance/component/v1/structural/043-json-value-with-enum/metadata.json @@ -0,0 +1,8 @@ +{ + "id": "structural-043-json-value-with-enum", + "phase": "structural", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#value-schema", + "requirements": ["COMP-VAL-002"], + "summary": "A closed enumeration on a JSON value is rejected." +} diff --git a/conformance/component/v1/structural/044-format-on-a-non-string-value/case.yaml b/conformance/component/v1/structural/044-format-on-a-non-string-value/case.yaml new file mode 100644 index 0000000..dea03d7 --- /dev/null +++ b/conformance/component/v1/structural/044-format-on-a-non-string-value/case.yaml @@ -0,0 +1,28 @@ +# Every format member names a lexical convention for text, so the field refines +# a STRING and nothing else. URI on a NUMBER describes nothing; that it used to +# validate was an accident of the two fields never having been described +# together. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + endpoints: + web: + containerPort: 8080 + protocol: HTTP + visibility: PRIVATE + contract: + inputs: + retries: + schema: + type: NUMBER + format: URI + ui: + label: Retry count + outputs: {} diff --git a/conformance/component/v1/structural/044-format-on-a-non-string-value/diagnostics.json b/conformance/component/v1/structural/044-format-on-a-non-string-value/diagnostics.json new file mode 100644 index 0000000..e8861ed --- /dev/null +++ b/conformance/component/v1/structural/044-format-on-a-non-string-value/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_INVALID_TYPE", + "path": "/spec/contract/inputs/retries/schema/format" + } +] diff --git a/conformance/component/v1/structural/044-format-on-a-non-string-value/metadata.json b/conformance/component/v1/structural/044-format-on-a-non-string-value/metadata.json new file mode 100644 index 0000000..85379f9 --- /dev/null +++ b/conformance/component/v1/structural/044-format-on-a-non-string-value/metadata.json @@ -0,0 +1,8 @@ +{ + "id": "structural-044-format-on-a-non-string-value", + "phase": "structural", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#value-schema", + "requirements": ["COMP-VAL-003"], + "summary": "A format refinement on a value that is not text is rejected." +} diff --git a/docs/traceability.md b/docs/traceability.md index c282018..3772973 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -34,6 +34,9 @@ this table makes visible is which rules the corpus actually holds down. | [`COMP-EP-002`](../specifications/component/v1/spec.md#COMP-EP-002) | [§Health probes](../specifications/component/v1/spec.md#health) | [`component/v1/semantic/002-probe-names-unknown-endpoint`](../conformance/component/v1/semantic/002-probe-names-unknown-endpoint/)
[`component/v1/semantic/003-platform-default-names-unknown-endpoint`](../conformance/component/v1/semantic/003-platform-default-names-unknown-endpoint/) | | [`COMP-EP-003`](../specifications/component/v1/spec.md#COMP-EP-003) | [§Health probes](../specifications/component/v1/spec.md#health) | [`component/v1/semantic/008-probe-on-tcp-endpoint`](../conformance/component/v1/semantic/008-probe-on-tcp-endpoint/)
[`component/v1/semantic/009-platform-default-on-tcp-endpoint`](../conformance/component/v1/semantic/009-platform-default-on-tcp-endpoint/) | | [`COMP-EP-004`](../specifications/component/v1/spec.md#COMP-EP-004) | [§Inputs](../specifications/component/v1/spec.md#inputs) | [`component/v1/semantic/004-platform-default-names-private-endpoint`](../conformance/component/v1/semantic/004-platform-default-names-private-endpoint/) | +| [`COMP-VAL-001`](../specifications/component/v1/spec.md#COMP-VAL-001) | [§Value schemas](../specifications/component/v1/spec.md#value-schema) | [`blueprint/v1/structural/024-integer-parameter-type`](../conformance/blueprint/v1/structural/024-integer-parameter-type/)
[`blueprint/v1/structural/026-json-parameter`](../conformance/blueprint/v1/structural/026-json-parameter/)
[`component/v1/structural/039-integer-value-type`](../conformance/component/v1/structural/039-integer-value-type/)
[`component/v1/structural/040-number-value-type`](../conformance/component/v1/structural/040-number-value-type/)
[`component/v1/structural/041-json-value-type`](../conformance/component/v1/structural/041-json-value-type/) | +| [`COMP-VAL-002`](../specifications/component/v1/spec.md#COMP-VAL-002) | [§Value schemas](../specifications/component/v1/spec.md#value-schema) | [`blueprint/v1/structural/025-json-parameter-with-pattern`](../conformance/blueprint/v1/structural/025-json-parameter-with-pattern/)
[`component/v1/structural/042-json-value-with-pattern`](../conformance/component/v1/structural/042-json-value-with-pattern/)
[`component/v1/structural/043-json-value-with-enum`](../conformance/component/v1/structural/043-json-value-with-enum/) | +| [`COMP-VAL-003`](../specifications/component/v1/spec.md#COMP-VAL-003) | [§Value schemas](../specifications/component/v1/spec.md#value-schema) | [`component/v1/structural/044-format-on-a-non-string-value`](../conformance/component/v1/structural/044-format-on-a-non-string-value/) | | [`COMP-YAML-001`](../specifications/component/v1/spec.md#COMP-YAML-001) | [§The Musher YAML profile](../specifications/component/v1/spec.md#yaml-profile) | [`blueprint/v1/parser/004-malformed-yaml`](../conformance/blueprint/v1/parser/004-malformed-yaml/)
[`component/v1/parser/004-malformed-yaml`](../conformance/component/v1/parser/004-malformed-yaml/)
[`listing/v1/parser/004-malformed-yaml`](../conformance/listing/v1/parser/004-malformed-yaml/) | | [`COMP-YAML-002`](../specifications/component/v1/spec.md#COMP-YAML-002) | [§The Musher YAML profile](../specifications/component/v1/spec.md#yaml-profile) | [`component/v1/parser/012-byte-order-mark`](../conformance/component/v1/parser/012-byte-order-mark/) | | [`COMP-YAML-003`](../specifications/component/v1/spec.md#COMP-YAML-003) | [§The Musher YAML profile](../specifications/component/v1/spec.md#yaml-profile) | [`component/v1/parser/013-crlf-line-endings`](../conformance/component/v1/parser/013-crlf-line-endings/) | @@ -61,4 +64,4 @@ this table makes visible is which rules the corpus actually holds down. | [`LIST-MEDIA-003`](../specifications/listing/v1/spec.md#LIST-MEDIA-003) | [§Media](../specifications/listing/v1/spec.md#media) | [`listing/v1/semantic/001-duplicate-screenshot-basename`](../conformance/listing/v1/semantic/001-duplicate-screenshot-basename/) | | [`LIST-MEDIA-004`](../specifications/listing/v1/spec.md#LIST-MEDIA-004) | [§Resolution after ingest](../specifications/listing/v1/spec.md#media-resolution) | — | -33 of 37 requirement(s) are pinned by at least one conformance case. +36 of 40 requirement(s) are pinned by at least one conformance case. diff --git a/specifications/component/v1/schemas/dist/component.schema.json b/specifications/component/v1/schemas/dist/component.schema.json index fa1e0cd..689735b 100644 --- a/specifications/component/v1/schemas/dist/component.schema.json +++ b/specifications/component/v1/schemas/dist/component.schema.json @@ -770,6 +770,53 @@ "ComponentValueSchema": { "description": "Data contract of an input or output value (JSON-Schema subset).", "additionalProperties": false, + "allOf": [ + { + "$comment": "spec.md §6.3. A JSON value's string form is a JSON document, and one value has many spellings — insignificant whitespace, member order, number formatting. `pattern` and `enum` both decide membership on the spelling, so neither can state a rule about the value.", + "if": { + "properties": { + "type": { + "const": "JSON" + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "enum": { + "maxItems": 0 + }, + "pattern": { + "type": "null" + } + } + } + }, + { + "$comment": "spec.md §6.3. Every `format` member names a lexical convention for text, so the field only refines a STRING. `type` is REQUIRED, so this is a `not` inside the `if` rather than an `else`: a document omitting `type` already reports ERR_MISSING_FIELD and should not also collect a format diagnostic.", + "if": { + "properties": { + "type": { + "not": { + "const": "STRING" + } + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "format": { + "type": "null" + } + } + } + } + ], "properties": { "default": { "description": "Pre-filled value when none is supplied, or null when the value must be provided.", @@ -784,14 +831,14 @@ "default": null }, "enum": { - "description": "Allowed values when the value is a closed enumeration; empty when unconstrained.", + "description": "Allowed values when the value is a closed enumeration; empty when unconstrained. MUST be empty where the type is JSON.", "items": { "type": "string" }, "type": "array" }, "format": { - "description": "Semantic refinement of a STRING value: EMAIL, URI, ENDPOINT_URL, or CONNECTION_STRING; null when unrefined.", + "description": "Semantic refinement of a STRING value: EMAIL, URI, ENDPOINT_URL, or CONNECTION_STRING; null when unrefined. MUST be null where the type is not STRING.", "anyOf": [ { "enum": [ @@ -814,7 +861,7 @@ "type": "boolean" }, "pattern": { - "description": "Regular expression the value must match, or null when unconstrained.", + "description": "Regular expression the value must match, or null when unconstrained. MUST be null where the type is JSON.", "anyOf": [ { "type": "string" @@ -847,11 +894,12 @@ "default": null }, "type": { - "description": "Primitive shape of the value: STRING, INTEGER, or BOOLEAN.", + "description": "Shape of the value's string form: STRING is any text, NUMBER a JSON number, BOOLEAN true or false, JSON a JSON document of any kind.", "enum": [ "STRING", - "INTEGER", - "BOOLEAN" + "NUMBER", + "BOOLEAN", + "JSON" ], "type": "string" } diff --git a/specifications/component/v1/schemas/src/component.schema.json b/specifications/component/v1/schemas/src/component.schema.json index 7a71c5b..8cc23fd 100644 --- a/specifications/component/v1/schemas/src/component.schema.json +++ b/specifications/component/v1/schemas/src/component.schema.json @@ -769,6 +769,53 @@ }, "ComponentValueSchema": { "additionalProperties": false, + "allOf": [ + { + "$comment": "spec.md §6.3. A JSON value's string form is a JSON document, and one value has many spellings — insignificant whitespace, member order, number formatting. `pattern` and `enum` both decide membership on the spelling, so neither can state a rule about the value.", + "if": { + "properties": { + "type": { + "const": "JSON" + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "enum": { + "maxItems": 0 + }, + "pattern": { + "type": "null" + } + } + } + }, + { + "$comment": "spec.md §6.3. Every `format` member names a lexical convention for text, so the field only refines a STRING. `type` is REQUIRED, so this is a `not` inside the `if` rather than an `else`: a document omitting `type` already reports ERR_MISSING_FIELD and should not also collect a format diagnostic.", + "if": { + "properties": { + "type": { + "not": { + "const": "STRING" + } + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "format": { + "type": "null" + } + } + } + } + ], "description": "Data contract of an input or output value (JSON-Schema subset).", "properties": { "default": { @@ -784,7 +831,7 @@ "description": "Pre-filled value when none is supplied, or null when the value must be provided." }, "enum": { - "description": "Allowed values when the value is a closed enumeration; empty when unconstrained.", + "description": "Allowed values when the value is a closed enumeration; empty when unconstrained. MUST be empty where the type is JSON.", "items": { "type": "string" }, @@ -806,7 +853,7 @@ } ], "default": null, - "description": "Semantic refinement of a STRING value: EMAIL, URI, ENDPOINT_URL, or CONNECTION_STRING; null when unrefined." + "description": "Semantic refinement of a STRING value: EMAIL, URI, ENDPOINT_URL, or CONNECTION_STRING; null when unrefined. MUST be null where the type is not STRING." }, "isSensitive": { "default": false, @@ -823,7 +870,7 @@ } ], "default": null, - "description": "Regular expression the value must match, or null when unconstrained." + "description": "Regular expression the value must match, or null when unconstrained. MUST be null where the type is JSON." }, "semanticType": { "anyOf": [ @@ -847,11 +894,12 @@ "description": "Backing-service compatibility tag the Blueprint publish gate matches when wiring this value into a consumer input; null when the value is not specific to a backing service." }, "type": { - "description": "Primitive shape of the value: STRING, INTEGER, or BOOLEAN.", + "description": "Shape of the value's string form: STRING is any text, NUMBER a JSON number, BOOLEAN true or false, JSON a JSON document of any kind.", "enum": [ "STRING", - "INTEGER", - "BOOLEAN" + "NUMBER", + "BOOLEAN", + "JSON" ], "type": "string" } diff --git a/specifications/component/v1/spec.md b/specifications/component/v1/spec.md index 601ca7b..20127a6 100644 --- a/specifications/component/v1/spec.md +++ b/specifications/component/v1/spec.md @@ -653,6 +653,73 @@ leaves nothing unresolved. carries the two diagnostics, because the connection is what joins the two ends and a component document sees only one of them. +### 6.3 Value schemas + +An input and an output carry the same `schema` block, and this section defines +it once. `type` is its only REQUIRED property. + +**A value is carried as text.** Whatever an input receives or an output +publishes reaches the workload as an environment variable, so `type` does not +name a host language's type — it names the shape the value's **string form** +takes. That is why `default` is a string, why `enum` is a list of strings, why a +`DECLARED` output's `value` is a string, and why `pattern` is a regular +expression over the same form. + +**`COMP-VAL-001`** — `type` MUST be one of four +members. Anything else is rejected in the `structural` phase with +`ERR_INVALID_VALUE`. + +| `type` | The string form is | `format` | `pattern` | `enum` | +|---|---|---|---|---| +| `STRING` | any text | permitted | permitted | permitted | +| `NUMBER` | a JSON number — `5432`, `-1`, `2.5` | MUST be null | permitted | permitted | +| `BOOLEAN` | `true` or `false`, in lower case | MUST be null | permitted | permitted | +| `JSON` | a JSON value of any kind — object, array, string, number, boolean or null | MUST be null | MUST be null | MUST be empty | + +**There is no integer member, and the omission is deliberate.** `NUMBER` is +JSON's own numeric kind and covers whole numbers and reals alike. An author who +needs whole numbers writes the constraint rather than reaching for a second +type: `type: NUMBER` with `pattern: '^-?[0-9]+$'`. A separate `INTEGER` would +have to answer whether `5432.0` is one, whether an integer output satisfies a +number input under [blueprint §4.2](../../blueprint/v1/spec.md#connections)'s +no-widening rule, and what a bound would mean on each — three answers bought for +a distinction the transport does not preserve. + +**`COMP-VAL-002`** — Where `type` is `JSON`, `pattern` +MUST be null and `enum` MUST be empty. Both are `structural`: a non-null +`pattern` is `ERR_INVALID_TYPE` and a non-empty `enum` is `ERR_INVALID_VALUE`. + +One JSON value has many spellings. `{"a":1}` and `{ "a" : 1 }` are the same +value, and so are `{"a":1,"b":2}` and `{"b":2,"a":1}`. A regular expression and +a string enumeration each decide membership on the spelling, so a rule written +with either would accept one author's formatter and reject another's. Permitting +them and leaving "equal" undefined is the worse option: two implementations +would then disagree about a valid document for a reason neither could see. + +**`COMP-VAL-003`** — Where `type` is not `STRING`, +`format` MUST be null. `structural`, `ERR_INVALID_TYPE`. + +Every `format` member — `EMAIL`, `URI`, `ENDPOINT_URL`, `CONNECTION_STRING` — +names a lexical convention for text. `format: EMAIL` on a `BOOLEAN` describes +nothing, and that it validates today is an accident of the two fields never +having been described together. This clause is about the Musher field `format`, +not the JSON Schema keyword; [§7.2](#format-policy) is that. + +**`semanticType` is not paired with `type`.** It tags the backing service a +value addresses rather than the shape the value takes, so nothing here restricts +which types may carry which tag — a `JSON` value describing a Postgres cluster +is as legitimately `POSTGRES` as a connection string is. That the pairing goes +unchecked in either direction is a gap rather than a considered permission. + +**What v1 does not check.** No phase tests a **value** against the shape its +`schema` declares. A `DECLARED` output may write `type: NUMBER` beside +`value: 'banana'`; a `default` may ignore the `pattern` and the `enum` written +beside it; a `JSON` value's `default` is not parsed. None of this is new with +`JSON` — it has been true of every member since v1 — and it is recorded here +rather than described aspirationally, because the table above is the first place +a reader could reasonably expect the check to be. Closing any of them rejects +documents that validate today. + ## 7. Validation layers Structural validation is one layer of four. An implementation MUST apply them From dcec8a5bb77a91feed90dca2be14addc9b94925d Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Wed, 19 Aug 2026 12:00:58 +0000 Subject: [PATCH 2/3] =?UTF-8?q?feat(blueprint)!:=20mirror=20the=20value=20?= =?UTF-8?q?vocabulary=20and=20cite=20component=20=C2=A76.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An authored parameter's `schema` is the same block a component input carries, but this family holds its own `$defs` for it — so the component-side change leaves blueprint parameters accepting a token the component module has withdrawn, and nothing in CI would say so. structural/024 exists because a fixture over there proves nothing about a document over here. The vocabulary itself is not restated. ADR 0003 §2's rule against mirroring a published vocabulary applies to a sibling spec.md as much as to an external surface, and §7 already uses that construction for diagnostics: §5.3 names the block component §6.3 defines and says this family enforces it on the same terms. §4.2's three worked examples named INTEGER, which no longer exists. Rewriting them also sharpens the argument they were making — "which string a formatter picks" is exactly the difference between `5432`, `5432.0` and `5.432e3`. A new paragraph says JSON is not a top type, because it is the member most likely to be read as one. A STRING output does not satisfy a JSON input, or the reverse: a consumer that will parse what it receives and one that will not are asking for different things, and the wire is the last place that difference is visible. Without it, the first author to try the wire reads the rejection as a bug. See ADR 0013. BREAKING CHANGE: `INTEGER` is withdrawn from a parameter's `schema.type`; use `NUMBER`. `pattern` MUST be null and `enum` MUST be empty where `type` is `JSON`, and `format` MUST be null where `type` is not `STRING`. ADR 0005 §1's pre-publication window applies: no `v` directory and no migration note, since no version has been tagged. Signed-off-by: Justin Merrell --- conformance/blueprint/v1/cases.json | 15 +++++ .../tree/acme-wiki/blueprint.yaml | 4 +- .../024-integer-parameter-type/case.yaml | 22 +++++++ .../diagnostics.json | 6 ++ .../024-integer-parameter-type/metadata.json | 8 +++ .../025-json-parameter-with-pattern/case.yaml | 22 +++++++ .../diagnostics.json | 6 ++ .../metadata.json | 8 +++ .../structural/026-json-parameter/case.yaml | 21 +++++++ .../026-json-parameter/metadata.json | 8 +++ .../v1/schemas/dist/blueprint.schema.json | 60 +++++++++++++++++-- .../v1/schemas/src/blueprint.schema.json | 60 +++++++++++++++++-- specifications/blueprint/v1/spec.md | 33 ++++++++-- 13 files changed, 253 insertions(+), 20 deletions(-) create mode 100644 conformance/blueprint/v1/structural/024-integer-parameter-type/case.yaml create mode 100644 conformance/blueprint/v1/structural/024-integer-parameter-type/diagnostics.json create mode 100644 conformance/blueprint/v1/structural/024-integer-parameter-type/metadata.json create mode 100644 conformance/blueprint/v1/structural/025-json-parameter-with-pattern/case.yaml create mode 100644 conformance/blueprint/v1/structural/025-json-parameter-with-pattern/diagnostics.json create mode 100644 conformance/blueprint/v1/structural/025-json-parameter-with-pattern/metadata.json create mode 100644 conformance/blueprint/v1/structural/026-json-parameter/case.yaml create mode 100644 conformance/blueprint/v1/structural/026-json-parameter/metadata.json diff --git a/conformance/blueprint/v1/cases.json b/conformance/blueprint/v1/cases.json index 995b806..650580d 100644 --- a/conformance/blueprint/v1/cases.json +++ b/conformance/blueprint/v1/cases.json @@ -137,6 +137,21 @@ "phase": "structural", "path": "structural/023-generated-parameter-marked-insensitive" }, + { + "id": "structural-024-integer-parameter-type", + "phase": "structural", + "path": "structural/024-integer-parameter-type" + }, + { + "id": "structural-025-json-parameter-with-pattern", + "phase": "structural", + "path": "structural/025-json-parameter-with-pattern" + }, + { + "id": "structural-026-json-parameter", + "phase": "structural", + "path": "structural/026-json-parameter" + }, { "id": "semantic-001-connection-names-unknown-role", "phase": "semantic", diff --git a/conformance/blueprint/v1/semantic/019-parameter-type-disagrees/tree/acme-wiki/blueprint.yaml b/conformance/blueprint/v1/semantic/019-parameter-type-disagrees/tree/acme-wiki/blueprint.yaml index cbe8d4a..2244005 100644 --- a/conformance/blueprint/v1/semantic/019-parameter-type-disagrees/tree/acme-wiki/blueprint.yaml +++ b/conformance/blueprint/v1/semantic/019-parameter-type-disagrees/tree/acme-wiki/blueprint.yaml @@ -1,4 +1,4 @@ -# The parameter asks for an INTEGER; the input it covers declares a STRING. +# The parameter asks for a NUMBER; the input it covers declares a STRING. # The deploying user is validated against the parameter's schema and the # component then receives the result against its own, so nothing fails until the # workload starts. @@ -20,7 +20,7 @@ spec: parameters: adminPassword: schema: - type: INTEGER + type: NUMBER isSensitive: true isRequired: true ui: diff --git a/conformance/blueprint/v1/structural/024-integer-parameter-type/case.yaml b/conformance/blueprint/v1/structural/024-integer-parameter-type/case.yaml new file mode 100644 index 0000000..42ce15a --- /dev/null +++ b/conformance/blueprint/v1/structural/024-integer-parameter-type/case.yaml @@ -0,0 +1,22 @@ +# The component half of this rule is component structural/039. This family +# carries its own parameter value schema, so a fixture over there proves nothing +# about a blueprint — the two would drift apart silently and CI would stay +# green. +specVersion: v1 +kind: BLUEPRINT +metadata: + slug: acme-wiki + version: 1 +spec: + components: + db: + component: ./components/postgres.yaml + size: general.standard.small + connections: {} + parameters: + replicaCount: + schema: + type: INTEGER + isRequired: true + ui: + label: Replica count diff --git a/conformance/blueprint/v1/structural/024-integer-parameter-type/diagnostics.json b/conformance/blueprint/v1/structural/024-integer-parameter-type/diagnostics.json new file mode 100644 index 0000000..12cef02 --- /dev/null +++ b/conformance/blueprint/v1/structural/024-integer-parameter-type/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_INVALID_VALUE", + "path": "/spec/parameters/replicaCount/schema/type" + } +] diff --git a/conformance/blueprint/v1/structural/024-integer-parameter-type/metadata.json b/conformance/blueprint/v1/structural/024-integer-parameter-type/metadata.json new file mode 100644 index 0000000..3302dbc --- /dev/null +++ b/conformance/blueprint/v1/structural/024-integer-parameter-type/metadata.json @@ -0,0 +1,8 @@ +{ + "id": "structural-024-integer-parameter-type", + "phase": "structural", + "expected": "fail", + "clause": "specifications/blueprint/v1/spec.md#authored-parameters", + "requirements": ["COMP-VAL-001"], + "summary": "An authored parameter declaring the withdrawn INTEGER shape is rejected." +} diff --git a/conformance/blueprint/v1/structural/025-json-parameter-with-pattern/case.yaml b/conformance/blueprint/v1/structural/025-json-parameter-with-pattern/case.yaml new file mode 100644 index 0000000..5fbede2 --- /dev/null +++ b/conformance/blueprint/v1/structural/025-json-parameter-with-pattern/case.yaml @@ -0,0 +1,22 @@ +# The JSON restrictions belong to component §6.3 and this family enforces them +# on the same terms. Fixtured here because the enforcement is a separate schema +# module, not because the rule is a separate rule. +specVersion: v1 +kind: BLUEPRINT +metadata: + slug: acme-wiki + version: 1 +spec: + components: + db: + component: ./components/postgres.yaml + size: general.standard.small + connections: {} + parameters: + settings: + schema: + type: JSON + pattern: '^\{.*\}$' + isRequired: false + ui: + label: Advanced settings diff --git a/conformance/blueprint/v1/structural/025-json-parameter-with-pattern/diagnostics.json b/conformance/blueprint/v1/structural/025-json-parameter-with-pattern/diagnostics.json new file mode 100644 index 0000000..bc7bd18 --- /dev/null +++ b/conformance/blueprint/v1/structural/025-json-parameter-with-pattern/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_INVALID_TYPE", + "path": "/spec/parameters/settings/schema/pattern" + } +] diff --git a/conformance/blueprint/v1/structural/025-json-parameter-with-pattern/metadata.json b/conformance/blueprint/v1/structural/025-json-parameter-with-pattern/metadata.json new file mode 100644 index 0000000..0041493 --- /dev/null +++ b/conformance/blueprint/v1/structural/025-json-parameter-with-pattern/metadata.json @@ -0,0 +1,8 @@ +{ + "id": "structural-025-json-parameter-with-pattern", + "phase": "structural", + "expected": "fail", + "clause": "specifications/blueprint/v1/spec.md#authored-parameters", + "requirements": ["COMP-VAL-002"], + "summary": "A regular expression on a JSON parameter is rejected." +} diff --git a/conformance/blueprint/v1/structural/026-json-parameter/case.yaml b/conformance/blueprint/v1/structural/026-json-parameter/case.yaml new file mode 100644 index 0000000..621733d --- /dev/null +++ b/conformance/blueprint/v1/structural/026-json-parameter/case.yaml @@ -0,0 +1,21 @@ +# The positive verdict for the same module, so both branches of the conditional +# are exercised in this family and not only in the component one. +specVersion: v1 +kind: BLUEPRINT +metadata: + slug: acme-wiki + version: 1 +spec: + components: + db: + component: ./components/postgres.yaml + size: general.standard.small + connections: {} + parameters: + settings: + schema: + type: JSON + default: '{"logLevel":"info"}' + isRequired: false + ui: + label: Advanced settings diff --git a/conformance/blueprint/v1/structural/026-json-parameter/metadata.json b/conformance/blueprint/v1/structural/026-json-parameter/metadata.json new file mode 100644 index 0000000..e88159f --- /dev/null +++ b/conformance/blueprint/v1/structural/026-json-parameter/metadata.json @@ -0,0 +1,8 @@ +{ + "id": "structural-026-json-parameter", + "phase": "structural", + "expected": "pass", + "clause": "specifications/blueprint/v1/spec.md#authored-parameters", + "requirements": ["COMP-VAL-001"], + "summary": "An authored parameter may take a JSON value." +} diff --git a/specifications/blueprint/v1/schemas/dist/blueprint.schema.json b/specifications/blueprint/v1/schemas/dist/blueprint.schema.json index 7402cca..51935f1 100644 --- a/specifications/blueprint/v1/schemas/dist/blueprint.schema.json +++ b/specifications/blueprint/v1/schemas/dist/blueprint.schema.json @@ -403,6 +403,53 @@ "BlueprintParameterValueSchema": { "description": "Data contract of an install-form parameter value (JSON-Schema subset).", "additionalProperties": false, + "allOf": [ + { + "$comment": "spec.md §5.3, the block component §6.3 defines. A JSON value's string form is a JSON document, and one value has many spellings — insignificant whitespace, member order, number formatting. `pattern` and `enum` both decide membership on the spelling, so neither can state a rule about the value.", + "if": { + "properties": { + "type": { + "const": "JSON" + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "enum": { + "maxItems": 0 + }, + "pattern": { + "type": "null" + } + } + } + }, + { + "$comment": "spec.md §5.3, the block component §6.3 defines. Every `format` member names a lexical convention for text, so the field only refines a STRING. `type` is REQUIRED, so this is a `not` inside the `if` rather than an `else`: a document omitting `type` already reports ERR_MISSING_FIELD and should not also collect a format diagnostic.", + "if": { + "properties": { + "type": { + "not": { + "const": "STRING" + } + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "format": { + "type": "null" + } + } + } + } + ], "properties": { "default": { "description": "Pre-filled value, or null when the deploying user must supply one.", @@ -417,14 +464,14 @@ "default": null }, "enum": { - "description": "Allowed values when the value is a closed enumeration; empty when unconstrained.", + "description": "Allowed values when the value is a closed enumeration; empty when unconstrained. MUST be empty where the type is JSON.", "items": { "type": "string" }, "type": "array" }, "format": { - "description": "Semantic refinement of a STRING value: EMAIL, URI, ENDPOINT_URL, or CONNECTION_STRING; null when unrefined.", + "description": "Semantic refinement of a STRING value: EMAIL, URI, ENDPOINT_URL, or CONNECTION_STRING; null when unrefined. MUST be null where the type is not STRING.", "anyOf": [ { "enum": [ @@ -447,7 +494,7 @@ "type": "boolean" }, "pattern": { - "description": "Regular expression the supplied value must match, or null when unconstrained.", + "description": "Regular expression the supplied value must match, or null when unconstrained. MUST be null where the type is JSON.", "anyOf": [ { "type": "string" @@ -459,11 +506,12 @@ "default": null }, "type": { - "description": "Primitive shape of the value: STRING, INTEGER, or BOOLEAN.", + "description": "Shape of the value's string form: STRING is any text, NUMBER a JSON number, BOOLEAN true or false, JSON a JSON document of any kind.", "enum": [ "STRING", - "INTEGER", - "BOOLEAN" + "NUMBER", + "BOOLEAN", + "JSON" ], "type": "string" } diff --git a/specifications/blueprint/v1/schemas/src/blueprint.schema.json b/specifications/blueprint/v1/schemas/src/blueprint.schema.json index a3c0911..ad04095 100644 --- a/specifications/blueprint/v1/schemas/src/blueprint.schema.json +++ b/specifications/blueprint/v1/schemas/src/blueprint.schema.json @@ -402,6 +402,53 @@ }, "BlueprintParameterValueSchema": { "additionalProperties": false, + "allOf": [ + { + "$comment": "spec.md §5.3, the block component §6.3 defines. A JSON value's string form is a JSON document, and one value has many spellings — insignificant whitespace, member order, number formatting. `pattern` and `enum` both decide membership on the spelling, so neither can state a rule about the value.", + "if": { + "properties": { + "type": { + "const": "JSON" + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "enum": { + "maxItems": 0 + }, + "pattern": { + "type": "null" + } + } + } + }, + { + "$comment": "spec.md §5.3, the block component §6.3 defines. Every `format` member names a lexical convention for text, so the field only refines a STRING. `type` is REQUIRED, so this is a `not` inside the `if` rather than an `else`: a document omitting `type` already reports ERR_MISSING_FIELD and should not also collect a format diagnostic.", + "if": { + "properties": { + "type": { + "not": { + "const": "STRING" + } + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "format": { + "type": "null" + } + } + } + } + ], "description": "Data contract of an install-form parameter value (JSON-Schema subset).", "properties": { "default": { @@ -417,7 +464,7 @@ "description": "Pre-filled value, or null when the deploying user must supply one." }, "enum": { - "description": "Allowed values when the value is a closed enumeration; empty when unconstrained.", + "description": "Allowed values when the value is a closed enumeration; empty when unconstrained. MUST be empty where the type is JSON.", "items": { "type": "string" }, @@ -439,7 +486,7 @@ } ], "default": null, - "description": "Semantic refinement of a STRING value: EMAIL, URI, ENDPOINT_URL, or CONNECTION_STRING; null when unrefined." + "description": "Semantic refinement of a STRING value: EMAIL, URI, ENDPOINT_URL, or CONNECTION_STRING; null when unrefined. MUST be null where the type is not STRING." }, "isSensitive": { "default": false, @@ -456,14 +503,15 @@ } ], "default": null, - "description": "Regular expression the supplied value must match, or null when unconstrained." + "description": "Regular expression the supplied value must match, or null when unconstrained. MUST be null where the type is JSON." }, "type": { - "description": "Primitive shape of the value: STRING, INTEGER, or BOOLEAN.", + "description": "Shape of the value's string form: STRING is any text, NUMBER a JSON number, BOOLEAN true or false, JSON a JSON document of any kind.", "enum": [ "STRING", - "INTEGER", - "BOOLEAN" + "NUMBER", + "BOOLEAN", + "JSON" ], "type": "string" } diff --git a/specifications/blueprint/v1/spec.md b/specifications/blueprint/v1/spec.md index 7ebaafa..d279430 100644 --- a/specifications/blueprint/v1/spec.md +++ b/specifications/blueprint/v1/spec.md @@ -277,7 +277,7 @@ the value, with nothing saying which arrives. That silence is a gap rather than a considered permission; closing it rejects compositions that validate today. **The two ends MUST fit.** Resolving both ends establishes only that they -exist. A `STRING` output wired into an `INTEGER` input satisfies every rule +exist. A `STRING` output wired into a `NUMBER` input satisfies every rule above, and fails at deploy time inside the consuming workload — the failure shape [§5.2](#merge) rejected for input merging, on the grounds that it lands "a long way from the two documents that disagreed and with nothing pointing @@ -291,12 +291,22 @@ are `semantic`, both need the referenced component documents, and both anchor at the connection's `fromOutput`. **`type` MUST be equal.** A mismatch is `ERR_INCOMPATIBLE_TYPE`. No widening is -permitted, in either direction. An `INTEGER` output feeding a `STRING` input +permitted, in either direction. A `NUMBER` output feeding a `STRING` input looks harmless — everything is a string by the time it reaches a container — but *which* string is a decision each language's formatter makes differently, -and a contract that permitted the wire would be promising a value it cannot -describe. An author who wants the conversion writes an output that already has -the type the consumer asked for. +and `5432`, `5432.0` and `5.432e3` are one value with three spellings. A +contract that permitted the wire would be promising a value it cannot describe. +An author who wants the conversion writes an output that already has the type +the consumer asked for. + +**`JSON` is not an exception, and it is the member most likely to look like +one.** It names a value whose string form is a JSON document +([component §6.3](../../component/v1/spec.md#value-schema)), not a supertype of +the other three. A `STRING` output does not satisfy a `JSON` input, and a +`JSON` output does not satisfy a `STRING` one — in either direction and for +the same reason: a consumer that will parse what it receives and one that will +not are asking for different things, and the wire is the last place that +difference is visible. **`semanticType` MUST agree where the consumer names one.** A consumer declaring `null` accepts any producer: it has said the value is not specific to @@ -612,7 +622,7 @@ equal that input's, and a mismatch is `ERR_INCOMPATIBLE_PARAMETER_TYPE`, anchored at `/spec/parameters//schema/type`. The deploying user is validated against the parameter's schema and the component then receives the result against its own: a `STRING` accepted at the form where the workload -expects an `INTEGER` is the failure [§4.2](#connections) rejected for +expects a `NUMBER` is the failure [§4.2](#connections) rejected for connections and [§5.2](#merge) rejected for merging, arriving through a third door. @@ -628,6 +638,17 @@ already. Without the same rule here, moving a generated secret onto the override path is enough to lose it — and `isSensitive` defaults to `false`, so losing it takes no more than not mentioning it. +**A parameter's `schema` is the block +[component §6.3](../../component/v1/spec.md#value-schema) defines**, minus +`semanticType`. The `type` vocabulary, the `JSON` restrictions on `pattern` +and `enum`, and the `STRING` restriction on `format` are that section's and +are not restated here; this family's schema enforces them on the same terms and +in the same phase. Naming where a vocabulary is published rather than mirroring +it is the rule +[ADR 0003](../../../docs/adr/0003-controlled-vocabulary-placement.md) §2 sets +out, and a sibling `spec.md` is no more exempt from it than an external +surface. + **What v1 does not compare.** `format`, `enum`, `pattern`, `default`, `isSensitive` and `ui` take no part in whether a parameter covers an input. A parameter whose `pattern` admits more than the input's does is accepted, and so From ee3c19d0d20f71490d9519a856adb8d1bbd4c02a Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Wed, 19 Aug 2026 12:02:04 +0000 Subject: [PATCH 3/3] docs(docs): record the value-shape vocabulary decision in ADR 0013 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #54 arrived asking for a two-file rename and citing a §10 that does not exist in this repository. What it had found was a real divergence with the direction reversed: the platform had adopted JSON's own value kinds, and this contract still carried INTEGER — enforced by an enum whose membership was stated nowhere but an informative `description` field. The ADR decides the six questions that answers, each on its own terms so a maintainer can decline one without unpicking the rest, and records the two it declines to answer — a default is not checked against the constraints beside it, and `semanticType` is not paired with `type`. It also records why the timing is not incidental. Adding a member stays free forever; withdrawing one is free only while no family has been tagged, and ADR 0005 §1's window closes at the first tag. Signed-off-by: Justin Merrell --- docs/adr/0013-value-shape-vocabulary.md | 188 ++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 docs/adr/0013-value-shape-vocabulary.md diff --git a/docs/adr/0013-value-shape-vocabulary.md b/docs/adr/0013-value-shape-vocabulary.md new file mode 100644 index 0000000..09afddb --- /dev/null +++ b/docs/adr/0013-value-shape-vocabulary.md @@ -0,0 +1,188 @@ +# ADR 0013: The value-shape vocabulary, and what `JSON` means in it + +- **Status:** Accepted +- **Date:** 2026-08-19 +- **Extends:** [ADR 0003](0003-controlled-vocabulary-placement.md) §1 +- **Relies on:** [ADR 0005](0005-platform-divergence-reconciliation.md) §1 + +## Context + +An input's `schema.type` and a blueprint parameter's `schema.type` name the +**shape** of a configuration value. Until this ADR the vocabulary was `STRING`, +`INTEGER`, `BOOLEAN`. Three things about that are worth stating together, +because no one of them would have been worth a decision on its own. + +**The vocabulary was published nowhere.** Neither `spec.md` enumerated the +members. Component §6.1 and §6.2 discussed `schema` and `type` at length without +ever saying what `type` may contain, while every neighbouring vocabulary — +`suppliedBy`, `platformDefault.source`, `valueFrom` — got a prose table. The only +statement of membership anywhere was a schema `description` string, and +[CONTRIBUTING](../../.github/CONTRIBUTING.md) ground rule 1 makes `description` +fields *informative*. So the meaning of `type` was defined in a field this +repository declares non-normative. That is survivable for three self-evident +primitives and stops being survivable the moment a member arrives whose meaning +is not self-evident. + +**The platform diverged.** `musher-dev/platform` PR #1997 adopted `STRING`, +`NUMBER`, `BOOLEAN`, `JSON` — JSON's own value kinds — and filed +[issue #54](https://github.com/musher-dev/spec/issues/54) against this +repository asking for two conformance fixtures to be renamed. The issue had the +divergence inverted: it cited a "§10" defining the new vocabulary, and no such +section exists here. Applying the rename as filed would have broken this +repository's CI in exactly the two ways the issue described, mirrored — a `pass` +fixture failing structurally on a token the schema does not admit, and a +`semantic` mismatch fixture dying before the rule it exists to pin. + +This is the first schema-shaped divergence since +[ADR 0010](0010-runtime-divergence-reconciliation.md) recorded that the two +repositories' bundles were "in lockstep", and unlike ADR 0010's seven it **is** +visible in a diff. That is what makes it cheap to settle. + +### Why now + +`git tag -l` is empty, `published.json` records `"releases": {}`, and the three +`1.0.0` release pull requests are still open, so +[ADR 0005](0005-platform-divergence-reconciliation.md) §1 applies. `check:compat` +replays each release's documents out of that release's own tag; with no tags it +has nothing to replay, and that emptiness is the mechanical form of the same +fact. + +Withdrawing an enum member rejects documents that validate today. Under the +window that costs maintainer approval and a breaking declaration in the commit +trailer — not a `v2` directory and not a migration note. **Adding members stays +free forever; withdrawing one is free now and never again.** That asymmetry, and +not the platform's timetable, is why this is decided in this pass. + +## Decision + +### 1. `INTEGER` is withdrawn, and `NUMBER` replaces it + +`NUMBER` is JSON's own numeric kind and covers whole numbers and reals alike. An +author who needs whole numbers writes the constraint rather than reaching for a +second type: `type: NUMBER` with `pattern: '^-?[0-9]+$'`. + +**Rejected:** keeping both, which was the cheaper change — purely additive, and +therefore not a breaking one. It was rejected on what it would have obliged the +contract to answer. Is `5432.0` an `INTEGER`? Does an `INTEGER` output satisfy a +`NUMBER` input, given that [blueprint §4.2](../../specifications/blueprint/v1/spec.md#connections) +permits no widening in either direction? What would a bound mean on each? Three +answers bought for a distinction the transport does not preserve — and under +that no-widening rule, two near-synonymous numeric shapes make independently +authored components reject each other over a difference no consumer can observe. + +What is given up is real and is worth naming: integrality is no longer +expressible as a shape. The value schema has never carried `minimum` or +`maximum`, so `pattern` was already the only quantitative lever the contract +had, and `type: NUMBER` with a digit pattern says strictly more about a port +than `type: INTEGER` ever did. + +### 2. `JSON` is added, and it names a string form rather than a host type + +A value reaches the workload as text. `default` is a string, `enum` is a list of +strings, a `DECLARED` output's `value` is a string, and `pattern` is a regular +expression over that same form. `type` therefore names the shape the value's +**string form** takes, and a `JSON` value's string form is a JSON document. + +Read that way, `JSON` needs no change to any sibling field: a default is written +`default: '{"logLevel":"info"}'`, quoted, and the +[YAML profile](../../specifications/component/v1/spec.md#yaml-profile) makes the +unquoted spelling self-diagnosing — it parses as a flow mapping and fails +`type: string`. + +**Rejected:** widening `default` to accept arbitrary JSON. It would make one +field mean two things depending on a sibling, and it would break the schema +canonicalisation `ERR_CONFLICTING_INPUT_SCHEMA` compares blocks with. + +### 3. `pattern` and `enum` are forbidden on a `JSON` value + +One JSON value has many spellings. `{"a":1}` and `{ "a" : 1 }` are the same +value, and so are `{"a":1,"b":2}` and `{"b":2,"a":1}`. Both keywords decide +membership on the spelling, so a rule written with either would accept one +author's formatter and reject another's. + +**Rejected:** permitting them under a defined JSON equality. Deciding whether +equality is byte-wise, parsed, or key-order-sensitive is a new normative surface, +bought for a rare case, and the failure it would leave behind is the one this +contract refuses everywhere else — two implementations disagreeing about a valid +document for a reason neither could see. + +### 4. `format` is confined to `STRING` + +Every `format` member — `EMAIL`, `URI`, `ENDPOINT_URL`, `CONNECTION_STRING` — +names a lexical convention for text. The field's own `description` has always +said so and nothing enforced it, so `format: EMAIL` on a `BOOLEAN` validates +today and describes nothing. + +This is stated as a decision of its own rather than folded into §2, because it +is the one narrowing here that `JSON` did not cause: it was reachable before +this ADR and would have stayed reachable after it. It is taken now because the +window closes once, and because a member table that lists which shapes accept +`format` is the first place a reader will assume the restriction is real. + +**`semanticType` is deliberately not paired with `type`.** It tags the backing +service a value addresses rather than the shape the value takes, so a `JSON` +value describing a Postgres cluster is as legitimately `POSTGRES` as a +connection string is. Restricting it would have foreclosed that. + +### 5. A `default` is not checked against the constraints beside it + +For any member. A `DECLARED` output may write `type: NUMBER` beside +`value: 'banana'`; a `default` may ignore the `pattern` written next to it; a +`JSON` default is not parsed. + +**Rejected:** a `semantic` rule requiring a `JSON` default to parse. +`ERR_INVALID_VALUE` is registered `structural`, and the corpus gate refuses a +`semantic` case declaring a `structural` code — so the rule would have cost a new diagnostic +code, a registry row, and an implementation, and it would have made the newest +member the only one whose `default` is validated. Also rejected: +`contentMediaType`, for the reason +[component §7.2](../../specifications/component/v1/spec.md#format-policy) +already gives about annotations that assert nothing. + +The silence is recorded in §6.3 rather than closed. Closing it rejects documents +that validate today, and this ADR spends the window on the vocabulary rather +than on a check no member has ever had. + +### 6. The vocabulary is published in component §6.3, and blueprint cites it + +The `schema` block is shared by inputs and outputs, so it gets a subsection of +its own rather than a home inside §6.1. Blueprint carries its own `$defs` and +enforces the same rules, but does not restate the members: +[ADR 0003](0003-controlled-vocabulary-placement.md) §2's rule against mirroring a +published vocabulary applies to a sibling `spec.md` as much as to an external +surface, and blueprint §7 already uses that construction for diagnostics. + +## Consequences + +**Documents are rejected that validate today**, in two ways. Any document using +`type: INTEGER` — five conformance fixtures did, and they are migrated with this +change. And any pairing `format` with a non-`STRING` type, which no fixture and +no example carried, so §4 cost nothing to adopt and would have cost a major +version to adopt later. + +**The window is spent further.** After the first tag, adding a fifth member is a +minor release and withdrawing one is a `v2` directory. + +**Blueprint §4.2 gains a member that looks like a top type and is not.** A +`STRING` output does not satisfy a `JSON` input, or the reverse. §4.2 now says so +outright, because the first author to try it would otherwise read the rejection +as a bug. + +**Nothing in `tools/` changes.** The runner compares `schema.type` by string +equality and maps Ajv keywords to diagnostic codes generically, so the +vocabulary was never encoded there. That is the property that let this change +cost nine fixtures and no new code, no registry row, and no `UNCOVERED` entry. + +**The vocabulary stops being defined in an informative field.** Three +requirement IDs — `COMP-VAL-001`, `COMP-VAL-002`, `COMP-VAL-003` — now name +rules a document can violate, and each is pinned by a fixture. + +## Follow-ups + +1. Confirm `musher-dev/platform` adopts §3 and §4. Its PR #1997 carries the + vocabulary but not the `pattern`, `enum` and `format` restrictions, so this is + the one half of the change where this repository is ahead rather than behind. +2. `minimum` and `maximum` on a value schema are a separate design surface — + whether a bound reads the string or the parsed number, how it composes with + `pattern`, what it means on a `STRING`. Additive, so it stays available after + the window closes, which is why it is not decided here.