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/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/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/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/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. 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/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 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