From 11f5759e8bde78fd4be92d7c3900ff3bb07de8e5 Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Tue, 11 Aug 2026 02:00:07 +0000 Subject: [PATCH 1/6] feat(component): permit PUBLIC on every endpoint protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §5.2 restricted a PUBLIC endpoint to the HTTP family on two arguments, and the edge-port address form answers both. A PUBLIC HTTP/HTTPS/WS/GRPC endpoint publishes a URL; a PUBLIC TCP or UDP endpoint publishes a host:port address allocated at the edge. Exposing a database, a broker or a game server is a capability that ships today and that a conformant document could not express. Three rules follow the split rather than the protocol: - The schema conditional restricting PUBLIC to four protocols is removed. - §5.4's readiness requirement narrows to a SERVICE exposing at least one PUBLIC endpoint in the HTTP family. The rationale §5.4 already gives for exempting PRIVATE — a mesh consumer retries, a browser does not — covers an L4 consumer unchanged, and compelling a probe against a port that speaks no HTTP is a rule no document can satisfy. - A probe's endpoint and a platform default's endpoint MUST resolve to an HTTP-family endpoint, named or elected as primary. A probe polls an HTTP path, and PUBLIC_URL and PUBLIC_HOSTNAME both derive from a URL. New code ERR_ENDPOINT_NOT_HTTP, semantic. structural-027-public-tcp-endpoint inverts to a pass case, and drops its readiness probe: the probe is no longer compelled, and keeping it would now fail the semantic phase instead. structural-028-public-grpc-endpoint stands. Whether the contract should expose the edge address is left undecided and recorded as such in §5.2 rather than left to be inferred. Closes #28 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Justin Merrell --- conformance/component/v1/cases.json | 10 +++ .../008-probe-on-tcp-endpoint/case.yaml | 22 ++++++ .../diagnostics.json | 6 ++ .../008-probe-on-tcp-endpoint/metadata.json | 7 ++ .../case.yaml | 29 ++++++++ .../diagnostics.json | 6 ++ .../metadata.json | 7 ++ .../027-public-tcp-endpoint/case.yaml | 12 ++-- .../027-public-tcp-endpoint/diagnostics.json | 6 -- .../027-public-tcp-endpoint/metadata.json | 4 +- .../component/v1/examples/multi-endpoint.yaml | 13 +++- .../v1/schemas/dist/component.schema.json | 50 +++++--------- .../v1/schemas/src/component.schema.json | 50 +++++--------- specifications/component/v1/spec.md | 68 ++++++++++++++----- tools/src/semantic.ts | 45 +++++++++--- 15 files changed, 225 insertions(+), 110 deletions(-) create mode 100644 conformance/component/v1/semantic/008-probe-on-tcp-endpoint/case.yaml create mode 100644 conformance/component/v1/semantic/008-probe-on-tcp-endpoint/diagnostics.json create mode 100644 conformance/component/v1/semantic/008-probe-on-tcp-endpoint/metadata.json create mode 100644 conformance/component/v1/semantic/009-platform-default-on-tcp-endpoint/case.yaml create mode 100644 conformance/component/v1/semantic/009-platform-default-on-tcp-endpoint/diagnostics.json create mode 100644 conformance/component/v1/semantic/009-platform-default-on-tcp-endpoint/metadata.json delete mode 100644 conformance/component/v1/structural/027-public-tcp-endpoint/diagnostics.json diff --git a/conformance/component/v1/cases.json b/conformance/component/v1/cases.json index e52f0bb..051c687 100644 --- a/conformance/component/v1/cases.json +++ b/conformance/component/v1/cases.json @@ -206,6 +206,16 @@ "id": "semantic-007-probe-on-endpointless-workload", "phase": "semantic", "path": "semantic/007-probe-on-endpointless-workload" + }, + { + "id": "semantic-008-probe-on-tcp-endpoint", + "phase": "semantic", + "path": "semantic/008-probe-on-tcp-endpoint" + }, + { + "id": "semantic-009-platform-default-on-tcp-endpoint", + "phase": "semantic", + "path": "semantic/009-platform-default-on-tcp-endpoint" } ] } diff --git a/conformance/component/v1/semantic/008-probe-on-tcp-endpoint/case.yaml b/conformance/component/v1/semantic/008-probe-on-tcp-endpoint/case.yaml new file mode 100644 index 0000000..5801390 --- /dev/null +++ b/conformance/component/v1/semantic/008-probe-on-tcp-endpoint/case.yaml @@ -0,0 +1,22 @@ +# Every probe polls an HTTP path, so an endpoint serving TCP has nothing for +# one to poll. The endpoint resolves and is PUBLIC — only its protocol makes +# the probe unsatisfiable. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: postgres:17.10-alpine + endpoints: + db: + containerPort: 5432 + protocol: TCP + visibility: PUBLIC + health: + readiness: + path: /healthz + endpoint: db diff --git a/conformance/component/v1/semantic/008-probe-on-tcp-endpoint/diagnostics.json b/conformance/component/v1/semantic/008-probe-on-tcp-endpoint/diagnostics.json new file mode 100644 index 0000000..f1e6435 --- /dev/null +++ b/conformance/component/v1/semantic/008-probe-on-tcp-endpoint/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_ENDPOINT_NOT_HTTP", + "path": "/spec/workload/health/readiness/endpoint" + } +] diff --git a/conformance/component/v1/semantic/008-probe-on-tcp-endpoint/metadata.json b/conformance/component/v1/semantic/008-probe-on-tcp-endpoint/metadata.json new file mode 100644 index 0000000..3200158 --- /dev/null +++ b/conformance/component/v1/semantic/008-probe-on-tcp-endpoint/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "semantic-008-probe-on-tcp-endpoint", + "phase": "semantic", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#health", + "summary": "A probe naming an endpoint outside the HTTP family is rejected." +} diff --git a/conformance/component/v1/semantic/009-platform-default-on-tcp-endpoint/case.yaml b/conformance/component/v1/semantic/009-platform-default-on-tcp-endpoint/case.yaml new file mode 100644 index 0000000..063aabb --- /dev/null +++ b/conformance/component/v1/semantic/009-platform-default-on-tcp-endpoint/case.yaml @@ -0,0 +1,29 @@ +# PUBLIC_URL takes its value from a URL, and PUBLIC_HOSTNAME from the host part +# of one. A PUBLIC TCP endpoint publishes a host:port edge address instead, so +# it has neither to give — the endpoint is declared and PUBLIC, and still +# cannot answer. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: postgres:17.10-alpine + endpoints: + db: + containerPort: 5432 + protocol: TCP + visibility: PUBLIC + contract: + inputs: + databaseUrl: + schema: + type: STRING + ui: + label: Database URL + platformDefault: + source: PUBLIC_URL + endpoint: db diff --git a/conformance/component/v1/semantic/009-platform-default-on-tcp-endpoint/diagnostics.json b/conformance/component/v1/semantic/009-platform-default-on-tcp-endpoint/diagnostics.json new file mode 100644 index 0000000..fceb7b9 --- /dev/null +++ b/conformance/component/v1/semantic/009-platform-default-on-tcp-endpoint/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_ENDPOINT_NOT_HTTP", + "path": "/spec/contract/inputs/databaseUrl/platformDefault/endpoint" + } +] diff --git a/conformance/component/v1/semantic/009-platform-default-on-tcp-endpoint/metadata.json b/conformance/component/v1/semantic/009-platform-default-on-tcp-endpoint/metadata.json new file mode 100644 index 0000000..8a38fb1 --- /dev/null +++ b/conformance/component/v1/semantic/009-platform-default-on-tcp-endpoint/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "semantic-009-platform-default-on-tcp-endpoint", + "phase": "semantic", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#inputs", + "summary": "A platform default naming an endpoint outside the HTTP family is rejected." +} diff --git a/conformance/component/v1/structural/027-public-tcp-endpoint/case.yaml b/conformance/component/v1/structural/027-public-tcp-endpoint/case.yaml index 9526c5e..362d37e 100644 --- a/conformance/component/v1/structural/027-public-tcp-endpoint/case.yaml +++ b/conformance/component/v1/structural/027-public-tcp-endpoint/case.yaml @@ -1,6 +1,8 @@ -# A PUBLIC endpoint is published at a URL by something that speaks the HTTP -# family, and TCP has no URL to publish. Readiness is present so the case -# fails on the protocol rule alone. +# A PUBLIC TCP endpoint publishes a host:port edge address rather than a URL, +# which is a working capability — an exposed database, broker or game server. +# No readiness probe accompanies it: §5.4 compels one only for a PUBLIC +# endpoint in the HTTP family, because an L4 consumer retries and a browser +# does not. specVersion: v1 kind: COMPONENT metadata: @@ -16,7 +18,3 @@ spec: containerPort: 5432 protocol: TCP visibility: PUBLIC - health: - readiness: - path: /healthz - endpoint: db diff --git a/conformance/component/v1/structural/027-public-tcp-endpoint/diagnostics.json b/conformance/component/v1/structural/027-public-tcp-endpoint/diagnostics.json deleted file mode 100644 index a531e51..0000000 --- a/conformance/component/v1/structural/027-public-tcp-endpoint/diagnostics.json +++ /dev/null @@ -1,6 +0,0 @@ -[ - { - "code": "ERR_INVALID_VALUE", - "path": "/spec/workload/endpoints/db/protocol" - } -] diff --git a/conformance/component/v1/structural/027-public-tcp-endpoint/metadata.json b/conformance/component/v1/structural/027-public-tcp-endpoint/metadata.json index ee2a2ed..b2f0b1e 100644 --- a/conformance/component/v1/structural/027-public-tcp-endpoint/metadata.json +++ b/conformance/component/v1/structural/027-public-tcp-endpoint/metadata.json @@ -1,7 +1,7 @@ { "id": "structural-027-public-tcp-endpoint", "phase": "structural", - "expected": "fail", + "expected": "pass", "clause": "specifications/component/v1/spec.md#endpoints", - "summary": "A TCP endpoint may not be PUBLIC." + "summary": "A TCP endpoint may be PUBLIC, and on its own compels no readiness probe." } diff --git a/specifications/component/v1/examples/multi-endpoint.yaml b/specifications/component/v1/examples/multi-endpoint.yaml index 3671d22..4e00fee 100644 --- a/specifications/component/v1/examples/multi-endpoint.yaml +++ b/specifications/component/v1/examples/multi-endpoint.yaml @@ -1,8 +1,11 @@ # yaml-language-server: $schema=https://schemas.musher.dev/component/v1/component.schema.json # -# A service fronting two public URLs and one private port. Because more than -# one endpoint is PUBLIC, nothing here may say "the component's URL" — the -# readiness probe and the platform default each name the endpoint they mean. +# A service fronting two public URLs, one public TCP port, and one private +# port. Because more than one endpoint is PUBLIC, nothing here may say "the +# component's URL" — the readiness probe and the platform default each name the +# endpoint they mean, and both name one in the HTTP family. `wire` is the other +# address form: a PUBLIC TCP endpoint published at a host:port edge address, +# which compels no probe and which no platform default may derive from. specVersion: v1 kind: COMPONENT metadata: @@ -22,6 +25,10 @@ spec: containerPort: 8081 protocol: HTTP visibility: PUBLIC + wire: + containerPort: 5671 + protocol: TCP + visibility: PUBLIC metrics: containerPort: 9090 protocol: HTTP diff --git a/specifications/component/v1/schemas/dist/component.schema.json b/specifications/component/v1/schemas/dist/component.schema.json index 055178d..619b269 100644 --- a/specifications/component/v1/schemas/dist/component.schema.json +++ b/specifications/component/v1/schemas/dist/component.schema.json @@ -124,34 +124,8 @@ "type": "object" }, "ComponentEndpoint": { + "$comment": "spec.md §5.2. Every protocol may be PUBLIC. The two published address forms differ — an HTTP-family endpoint publishes a URL, a TCP or UDP endpoint an edge `host:port` address — and that difference is carried by the rules in §5.4 and §6.1 rather than by a constraint here.", "additionalProperties": false, - "allOf": [ - { - "$comment": "spec.md §5.2. A PUBLIC endpoint is published at a URL by something that speaks the HTTP family, so TCP and UDP have no URL to publish. A bare conditional carrying no `type` declares no properties of its own, so lint.ts checkClosedObjects correctly skips it.", - "if": { - "properties": { - "visibility": { - "const": "PUBLIC" - } - }, - "required": [ - "visibility" - ] - }, - "then": { - "properties": { - "protocol": { - "enum": [ - "HTTP", - "HTTPS", - "WS", - "GRPC" - ] - } - } - } - } - ], "properties": { "containerPort": { "description": "Port the workload listens on inside the container. Below 1024 needs a capability the runtime grants, so spec.md §5.2 discourages it without rejecting it.", @@ -1002,18 +976,30 @@ } }, { - "$comment": "spec.md §5.4: readiness is required for a SERVICE exposing at least one PUBLIC endpoint. Endpoints are a mapping, and no keyword asserts `some member matches` over one, so the condition is spelled as the negation of `every member fails to match`.", + "$comment": "spec.md §5.4: readiness is required for a SERVICE exposing at least one PUBLIC endpoint whose protocol is in the HTTP family. Endpoints are a mapping, and no keyword asserts `some member matches` over one, so the condition is spelled as the negation of `every member fails to match`. A PUBLIC TCP or UDP endpoint publishes a host:port address rather than a URL, and an L4 consumer retries where a browser does not, so it compels no probe.", "if": { "properties": { "endpoints": { "not": { "additionalProperties": { - "properties": { - "visibility": { - "not": { + "not": { + "properties": { + "protocol": { + "enum": [ + "HTTP", + "HTTPS", + "WS", + "GRPC" + ] + }, + "visibility": { "const": "PUBLIC" } - } + }, + "required": [ + "protocol", + "visibility" + ] } } } diff --git a/specifications/component/v1/schemas/src/component.schema.json b/specifications/component/v1/schemas/src/component.schema.json index 5f1df05..970f9a2 100644 --- a/specifications/component/v1/schemas/src/component.schema.json +++ b/specifications/component/v1/schemas/src/component.schema.json @@ -125,33 +125,7 @@ }, "ComponentEndpoint": { "additionalProperties": false, - "allOf": [ - { - "$comment": "spec.md §5.2. A PUBLIC endpoint is published at a URL by something that speaks the HTTP family, so TCP and UDP have no URL to publish. A bare conditional carrying no `type` declares no properties of its own, so lint.ts checkClosedObjects correctly skips it.", - "if": { - "properties": { - "visibility": { - "const": "PUBLIC" - } - }, - "required": [ - "visibility" - ] - }, - "then": { - "properties": { - "protocol": { - "enum": [ - "HTTP", - "HTTPS", - "WS", - "GRPC" - ] - } - } - } - } - ], + "$comment": "spec.md §5.2. Every protocol may be PUBLIC. The two published address forms differ — an HTTP-family endpoint publishes a URL, a TCP or UDP endpoint an edge `host:port` address — and that difference is carried by the rules in §5.4 and §6.1 rather than by a constraint here.", "properties": { "containerPort": { "description": "Port the workload listens on inside the container. Below 1024 needs a capability the runtime grants, so spec.md §5.2 discourages it without rejecting it.", @@ -1002,18 +976,30 @@ } }, { - "$comment": "spec.md §5.4: readiness is required for a SERVICE exposing at least one PUBLIC endpoint. Endpoints are a mapping, and no keyword asserts `some member matches` over one, so the condition is spelled as the negation of `every member fails to match`.", + "$comment": "spec.md §5.4: readiness is required for a SERVICE exposing at least one PUBLIC endpoint whose protocol is in the HTTP family. Endpoints are a mapping, and no keyword asserts `some member matches` over one, so the condition is spelled as the negation of `every member fails to match`. A PUBLIC TCP or UDP endpoint publishes a host:port address rather than a URL, and an L4 consumer retries where a browser does not, so it compels no probe.", "if": { "properties": { "endpoints": { "not": { "additionalProperties": { - "properties": { - "visibility": { - "not": { + "not": { + "properties": { + "protocol": { + "enum": [ + "HTTP", + "HTTPS", + "WS", + "GRPC" + ] + }, + "visibility": { "const": "PUBLIC" } - } + }, + "required": [ + "protocol", + "visibility" + ] } } } diff --git a/specifications/component/v1/spec.md b/specifications/component/v1/spec.md index cedab2c..e0349ec 100644 --- a/specifications/component/v1/spec.md +++ b/specifications/component/v1/spec.md @@ -286,20 +286,27 @@ document cannot see, and a rule that rejects on a fact it cannot check is guessing. Making it a MUST later rejects documents v1 accepts, and is therefore breaking. -**A `PUBLIC` endpoint is reached over HTTP.** `visibility: PUBLIC` publishes the -endpoint at an externally reachable URL, and what publishes it speaks the HTTP -family. `protocol` on a `PUBLIC` endpoint MUST therefore be one of `HTTP`, -`HTTPS`, `WS` or `GRPC`; a `TCP` or `UDP` endpoint MUST be `PRIVATE`. The rule -is `structural` and carries `ERR_INVALID_VALUE`. +**Every protocol may be `PUBLIC`, and the address form is what differs.** +`visibility: PUBLIC` publishes the endpoint at an externally reachable address. +Which kind of address depends on the protocol, and the distinction is what the +two rules below turn on: -[§5.4](#health) is the second argument for it. A probe polls an HTTP path, and -`readiness` is REQUIRED for a `SERVICE` exposing a `PUBLIC` endpoint — so a -`PUBLIC` `TCP` endpoint would compel a probe it has no way to express. +| `protocol` | What a `PUBLIC` endpoint publishes | +|---|---| +| `HTTP`, `HTTPS`, `WS`, `GRPC` | A **URL**, on a hostname derived from the endpoint name. | +| `TCP`, `UDP` | A **`host:port` address**, on a port allocated from the edge. | + +Nothing here is `structural`: a `PUBLIC` `TCP` endpoint is a database, a game +server, an MQTT broker or an SMTP relay exposed to the internet, and rejecting +it would make a working capability inexpressible. What the split does decide is +which references may name such an endpoint — [§5.4](#health) for a probe and +[§6.1](#inputs) for a platform default, both of which derive something only a +URL-published endpoint has. **A component MAY declare more than one `PUBLIC` endpoint**, and each one -publishes its own URL. A component fronting an API on one port and a console on -another is one component rather than two, and nothing about routing the second -is harder than routing the first. +publishes its own address. A component fronting an API on one port and a console +on another is one component rather than two, and nothing about routing the +second is harder than routing the first. The consequence is a rule and not a caveat: **anything naming a public address MUST name the endpoint it means.** Where two exist there is no such thing as @@ -338,6 +345,12 @@ should believe. That silence is a gap rather than a considered permission, and is recorded here so a reader can tell the two apart. Closing it rejects documents that validate today. +Nor does v1 give the contract any way to read the edge address of a `PUBLIC` +`TCP` or `UDP` endpoint. [§6.1](#inputs)'s two sources both derive from a URL, +and no third source is defined. That is a decision rather than a gap: the +address form exists, and whether the contract should expose it is a question +this version declines to answer rather than one it overlooked. + ### 5.3 Environment variables > **TODO** — Key grammar, the precedence order between literal, config-reference, @@ -366,12 +379,26 @@ Both are `semantic`, and the schema can express neither — the endpoint names a mapping keys elsewhere in the document, and JSON Schema cannot constrain a value against a sibling's keys. +**A probe MUST name an endpoint in the HTTP family.** Every probe polls an HTTP +path, so an endpoint whose `protocol` is `TCP` or `UDP` has nothing for one to +poll. A probe resolving to such an endpoint — by naming it, or by having the +primary election select it — is rejected in the `semantic` phase with +`ERR_ENDPOINT_NOT_HTTP`. The rule is `semantic` for the same reason as the two +above it. + **`readiness` is REQUIRED for a `SERVICE` exposing at least one `PUBLIC` -endpoint**, and OPTIONAL everywhere else, including on a `SERVICE` whose -endpoints are all `PRIVATE`. The rule is narrow on purpose. Without a readiness -gate a public URL routes to a replica that is running but not yet serving, and -the first request a user makes is the one that fails. A private consumer inside -the mesh retries; a browser does not. +endpoint whose `protocol` is `HTTP`, `HTTPS`, `WS` or `GRPC`**, and OPTIONAL +everywhere else — including on a `SERVICE` whose endpoints are all `PRIVATE`, +and on one whose only `PUBLIC` endpoint is `TCP` or `UDP`. The rule is narrow on +purpose. Without a readiness gate a public URL routes to a replica that is +running but not yet serving, and the first request a user makes is the one that +fails. A private consumer inside the mesh retries; a browser does not. + +The `TCP`/`UDP` exemption is that same argument, not a second one. What +[§5.2](#endpoints) publishes for those protocols is a `host:port` address, and +an L4 consumer connecting to one retries exactly as a private consumer does. +Compelling a probe there would also compel an HTTP path against a port that +speaks no HTTP — a rule the document has no way to satisfy. ### 5.5 Volumes @@ -438,12 +465,18 @@ which endpoint it is derived from. endpoints, one that does MUST name the endpoint here: null elects nothing there and is rejected with `ERR_AMBIGUOUS_ENDPOINT`. -Two further rules follow the name, both `semantic`. An endpoint the workload +Three further rules follow the name, all `semantic`. An endpoint the workload does not declare is `ERR_UNKNOWN_ENDPOINT` — the same code and the same reason as a probe's. An endpoint that is declared but `PRIVATE` is `ERR_ENDPOINT_NOT_PUBLIC`: both sources derive an externally reachable address, and a `PRIVATE` endpoint has none to give. +The third follows from [§5.2](#endpoints)'s two address forms. Both sources +derive from a **URL** — `PUBLIC_URL` is one, `PUBLIC_HOSTNAME` is the host part +of one — and a `PUBLIC` `TCP` or `UDP` endpoint publishes a `host:port` address +instead, which has no URL to take either from. Naming one is +`ERR_ENDPOINT_NOT_HTTP`, the same code a probe on such an endpoint carries. + **A platform default is not a `CONNECTION`.** The value comes from the component's own workload, never from an upstream node, which is the same line [§6.2](#outputs) draws around an output. `suppliedBy` is unconstrained by @@ -533,6 +566,7 @@ different text and that is expected. | `ERR_UNKNOWN_ENDPOINT` | `semantic` | A probe or a platform default names an endpoint the workload does not declare. | | `ERR_AMBIGUOUS_ENDPOINT` | `semantic` | A reference omits the endpoint, and the workload elects no primary. | | `ERR_ENDPOINT_NOT_PUBLIC` | `semantic` | A platform default deriving a public address names a `PRIVATE` endpoint. | +| `ERR_ENDPOINT_NOT_HTTP` | `semantic` | A probe or a platform default resolves to an endpoint whose protocol is not in the HTTP family. | | `ERR_VERSION_NOT_MONOTONIC` | `capability` | A published component version is not greater than the lineage's current version. | The `parser` and `structural` rows are the shared envelope registry: the diff --git a/tools/src/semantic.ts b/tools/src/semantic.ts index b13f4c9..5e2a4c8 100644 --- a/tools/src/semantic.ts +++ b/tools/src/semantic.ts @@ -80,6 +80,14 @@ const FLOATING_TAGS = new Set([ 'rolling', ]) +/** + * Component §5.2 — the protocols whose `PUBLIC` form publishes a URL. A `TCP` or + * `UDP` endpoint publishes a `host:port` address instead, which is why §5.4's + * probes and §6.1's platform defaults may not resolve to one: a probe polls an + * HTTP path, and both platform-default sources take their value from a URL. + */ +const HTTP_FAMILY = new Set(['HTTP', 'HTTPS', 'WS', 'GRPC']) + /** * The tag of an image reference, or `undefined` when it carries none. * @@ -156,23 +164,38 @@ function checkEndpointReference( const named = asString(reference.value) // Absent or null both select the primary, which §5.2 may elect to be nothing. - if (named === undefined) { - if (primaryEndpoint(endpoints) === undefined) { - out.push({ - code: 'ERR_AMBIGUOUS_ENDPOINT', - path: reference.path, - message: `${reference.subject} names no endpoint, and the workload elects no primary`, - }) - } + // Every rule below is measured against whichever endpoint the reference + // resolves to, named or elected — §5.2 makes the primary what null *selects*, + // so a rule about the endpoint a reference names reaches it equally. + const selected = named ?? primaryEndpoint(endpoints) + if (selected === undefined) { + out.push({ + code: 'ERR_AMBIGUOUS_ENDPOINT', + path: reference.path, + message: `${reference.subject} names no endpoint, and the workload elects no primary`, + }) return } - const declared = child(endpoints, named) + const declared = child(endpoints, selected) if (declared === undefined) { out.push({ code: 'ERR_UNKNOWN_ENDPOINT', path: reference.path, - message: `${reference.subject} targets endpoint "${named}", which the workload does not declare`, + message: `${reference.subject} targets endpoint "${selected}", which the workload does not declare`, + }) + return + } + + // §5.4 and §6.1 — a probe polls an HTTP path and both platform-default + // sources derive from a URL. A TCP or UDP endpoint publishes a host:port + // address instead, and has neither to give. + const protocol = asString(child(declared, 'protocol')) + if (protocol !== undefined && !HTTP_FAMILY.has(protocol)) { + out.push({ + code: 'ERR_ENDPOINT_NOT_HTTP', + path: reference.path, + message: `${reference.subject} resolves to endpoint "${selected}", which serves ${protocol}`, }) return } @@ -181,7 +204,7 @@ function checkEndpointReference( out.push({ code: 'ERR_ENDPOINT_NOT_PUBLIC', path: reference.path, - message: `${reference.subject} derives a public address from endpoint "${named}", which is PRIVATE`, + message: `${reference.subject} derives a public address from endpoint "${selected}", which is PRIVATE`, }) } } From 9a02e6bc75ce6d92f40c23e5291900c76c4e9bed Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Tue, 11 Aug 2026 02:01:59 +0000 Subject: [PATCH 2/6] feat(component): narrow the endpoint-name grammar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §5.2 gave an endpoint name the slug grammar on the grounds that the name "becomes a DNS label". It does not become one alone — it is composed into one beside the component and application names, and the slug grammar breaks that composition in two ways. A name drawn from the same alphabet as the parts it is joined to cannot be delimited: with a hyphen separator, endpoint `api` on component `web` and component `api-web` compose to the same label, and doubling the separator only helps if neither side may contain the doubled form — which the slug grammar permits. And a 63-character label shared with three other parts leaves no room for a name allowed to reach 62, forcing every implementation into a truncation rule that reintroduces the collisions the grammar was meant to prevent. The grammar is now ^[a-z][a-z0-9]{0,19}$, applied in the three places that carry it: the endpoints map's propertyNames, ComponentProbe.endpoint, and ComponentPlatformDefault.endpoint. Twenty characters leave at least forty for the rest of the label and fit every name worth having. Narrowing, and free only before the first tag. Nothing in the corpus moves: the names in use are api, console, db, internal, metrics, primary, rpc and web. Closes #29 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Justin Merrell --- conformance/component/v1/cases.json | 5 +++ .../029-endpoint-name-not-a-slug/case.yaml | 5 +-- .../metadata.json | 2 +- .../031-endpoint-name-with-a-hyphen/case.yaml | 19 +++++++++++ .../diagnostics.json | 6 ++++ .../metadata.json | 7 ++++ .../v1/schemas/dist/component.schema.json | 8 ++--- .../v1/schemas/src/component.schema.json | 8 ++--- specifications/component/v1/spec.md | 32 ++++++++++++++++--- 9 files changed, 76 insertions(+), 16 deletions(-) create mode 100644 conformance/component/v1/structural/031-endpoint-name-with-a-hyphen/case.yaml create mode 100644 conformance/component/v1/structural/031-endpoint-name-with-a-hyphen/diagnostics.json create mode 100644 conformance/component/v1/structural/031-endpoint-name-with-a-hyphen/metadata.json diff --git a/conformance/component/v1/cases.json b/conformance/component/v1/cases.json index 051c687..e9ffcb5 100644 --- a/conformance/component/v1/cases.json +++ b/conformance/component/v1/cases.json @@ -172,6 +172,11 @@ "phase": "structural", "path": "structural/030-two-public-endpoints" }, + { + "id": "structural-031-endpoint-name-with-a-hyphen", + "phase": "structural", + "path": "structural/031-endpoint-name-with-a-hyphen" + }, { "id": "semantic-001-floating-tag-image-reference", "phase": "semantic", diff --git a/conformance/component/v1/structural/029-endpoint-name-not-a-slug/case.yaml b/conformance/component/v1/structural/029-endpoint-name-not-a-slug/case.yaml index fc4a46a..466c265 100644 --- a/conformance/component/v1/structural/029-endpoint-name-not-a-slug/case.yaml +++ b/conformance/component/v1/structural/029-endpoint-name-not-a-slug/case.yaml @@ -1,5 +1,6 @@ -# An endpoint name is referenced by probes and by platform defaults, and -# becomes a DNS label. A dot in one is a hazard rather than a matter of taste. +# An endpoint name is referenced by probes and by platform defaults, and is +# composed into a DNS label beside the names identifying the deployment. A dot +# in one is a hazard rather than a matter of taste. specVersion: v1 kind: COMPONENT metadata: diff --git a/conformance/component/v1/structural/029-endpoint-name-not-a-slug/metadata.json b/conformance/component/v1/structural/029-endpoint-name-not-a-slug/metadata.json index fd05591..71e0231 100644 --- a/conformance/component/v1/structural/029-endpoint-name-not-a-slug/metadata.json +++ b/conformance/component/v1/structural/029-endpoint-name-not-a-slug/metadata.json @@ -3,5 +3,5 @@ "phase": "structural", "expected": "fail", "clause": "specifications/component/v1/spec.md#endpoints", - "summary": "An endpoint name outside the slug grammar is rejected." + "summary": "An endpoint name carrying a dot is rejected." } diff --git a/conformance/component/v1/structural/031-endpoint-name-with-a-hyphen/case.yaml b/conformance/component/v1/structural/031-endpoint-name-with-a-hyphen/case.yaml new file mode 100644 index 0000000..0ddfc06 --- /dev/null +++ b/conformance/component/v1/structural/031-endpoint-name-with-a-hyphen/case.yaml @@ -0,0 +1,19 @@ +# A hyphen is what a composed DNS label uses to join an endpoint name to the +# names beside it. A name drawn from that same alphabet cannot be delimited: +# endpoint `api` on a component `web` and a component `api-web` would compose +# to one label. The grammar excludes the hyphen so the separator survives. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + endpoints: + admin-console: + containerPort: 8080 + protocol: HTTP + visibility: PRIVATE diff --git a/conformance/component/v1/structural/031-endpoint-name-with-a-hyphen/diagnostics.json b/conformance/component/v1/structural/031-endpoint-name-with-a-hyphen/diagnostics.json new file mode 100644 index 0000000..0682289 --- /dev/null +++ b/conformance/component/v1/structural/031-endpoint-name-with-a-hyphen/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_INVALID_VALUE", + "path": "/spec/workload/endpoints" + } +] diff --git a/conformance/component/v1/structural/031-endpoint-name-with-a-hyphen/metadata.json b/conformance/component/v1/structural/031-endpoint-name-with-a-hyphen/metadata.json new file mode 100644 index 0000000..c8a7b41 --- /dev/null +++ b/conformance/component/v1/structural/031-endpoint-name-with-a-hyphen/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-031-endpoint-name-with-a-hyphen", + "phase": "structural", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#endpoints", + "summary": "An endpoint name carrying a hyphen is rejected." +} diff --git a/specifications/component/v1/schemas/dist/component.schema.json b/specifications/component/v1/schemas/dist/component.schema.json index 619b269..ef9feb2 100644 --- a/specifications/component/v1/schemas/dist/component.schema.json +++ b/specifications/component/v1/schemas/dist/component.schema.json @@ -626,7 +626,7 @@ "description": "Name of the PUBLIC endpoint the value is derived from. Null selects the primary endpoint spec.md §5.2 elects.", "anyOf": [ { - "pattern": "^[a-z][a-z0-9-]{0,61}[a-z0-9]$", + "pattern": "^[a-z][a-z0-9]{0,19}$", "type": "string" }, { @@ -656,7 +656,7 @@ "description": "Name of the endpoint whose port the probe targets. Null selects the primary endpoint spec.md §5.2 elects.", "anyOf": [ { - "pattern": "^[a-z][a-z0-9-]{0,61}[a-z0-9]$", + "pattern": "^[a-z][a-z0-9]{0,19}$", "type": "string" }, { @@ -1052,8 +1052,8 @@ "$ref": "#/$defs/ComponentEndpoint" }, "propertyNames": { - "$comment": "spec.md §5.2. An endpoint name is referenced by probes and by platform defaults, and becomes a DNS label, so it takes the slug grammar rather than an arbitrary key.", - "pattern": "^[a-z][a-z0-9-]{0,61}[a-z0-9]$" + "$comment": "spec.md §5.2. An endpoint name is referenced by probes and by platform defaults, and is composed into a DNS label beside other slugs rather than being one. Alphanumeric keeps any hyphen separator unambiguous; 20 characters leave room in the 63-character label for what it composes with.", + "pattern": "^[a-z][a-z0-9]{0,19}$" }, "type": "object" }, diff --git a/specifications/component/v1/schemas/src/component.schema.json b/specifications/component/v1/schemas/src/component.schema.json index 970f9a2..cf0e96d 100644 --- a/specifications/component/v1/schemas/src/component.schema.json +++ b/specifications/component/v1/schemas/src/component.schema.json @@ -625,7 +625,7 @@ "endpoint": { "anyOf": [ { - "pattern": "^[a-z][a-z0-9-]{0,61}[a-z0-9]$", + "pattern": "^[a-z][a-z0-9]{0,19}$", "type": "string" }, { @@ -655,7 +655,7 @@ "endpoint": { "anyOf": [ { - "pattern": "^[a-z][a-z0-9-]{0,61}[a-z0-9]$", + "pattern": "^[a-z][a-z0-9]{0,19}$", "type": "string" }, { @@ -1052,8 +1052,8 @@ }, "description": "Endpoints the workload exposes, keyed by endpoint name. Only a SERVICE may declare one; on any other kind the mapping must be absent or empty.", "propertyNames": { - "$comment": "spec.md §5.2. An endpoint name is referenced by probes and by platform defaults, and becomes a DNS label, so it takes the slug grammar rather than an arbitrary key.", - "pattern": "^[a-z][a-z0-9-]{0,61}[a-z0-9]$" + "$comment": "spec.md §5.2. An endpoint name is referenced by probes and by platform defaults, and is composed into a DNS label beside other slugs rather than being one. Alphanumeric keeps any hyphen separator unambiguous; 20 characters leave room in the 63-character label for what it composes with.", + "pattern": "^[a-z][a-z0-9]{0,19}$" }, "type": "object" }, diff --git a/specifications/component/v1/spec.md b/specifications/component/v1/spec.md index e0349ec..f943bf6 100644 --- a/specifications/component/v1/spec.md +++ b/specifications/component/v1/spec.md @@ -267,13 +267,35 @@ missing any of them describes a port nothing can route to. Only a `SERVICE` may declare one; [§5](#workload) carries the rest of that rule. **An endpoint name is a reference.** A name MUST match -`^[a-z][a-z0-9-]{0,61}[a-z0-9]$` — the grammar -[blueprint §4.1](../../blueprint/v1/spec.md#component-reference) quotes for a -slug — and one that does not is rejected in the `structural` phase with +`^[a-z][a-z0-9]{0,19}$` — lowercase alphanumeric, one to twenty characters — +and one that does not is rejected in the `structural` phase with `ERR_INVALID_VALUE`. The name is not decoration: [§5.4](#health) points a probe at one and [§6.1](#inputs) derives an address from one, so a dot or a space in -a name is a hazard rather than a matter of taste. The 63-character bound is a -DNS label, which is what the name becomes. +a name is a hazard rather than a matter of taste. + +**The grammar is narrower than a slug, and deliberately.** A name does not +become a DNS label; it is composed *into* one, beside the other names that +identify the deployment. Two properties follow from that, and neither holds for +the slug grammar [blueprint §4.1](../../blueprint/v1/spec.md#component-reference) +uses: + +1. **A separator has to survive.** Whatever character an implementation joins + the parts with, a name drawn from the same alphabet as the parts it joins to + cannot be delimited — with `-`, endpoint `api` on a component named `web` and + a component named `api-web` compose to the same label. Doubling the separator + only helps if neither side may contain the doubled form, which a + hyphen-bearing grammar permits. Alphanumeric makes any hyphen separator + unambiguous by construction. +2. **The label budget is shared.** A DNS label holds 63 characters, and the + endpoint name is one of several things inside it. A name permitted to reach + 62 leaves nothing for the rest, which forces every implementation into a + truncation rule of its own — and truncation reintroduces exactly the + collisions the grammar was meant to prevent. Twenty characters leave at least + forty for everything else. + +Twenty characters also fit every name worth having — `web`, `api`, `grpc`, +`metrics`, `console`, `admin`. A name that does not fit is describing something +an endpoint name should not be describing. **`containerPort` is an integer from 1 to 65535.** Outside that range there is no port to bind. The bound is `structural` and carries `ERR_INVALID_VALUE`. From ec3579efcc8e77bb04e0235c96adf6be7ec2321a Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Tue, 11 Aug 2026 02:04:16 +0000 Subject: [PATCH 3/6] feat(component): specify environment variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §5.3 was a bare TODO while the schema already carried the whole shape. This writes the behaviour down and settles the three questions #17 raises. Key grammar is REQUIRED, not conventional: ^[A-Z_][A-Z0-9_]*$, structural, already enforced and now stated. Precedence has no answer because a collision is an error. envVars is a sequence rather than a mapping, so §5's "a repeated key is ERR_DUPLICATE_KEY in the parser phase" does not reach it — two entries of a sequence repeat nothing at the YAML level, and a repeated key today means whatever an implementation's last write happens to be. An input's target.envVarKey binds into the same namespace and competes with those entries. Both are now semantic errors: ERR_DUPLICATE_ENV_KEY for two entries sharing a key, and ERR_CONFLICTING_ENV_KEY for a key claimed by more than one declaration. Ranking the two instead would settle the ambiguity without telling anyone there was one, which is the trade blueprint §5.2 refused for input merging. Because the anchor is normative, the tiebreak is written down: the collision anchors at an input's target/envVarKey, and where two inputs collide, at the later in lexicographic input-name order. Sensitivity: isSensitive on a LITERAL obliges the handling §6.1 already requires of a generated input. A CONFIG_REF needs no marking — the document holds the name, not the value. A SHOULD prefers either of those to a marked literal, since isSensitive governs handling and not the fact that the value is bytes in a committed file. Closes #17 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Justin Merrell --- conformance/component/v1/cases.json | 15 ++++ .../010-duplicate-env-var-key/case.yaml | 22 ++++++ .../diagnostics.json | 6 ++ .../010-duplicate-env-var-key/metadata.json | 7 ++ .../011-env-key-claimed-twice/case.yaml | 28 ++++++++ .../diagnostics.json | 6 ++ .../011-env-key-claimed-twice/metadata.json | 7 ++ .../032-env-var-key-not-uppercase/case.yaml | 17 +++++ .../diagnostics.json | 6 ++ .../metadata.json | 7 ++ specifications/component/v1/spec.md | 69 ++++++++++++++++++- tools/src/semantic.ts | 53 ++++++++++++++ 12 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 conformance/component/v1/semantic/010-duplicate-env-var-key/case.yaml create mode 100644 conformance/component/v1/semantic/010-duplicate-env-var-key/diagnostics.json create mode 100644 conformance/component/v1/semantic/010-duplicate-env-var-key/metadata.json create mode 100644 conformance/component/v1/semantic/011-env-key-claimed-twice/case.yaml create mode 100644 conformance/component/v1/semantic/011-env-key-claimed-twice/diagnostics.json create mode 100644 conformance/component/v1/semantic/011-env-key-claimed-twice/metadata.json create mode 100644 conformance/component/v1/structural/032-env-var-key-not-uppercase/case.yaml create mode 100644 conformance/component/v1/structural/032-env-var-key-not-uppercase/diagnostics.json create mode 100644 conformance/component/v1/structural/032-env-var-key-not-uppercase/metadata.json diff --git a/conformance/component/v1/cases.json b/conformance/component/v1/cases.json index e9ffcb5..397c14d 100644 --- a/conformance/component/v1/cases.json +++ b/conformance/component/v1/cases.json @@ -177,6 +177,11 @@ "phase": "structural", "path": "structural/031-endpoint-name-with-a-hyphen" }, + { + "id": "structural-032-env-var-key-not-uppercase", + "phase": "structural", + "path": "structural/032-env-var-key-not-uppercase" + }, { "id": "semantic-001-floating-tag-image-reference", "phase": "semantic", @@ -221,6 +226,16 @@ "id": "semantic-009-platform-default-on-tcp-endpoint", "phase": "semantic", "path": "semantic/009-platform-default-on-tcp-endpoint" + }, + { + "id": "semantic-010-duplicate-env-var-key", + "phase": "semantic", + "path": "semantic/010-duplicate-env-var-key" + }, + { + "id": "semantic-011-env-key-claimed-twice", + "phase": "semantic", + "path": "semantic/011-env-key-claimed-twice" } ] } diff --git a/conformance/component/v1/semantic/010-duplicate-env-var-key/case.yaml b/conformance/component/v1/semantic/010-duplicate-env-var-key/case.yaml new file mode 100644 index 0000000..084e6e2 --- /dev/null +++ b/conformance/component/v1/semantic/010-duplicate-env-var-key/case.yaml @@ -0,0 +1,22 @@ +# envVars is a sequence, so two entries sharing a key repeat nothing at the +# YAML level and the parser phase sees no duplicate. The rule that catches it +# is §5.3's, and it anchors at the later entry — the first declaration stands. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + envVars: + - key: LOG_LEVEL + value: + type: LITERAL + value: info + - key: LOG_LEVEL + value: + type: LITERAL + value: debug diff --git a/conformance/component/v1/semantic/010-duplicate-env-var-key/diagnostics.json b/conformance/component/v1/semantic/010-duplicate-env-var-key/diagnostics.json new file mode 100644 index 0000000..96c4b12 --- /dev/null +++ b/conformance/component/v1/semantic/010-duplicate-env-var-key/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_DUPLICATE_ENV_KEY", + "path": "/spec/workload/envVars/1/key" + } +] diff --git a/conformance/component/v1/semantic/010-duplicate-env-var-key/metadata.json b/conformance/component/v1/semantic/010-duplicate-env-var-key/metadata.json new file mode 100644 index 0000000..9841608 --- /dev/null +++ b/conformance/component/v1/semantic/010-duplicate-env-var-key/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "semantic-010-duplicate-env-var-key", + "phase": "semantic", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#env-vars", + "summary": "Two envVars entries declaring the same key are rejected." +} diff --git a/conformance/component/v1/semantic/011-env-key-claimed-twice/case.yaml b/conformance/component/v1/semantic/011-env-key-claimed-twice/case.yaml new file mode 100644 index 0000000..b16d4b7 --- /dev/null +++ b/conformance/component/v1/semantic/011-env-key-claimed-twice/case.yaml @@ -0,0 +1,28 @@ +# An input's target.envVarKey binds into the same namespace an envVars entry +# writes to. Ranking the two would settle the ambiguity silently and surface +# it as a wrong value inside the running workload, so the collision is an +# error and there is no precedence rule to apply. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: postgres:17.10-alpine + envVars: + - key: DATABASE_URL + value: + type: LITERAL + value: postgres://localhost/app + contract: + inputs: + databaseUrl: + schema: + type: STRING + ui: + label: Database URL + target: + envVarKey: DATABASE_URL diff --git a/conformance/component/v1/semantic/011-env-key-claimed-twice/diagnostics.json b/conformance/component/v1/semantic/011-env-key-claimed-twice/diagnostics.json new file mode 100644 index 0000000..56e6604 --- /dev/null +++ b/conformance/component/v1/semantic/011-env-key-claimed-twice/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_CONFLICTING_ENV_KEY", + "path": "/spec/contract/inputs/databaseUrl/target/envVarKey" + } +] diff --git a/conformance/component/v1/semantic/011-env-key-claimed-twice/metadata.json b/conformance/component/v1/semantic/011-env-key-claimed-twice/metadata.json new file mode 100644 index 0000000..080a166 --- /dev/null +++ b/conformance/component/v1/semantic/011-env-key-claimed-twice/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "semantic-011-env-key-claimed-twice", + "phase": "semantic", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#env-vars", + "summary": "An envVars key also claimed by an input's target.envVarKey is rejected." +} diff --git a/conformance/component/v1/structural/032-env-var-key-not-uppercase/case.yaml b/conformance/component/v1/structural/032-env-var-key-not-uppercase/case.yaml new file mode 100644 index 0000000..d9e914a --- /dev/null +++ b/conformance/component/v1/structural/032-env-var-key-not-uppercase/case.yaml @@ -0,0 +1,17 @@ +# The POSIX shape is required rather than conventional: a name outside it is +# not portably settable by the thing that has to set it. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + envVars: + - key: database-url + value: + type: LITERAL + value: postgres://localhost/app diff --git a/conformance/component/v1/structural/032-env-var-key-not-uppercase/diagnostics.json b/conformance/component/v1/structural/032-env-var-key-not-uppercase/diagnostics.json new file mode 100644 index 0000000..f43b29d --- /dev/null +++ b/conformance/component/v1/structural/032-env-var-key-not-uppercase/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_INVALID_VALUE", + "path": "/spec/workload/envVars/0/key" + } +] diff --git a/conformance/component/v1/structural/032-env-var-key-not-uppercase/metadata.json b/conformance/component/v1/structural/032-env-var-key-not-uppercase/metadata.json new file mode 100644 index 0000000..4439d9d --- /dev/null +++ b/conformance/component/v1/structural/032-env-var-key-not-uppercase/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-032-env-var-key-not-uppercase", + "phase": "structural", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#env-vars", + "summary": "An environment-variable key outside the POSIX grammar is rejected." +} diff --git a/specifications/component/v1/spec.md b/specifications/component/v1/spec.md index f943bf6..a22c2ae 100644 --- a/specifications/component/v1/spec.md +++ b/specifications/component/v1/spec.md @@ -375,8 +375,71 @@ this version declines to answer rather than one it overlooked. ### 5.3 Environment variables -> **TODO** — Key grammar, the precedence order between literal, config-reference, -> and contract-supplied values, and the handling of sensitive values. +`envVars` is a **sequence** of entries, each pairing a `key` with a `value`. It +is the one collection in this document that is not a mapping, and that shape +decides more than it looks like it should — [§5](#workload)'s argument that +"input and output keys are unique within a component because `contract.inputs` +and `contract.outputs` are mappings" does not reach here. A repeated env-var key +is not `ERR_DUPLICATE_KEY` in the `parser` phase, because at the YAML level +nothing is repeated: two entries of a sequence are two entries. + +**A key MUST match `^[A-Z_][A-Z0-9_]*$`** and be 1 to 128 characters. That is +the POSIX shape, and it is REQUIRED rather than conventional: a name outside it +is not portably settable by the thing that has to set it. The rule is +`structural` and carries `ERR_INVALID_VALUE`. + +`value` is discriminated on `type`. + +| `type` | Carries | What the document holds | +|---|---|---| +| `LITERAL` | `value`, and OPTIONAL `isSensitive` | The value itself. An empty string is permitted. | +| `CONFIG_REF` | `configKey` | A reference. The document never holds the value. | + +**A key is declared once.** Two entries sharing a key are rejected in the +`semantic` phase with `ERR_DUPLICATE_ENV_KEY`, anchored at the **later** of the +two — the first declaration is the one that stands, so the second is the one an +author has to change. Without this rule the sequence shape would make a repeated +key mean whatever an implementation's last write happened to be. + +**A key is claimed by one declaration.** An input's +[`target.envVarKey`](#inputs) binds a resolved value into the environment, so it +competes for the same namespace these entries do. Two declarations of one key +are rejected in the `semantic` phase with `ERR_CONFLICTING_ENV_KEY`, in both +shapes it can take: + +- an `envVars` entry whose `key` equals some input's `target.envVarKey`, and +- two inputs declaring the same `target.envVarKey`. + +The diagnostic anchors at an input's `target/envVarKey` in both cases, and where +two inputs collide it anchors at the later of the two in **lexicographic +input-name order**. Anchors are normative ([§8](#diagnostics)), so the tiebreak +is written down rather than left to whichever order an implementation iterates +a mapping in. + +**There is no precedence order**, and there is nothing for one to resolve: a +collision is an error, so no key is ever claimed twice. Ranking the two instead +— letting a contract-supplied value quietly outrank a literal, or the reverse — +would settle the ambiguity without telling anyone there was one, and the +consequence would surface as a wrong value inside a running workload rather than +as a diagnostic. [Blueprint §5.2](../../blueprint/v1/spec.md#merge) refused that +same trade for input merging, and refused it on the same ground. + +**Sensitivity.** `isSensitive` on a `LITERAL` marks the value as secret +material. An implementation MUST treat a marked value as +[§6.1](#inputs) requires of a generated input: masked in read surfaces, and +never echoed back into logs, diagnostics, or interfaces. It defaults to `false`, +so an unmarked literal is handled as ordinary configuration. + +A `CONFIG_REF` carries no marking and needs none. It names a value resolved +elsewhere and the document holds only the name, so there is nothing in this file +to mask. + +A component SHOULD carry secret material as a `CONFIG_REF`, or as an input with +a `generator` ([§6.1](#inputs)), rather than as a marked literal. `isSensitive` +governs how a value is *handled*; it does not stop the value being bytes in a +file that is read, reviewed, and committed. It is a SHOULD because a marked +literal is still better than an unmarked one, and this document cannot see where +the file lives. ### 5.4 Health probes @@ -585,6 +648,8 @@ different text and that is expected. | `ERR_INVALID_TYPE` | `structural` | A value has the wrong type. | | `ERR_INVALID_VALUE` | `structural` | A value violates a pattern, enum, or bound. | | `ERR_UNPINNED_IMAGE` | `semantic` | An image reference carries a floating tag. | +| `ERR_DUPLICATE_ENV_KEY` | `semantic` | Two `envVars` entries declare the same key. | +| `ERR_CONFLICTING_ENV_KEY` | `semantic` | An environment-variable key is claimed by more than one declaration. | | `ERR_UNKNOWN_ENDPOINT` | `semantic` | A probe or a platform default names an endpoint the workload does not declare. | | `ERR_AMBIGUOUS_ENDPOINT` | `semantic` | A reference omits the endpoint, and the workload elects no primary. | | `ERR_ENDPOINT_NOT_PUBLIC` | `semantic` | A platform default deriving a public address names a `PRIVATE` endpoint. | diff --git a/tools/src/semantic.ts b/tools/src/semantic.ts index 5e2a4c8..b184637 100644 --- a/tools/src/semantic.ts +++ b/tools/src/semantic.ts @@ -246,6 +246,58 @@ function checkEndpointReferences(document: Json, out: Diagnostic[]): void { } } +/** + * Component §5.3 — every environment-variable key is declared exactly once, + * across both the places that declare one. + * + * `envVars` is a sequence rather than a mapping, so §5's "a repeated key is + * ERR_DUPLICATE_KEY in the parser phase" does not reach it: two entries of a + * sequence repeat nothing at the YAML level. An input's `target.envVarKey` + * binds into the same namespace, so it competes with them. + */ +function checkEnvVarKeys(document: Json, out: Diagnostic[]): void { + const spec = child(document, 'spec') + const envVars = child(child(spec, 'workload'), 'envVars') + const inputs = child(child(spec, 'contract'), 'inputs') + + // First declaration wins the key, so the later one is what an author changes. + const declared = new Map() + + if (Array.isArray(envVars)) { + for (const [position, entry] of envVars.entries()) { + const key = asString(child(entry, 'key')) + if (key === undefined) continue + const earlier = declared.get(key) + if (earlier === undefined) { + declared.set(key, `envVars entry ${position}`) + continue + } + out.push({ + code: 'ERR_DUPLICATE_ENV_KEY', + path: `/spec/workload/envVars/${position}/key`, + message: `environment variable "${key}" is already declared by ${earlier}`, + }) + } + } + + // Lexicographic input-name order, because §5.3 anchors the collision at the + // later of two inputs and a mapping supplies no order of its own. + for (const input of keysOf(inputs).sort()) { + const key = asString(child(child(child(inputs, input), 'target'), 'envVarKey')) + if (key === undefined) continue + const earlier = declared.get(key) + if (earlier === undefined) { + declared.set(key, `input "${input}"`) + continue + } + out.push({ + code: 'ERR_CONFLICTING_ENV_KEY', + path: `/spec/contract/inputs/${token(input)}/target/envVarKey`, + message: `environment variable "${key}" is already claimed by ${earlier}`, + }) + } +} + // =========================================================================== // blueprint // =========================================================================== @@ -749,6 +801,7 @@ export function semanticDiagnostics( if (family.name === 'component') { checkImageRef(document, out) checkEndpointReferences(document, out) + checkEnvVarKeys(document, out) } if (family.name === 'blueprint') { checkConnectionRoles(document, out) From 07b25898f538cdf3ceb6011c26766c966c1e0f3d Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Tue, 11 Aug 2026 02:06:53 +0000 Subject: [PATCH 4/6] feat(blueprint): decide connection type compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §4.2 resolved both ends of a connection and rejected each half independently, but never asked whether the two fit. A STRING output wired into an INTEGER input satisfied every rule the specification stated and failed at deploy time inside the consuming workload — the last place a composition could be internally inconsistent and still validate. The rule lives in blueprint §4.2, which resolves the connection; component §6.2's TODO becomes a pointer to it, since a component document sees only one end. `type` is compared for equality, with no widening in either direction. An INTEGER output feeding a STRING input looks harmless, but which string is a per-language formatting decision, and a contract that permitted the wire would be promising a value it cannot describe. `semanticType` must agree wherever the consuming input names one. A consumer declaring null accepts any producer; a consumer declaring a tag requires the same tag, including rejecting a null producer, because an unconstrained producer does not satisfy a constrained consumer. That is also the answer to what the tag is for: type is the primitive shape, semanticType is the backing service, and a Postgres connection string and a MySQL one are both STRING. Two codes rather than one, so a diagnostic names the axis that failed: ERR_INCOMPATIBLE_TYPE and ERR_INCOMPATIBLE_SEMANTIC_TYPE, both semantic, both anchored at the connection's fromOutput. Both ends always carry a schema with a required type, so there is no unconstrained-producer case to except. format, enum, pattern, default and isSensitive take no part in the decision, and §4.2 records those silences as gaps rather than as permissions. Closes #18 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Justin Merrell --- conformance/blueprint/v1/cases.json | 10 +++ .../diagnostics.json | 6 ++ .../metadata.json | 8 +++ .../tree/acme-wiki/blueprint.yaml | 23 +++++++ .../tree/acme-wiki/components/postgres.yaml | 23 +++++++ .../tree/acme-wiki/components/web.yaml | 23 +++++++ .../tree/acme-wiki/listing.yaml | 11 ++++ .../diagnostics.json | 6 ++ .../metadata.json | 8 +++ .../tree/acme-wiki/blueprint.yaml | 22 +++++++ .../tree/acme-wiki/components/postgres.yaml | 26 ++++++++ .../tree/acme-wiki/components/web.yaml | 26 ++++++++ .../tree/acme-wiki/listing.yaml | 11 ++++ specifications/blueprint/v1/spec.md | 57 ++++++++++++++--- specifications/component/v1/spec.md | 11 ++-- tools/src/semantic.ts | 64 +++++++++++++++++++ 16 files changed, 321 insertions(+), 14 deletions(-) create mode 100644 conformance/blueprint/v1/semantic/012-connection-type-mismatch/diagnostics.json create mode 100644 conformance/blueprint/v1/semantic/012-connection-type-mismatch/metadata.json create mode 100644 conformance/blueprint/v1/semantic/012-connection-type-mismatch/tree/acme-wiki/blueprint.yaml create mode 100644 conformance/blueprint/v1/semantic/012-connection-type-mismatch/tree/acme-wiki/components/postgres.yaml create mode 100644 conformance/blueprint/v1/semantic/012-connection-type-mismatch/tree/acme-wiki/components/web.yaml create mode 100644 conformance/blueprint/v1/semantic/012-connection-type-mismatch/tree/acme-wiki/listing.yaml create mode 100644 conformance/blueprint/v1/semantic/013-connection-semantic-type-mismatch/diagnostics.json create mode 100644 conformance/blueprint/v1/semantic/013-connection-semantic-type-mismatch/metadata.json create mode 100644 conformance/blueprint/v1/semantic/013-connection-semantic-type-mismatch/tree/acme-wiki/blueprint.yaml create mode 100644 conformance/blueprint/v1/semantic/013-connection-semantic-type-mismatch/tree/acme-wiki/components/postgres.yaml create mode 100644 conformance/blueprint/v1/semantic/013-connection-semantic-type-mismatch/tree/acme-wiki/components/web.yaml create mode 100644 conformance/blueprint/v1/semantic/013-connection-semantic-type-mismatch/tree/acme-wiki/listing.yaml diff --git a/conformance/blueprint/v1/cases.json b/conformance/blueprint/v1/cases.json index 5842b7f..7030b61 100644 --- a/conformance/blueprint/v1/cases.json +++ b/conformance/blueprint/v1/cases.json @@ -136,6 +136,16 @@ "id": "semantic-011-three-node-cycle-reporting", "phase": "semantic", "path": "semantic/011-three-node-cycle-reporting" + }, + { + "id": "semantic-012-connection-type-mismatch", + "phase": "semantic", + "path": "semantic/012-connection-type-mismatch" + }, + { + "id": "semantic-013-connection-semantic-type-mismatch", + "phase": "semantic", + "path": "semantic/013-connection-semantic-type-mismatch" } ] } diff --git a/conformance/blueprint/v1/semantic/012-connection-type-mismatch/diagnostics.json b/conformance/blueprint/v1/semantic/012-connection-type-mismatch/diagnostics.json new file mode 100644 index 0000000..86c2d80 --- /dev/null +++ b/conformance/blueprint/v1/semantic/012-connection-type-mismatch/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_INCOMPATIBLE_TYPE", + "path": "/spec/components/web/connections/DATABASE_PORT/fromOutput" + } +] diff --git a/conformance/blueprint/v1/semantic/012-connection-type-mismatch/metadata.json b/conformance/blueprint/v1/semantic/012-connection-type-mismatch/metadata.json new file mode 100644 index 0000000..a94dc96 --- /dev/null +++ b/conformance/blueprint/v1/semantic/012-connection-type-mismatch/metadata.json @@ -0,0 +1,8 @@ +{ + "id": "semantic-012-connection-type-mismatch", + "phase": "semantic", + "expected": "fail", + "clause": "specifications/blueprint/v1/spec.md#connections", + "summary": "A connection joining an output and an input of different types is rejected.", + "document": "acme-wiki/blueprint.yaml" +} 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 new file mode 100644 index 0000000..06b5a3e --- /dev/null +++ b/conformance/blueprint/v1/semantic/012-connection-type-mismatch/tree/acme-wiki/blueprint.yaml @@ -0,0 +1,23 @@ +# `db` publishes an INTEGER 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. +specVersion: v1 +kind: BLUEPRINT +metadata: + slug: acme-wiki + version: 1 +spec: + components: + db: + component: ./components/postgres.yaml + size: general.standard.small + connections: {} + web: + component: ./components/web.yaml + size: general.standard.small + connections: + DATABASE_PORT: + fromRole: db + fromOutput: port + parameters: {} 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 new file mode 100644 index 0000000..4e7060d --- /dev/null +++ b/conformance/blueprint/v1/semantic/012-connection-type-mismatch/tree/acme-wiki/components/postgres.yaml @@ -0,0 +1,23 @@ +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: {} + outputs: + port: + schema: + type: INTEGER + valueFrom: DECLARED + value: '5432' diff --git a/conformance/blueprint/v1/semantic/012-connection-type-mismatch/tree/acme-wiki/components/web.yaml b/conformance/blueprint/v1/semantic/012-connection-type-mismatch/tree/acme-wiki/components/web.yaml new file mode 100644 index 0000000..86f69c7 --- /dev/null +++ b/conformance/blueprint/v1/semantic/012-connection-type-mismatch/tree/acme-wiki/components/web.yaml @@ -0,0 +1,23 @@ +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: + DATABASE_PORT: + schema: + type: STRING + suppliedBy: CONNECTION + ui: null + outputs: {} diff --git a/conformance/blueprint/v1/semantic/012-connection-type-mismatch/tree/acme-wiki/listing.yaml b/conformance/blueprint/v1/semantic/012-connection-type-mismatch/tree/acme-wiki/listing.yaml new file mode 100644 index 0000000..da5eed4 --- /dev/null +++ b/conformance/blueprint/v1/semantic/012-connection-type-mismatch/tree/acme-wiki/listing.yaml @@ -0,0 +1,11 @@ +specVersion: v1 +kind: LISTING +metadata: + slug: acme-wiki + version: 1 +spec: + listingKind: BLUEPRINT + displayName: Acme Wiki + summary: A wiki backed by PostgreSQL + category: PRODUCTIVITY + lifecycleStage: STABLE diff --git a/conformance/blueprint/v1/semantic/013-connection-semantic-type-mismatch/diagnostics.json b/conformance/blueprint/v1/semantic/013-connection-semantic-type-mismatch/diagnostics.json new file mode 100644 index 0000000..c31f5f5 --- /dev/null +++ b/conformance/blueprint/v1/semantic/013-connection-semantic-type-mismatch/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_INCOMPATIBLE_SEMANTIC_TYPE", + "path": "/spec/components/web/connections/DATABASE_URL/fromOutput" + } +] diff --git a/conformance/blueprint/v1/semantic/013-connection-semantic-type-mismatch/metadata.json b/conformance/blueprint/v1/semantic/013-connection-semantic-type-mismatch/metadata.json new file mode 100644 index 0000000..e611a2a --- /dev/null +++ b/conformance/blueprint/v1/semantic/013-connection-semantic-type-mismatch/metadata.json @@ -0,0 +1,8 @@ +{ + "id": "semantic-013-connection-semantic-type-mismatch", + "phase": "semantic", + "expected": "fail", + "clause": "specifications/blueprint/v1/spec.md#connections", + "summary": "A connection joining an output and an input whose semanticTypes disagree is rejected.", + "document": "acme-wiki/blueprint.yaml" +} diff --git a/conformance/blueprint/v1/semantic/013-connection-semantic-type-mismatch/tree/acme-wiki/blueprint.yaml b/conformance/blueprint/v1/semantic/013-connection-semantic-type-mismatch/tree/acme-wiki/blueprint.yaml new file mode 100644 index 0000000..45bf8a4 --- /dev/null +++ b/conformance/blueprint/v1/semantic/013-connection-semantic-type-mismatch/tree/acme-wiki/blueprint.yaml @@ -0,0 +1,22 @@ +# Both ends are STRING, so the type axis agrees. `db` publishes a POSTGRES +# connection string and `web` asks for a MYSQL one — a mismatch only the +# semanticType tag can see, and the mistake the tag exists to catch. +specVersion: v1 +kind: BLUEPRINT +metadata: + slug: acme-wiki + version: 1 +spec: + components: + db: + component: ./components/postgres.yaml + size: general.standard.small + connections: {} + web: + component: ./components/web.yaml + size: general.standard.small + connections: + DATABASE_URL: + fromRole: db + fromOutput: connectionString + parameters: {} diff --git a/conformance/blueprint/v1/semantic/013-connection-semantic-type-mismatch/tree/acme-wiki/components/postgres.yaml b/conformance/blueprint/v1/semantic/013-connection-semantic-type-mismatch/tree/acme-wiki/components/postgres.yaml new file mode 100644 index 0000000..f430be5 --- /dev/null +++ b/conformance/blueprint/v1/semantic/013-connection-semantic-type-mismatch/tree/acme-wiki/components/postgres.yaml @@ -0,0 +1,26 @@ +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: {} + outputs: + connectionString: + schema: + type: STRING + format: CONNECTION_STRING + semanticType: POSTGRES + isSensitive: true + valueFrom: DERIVED + value: null diff --git a/conformance/blueprint/v1/semantic/013-connection-semantic-type-mismatch/tree/acme-wiki/components/web.yaml b/conformance/blueprint/v1/semantic/013-connection-semantic-type-mismatch/tree/acme-wiki/components/web.yaml new file mode 100644 index 0000000..f9ece94 --- /dev/null +++ b/conformance/blueprint/v1/semantic/013-connection-semantic-type-mismatch/tree/acme-wiki/components/web.yaml @@ -0,0 +1,26 @@ +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: + DATABASE_URL: + schema: + type: STRING + format: CONNECTION_STRING + semanticType: MYSQL + isSensitive: true + suppliedBy: CONNECTION + ui: null + outputs: {} diff --git a/conformance/blueprint/v1/semantic/013-connection-semantic-type-mismatch/tree/acme-wiki/listing.yaml b/conformance/blueprint/v1/semantic/013-connection-semantic-type-mismatch/tree/acme-wiki/listing.yaml new file mode 100644 index 0000000..da5eed4 --- /dev/null +++ b/conformance/blueprint/v1/semantic/013-connection-semantic-type-mismatch/tree/acme-wiki/listing.yaml @@ -0,0 +1,11 @@ +specVersion: v1 +kind: LISTING +metadata: + slug: acme-wiki + version: 1 +spec: + listingKind: BLUEPRINT + displayName: Acme Wiki + summary: A wiki backed by PostgreSQL + category: PRODUCTIVITY + lifecycleStage: STABLE diff --git a/specifications/blueprint/v1/spec.md b/specifications/blueprint/v1/spec.md index c27c53e..1b4d903 100644 --- a/specifications/blueprint/v1/spec.md +++ b/specifications/blueprint/v1/spec.md @@ -225,9 +225,52 @@ Both are `semantic`. The first needs only this document; the second needs the referenced component document, which a repo-local reference makes readable without a network. -The consumer end needs no rule. The map key names the input being filled and -the enclosing node names the consumer, so one input cannot take two wires — -the mapping already makes that structural. +The consumer end needs no rule of its own for uniqueness. The map key names the +input being filled and the enclosing node names the consumer, so one input +cannot take two wires — the mapping already makes that structural. + +**The two ends MUST fit.** Resolving both ends establishes only that they +exist. A `STRING` output wired into an `INTEGER` 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 +back at them". The argument is the same here, so the answer is. + +Both ends always carry a `schema`, and `type` is REQUIRED on one +([component §6.1](../../component/v1/spec.md#inputs), +[component §6.2](../../component/v1/spec.md#outputs)), so there is no +unconstrained producer to make an exception for. Two axes are compared. Both +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 +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. + +**`semanticType` MUST agree where the consumer names one.** A consumer +declaring `null` accepts any producer: it has said the value is not specific to +a backing service, and nothing it receives can contradict that. A consumer +declaring a tag requires a producer declaring the **same** tag — including +rejecting a producer that declares `null`, because an unconstrained producer +does not satisfy a constrained consumer. A mismatch is +`ERR_INCOMPATIBLE_SEMANTIC_TYPE`. + +That is what `semanticType` is for, given `type` exists. `type` is the +primitive shape; `semanticType` is the backing service the value addresses. A +Postgres connection string and a MySQL one are both `STRING`, both plausibly +`CONNECTION_STRING`-formatted, and wiring one into a consumer expecting the +other is the mistake the tag exists to catch. + +**What v1 does not compare.** `format`, `enum`, `pattern`, `default` and +`isSensitive` take no part in the decision. A producer whose `pattern` admits +more than the consumer's does is accepted, and nothing checks that a +non-sensitive output is not wired into a sensitive input. Those silences are +gaps rather than considered permissions, recorded here so a reader can tell the +two apart; closing any of them rejects compositions that validate today. **The connection graph MUST be acyclic.** A cycle is rejected in the `semantic` phase with `ERR_DEPENDENCY_CYCLE`. @@ -256,12 +299,6 @@ what makes the node names comparable across a conformance corpus instead of an artifact of whichever node the traversal happened to start from. The diagnostic anchors at `/spec/components//connections`. -> **TODO** — Type compatibility between the output and the input it feeds. -> `schema.semanticType` is the tag a composition layer matches on, but the -> matching rule is not stated, and neither is what happens when a `STRING` -> output feeds an `INTEGER` input. See -> [component §6.2](../../component/v1/spec.md#outputs). - ## 5. Parameters `spec.parameters` is the install form: what a deploying user is asked for once, @@ -362,6 +399,8 @@ family adds: | `ERR_UNKNOWN_COMPONENT` | `capability` | A published `component` reference names no component, or no such `componentVersion`. | | `ERR_UNKNOWN_ROLE` | `semantic` | A connection's `fromRole` names no node in this blueprint. | | `ERR_UNKNOWN_OUTPUT` | `semantic` | A connection's `fromOutput` names no output of the referenced component. | +| `ERR_INCOMPATIBLE_TYPE` | `semantic` | A connection joins an output and an input whose `schema.type`s differ. | +| `ERR_INCOMPATIBLE_SEMANTIC_TYPE` | `semantic` | A connection joins an output and an input whose `schema.semanticType`s disagree. | | `ERR_DEPENDENCY_CYCLE` | `semantic` | The connection graph contains a cycle. | | `ERR_SLUG_MISMATCH` | `semantic` | `metadata.slug` disagrees with the item directory name. | | `ERR_VERSION_MISMATCH` | `semantic` | `metadata.version` disagrees with the sibling listing document. | diff --git a/specifications/component/v1/spec.md b/specifications/component/v1/spec.md index a22c2ae..941a956 100644 --- a/specifications/component/v1/spec.md +++ b/specifications/component/v1/spec.md @@ -588,11 +588,12 @@ It is not, however, a licence for a cyclic graph. [Blueprint §4.2](../../blueprint/v1/spec.md#connections) requires the connection graph to be acyclic, for reasons of its own. -> **TODO** — Type compatibility between an output and the input it feeds. -> `schema.semanticType` is the tag a composition layer matches on, but the -> matching rule is not yet stated. It belongs with -> [blueprint §4.2](../../blueprint/v1/spec.md#connections), which resolves the -> connection. +**Where an output must fit the input it feeds.** An output's `schema` and the +`schema` of the input it is wired to must agree on `type`, and on +`semanticType` wherever the consuming input names one. +[Blueprint §4.2](../../blueprint/v1/spec.md#connections) states the rule and +carries the two diagnostics, because the connection is what joins the two ends +and a component document sees only one of them. ## 7. Validation layers diff --git a/tools/src/semantic.ts b/tools/src/semantic.ts index b184637..a11a1d8 100644 --- a/tools/src/semantic.ts +++ b/tools/src/semantic.ts @@ -683,6 +683,7 @@ function checkGraphAgainstItem( checkUnreferencedComponents(itemRoot, documentPath, referenced, out) checkConnectionOutputs(components, resolved, out) + checkConnectionCompatibility(components, resolved, out) checkInputMerge(components, resolved, out) } @@ -739,6 +740,69 @@ function checkConnectionOutputs( } } +/** + * Blueprint §4.2 — the two ends of a connection MUST fit. + * + * `type` is compared for equality with no widening in either direction; a + * `semanticType` the consumer names must be matched exactly by the producer, + * while a consumer naming none accepts anything. Both ends always carry a + * `schema` with a required `type`, so there is no unconstrained producer case. + */ +function checkConnectionCompatibility( + components: Json | undefined, + resolved: Map, + out: Diagnostic[], +): void { + for (const node of keysOf(components)) { + const connections = child(child(components, node), 'connections') + const consumer = resolved.get(node) + if (consumer === undefined) continue + + for (const key of keysOf(connections)) { + const connection = child(connections, key) + const role = asString(child(connection, 'fromRole')) + const output = asString(child(connection, 'fromOutput')) + if (role === undefined || output === undefined) continue + + const producer = resolved.get(role) + if (producer === undefined) continue + + // A dangling end is already ERR_UNKNOWN_OUTPUT or ERR_UNKNOWN_INPUT; do + // not pile a compatibility verdict on a pair that does not both exist. + const outputs = child(child(child(producer, 'spec'), 'contract'), 'outputs') + const inputs = child(child(child(consumer, 'spec'), 'contract'), 'inputs') + const from = child(child(outputs, output), 'schema') + const to = child(child(inputs, key), 'schema') + if (from === undefined || to === undefined) continue + + const path = `/spec/components/${token(node)}/connections/${token(key)}/fromOutput` + const fromType = child(from, 'type') + const toType = child(to, 'type') + if (fromType !== toType) { + out.push({ + code: 'ERR_INCOMPATIBLE_TYPE', + path, + message: `output "${output}" is ${String(fromType)}, and input "${key}" takes ${String(toType)}`, + }) + continue + } + + // A consumer naming no semanticType has said the value is not specific to + // a backing service, so nothing it receives can contradict that. + const toSemantic = asString(child(to, 'semanticType')) + if (toSemantic === undefined) continue + const fromSemantic = asString(child(from, 'semanticType')) + if (fromSemantic !== toSemantic) { + out.push({ + code: 'ERR_INCOMPATIBLE_SEMANTIC_TYPE', + path, + message: `input "${key}" requires ${toSemantic}, and output "${output}" declares ${fromSemantic ?? 'none'}`, + }) + } + } + } +} + /** * Blueprint §5.2 — first-wins in lexicographic node-name order, and a *differing* * redeclaration is an error rather than a silent discard. An identical one is From 5facc683914c27ea2de424083960091b8a9ac6bc Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Tue, 11 Aug 2026 02:11:29 +0000 Subject: [PATCH 5/6] feat(blueprint): close the graph-rule registry gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The platform's blueprint validator enforces graph rules §7 does not name. Each is answered here, and the two that need no row say so rather than going unmentioned. spec.components is now REQUIRED and must declare at least one node. §1 already made a blueprint a composition of one or more components; the schema accepted `spec: {}`. Answered structurally, so NO_COMPONENTS needs no code of its own — an absent mapping is ERR_MISSING_FIELD and an empty one ERR_INVALID_VALUE. ERR_UNKNOWN_INPUT is the mirror of ERR_UNKNOWN_OUTPUT, and was a plain omission: §4.2 resolved the producer end of a wire and left the consumer end, so a connection could be misspelled at one end only. The regression pin found it immediately — semantic-010-well-formed-item wired DATABASE_URL into a component that declared no such input, and had been passing as "well formed" since it was written. That fixture is corrected here. ERR_UNWIRED_REQUIRED_INPUT covers a required CONNECTION input no connection satisfies. Such an input never reaches the install form, so a graph that leaves one unwired has no later chance to supply it. ERR_COMPONENT_NOT_PUBLISHED gets a capability row and an UNCOVERED entry: publication state is held only by the registry, so a fixture is a tree of files none of which has one. §4.1 says why it reads differently from ERR_UNKNOWN_COMPONENT — a component that exists and is not yet released is a wait rather than a typo. DUPLICATE_WIRE_TARGET gets no row and §4.2 records why: connections is a mapping keyed by the consumer input, so a second wire to one input is a repeated key and already ERR_DUPLICATE_KEY in the parser phase. It is impossible by construction rather than unenforced. INCOMPATIBLE_SEMANTIC_TYPE is answered by the previous commit. Closes #30 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Justin Merrell --- conformance/blueprint/v1/cases.json | 20 ++++++ .../tree/acme-wiki/blueprint.yaml | 7 +- .../tree/acme-wiki/components/web.yaml | 6 ++ .../diagnostics.json | 6 ++ .../metadata.json | 8 +++ .../tree/acme-wiki/blueprint.yaml | 23 +++++++ .../tree/acme-wiki/components/postgres.yaml | 26 +++++++ .../tree/acme-wiki/components/web.yaml | 27 ++++++++ .../tree/acme-wiki/listing.yaml | 11 +++ .../diagnostics.json | 6 ++ .../metadata.json | 8 +++ .../tree/acme-wiki/blueprint.yaml | 19 ++++++ .../tree/acme-wiki/components/postgres.yaml | 26 +++++++ .../tree/acme-wiki/components/web.yaml | 26 +++++++ .../tree/acme-wiki/listing.yaml | 11 +++ .../013-empty-component-graph/case.yaml | 11 +++ .../diagnostics.json | 6 ++ .../013-empty-component-graph/metadata.json | 7 ++ .../014-missing-component-graph/case.yaml | 10 +++ .../diagnostics.json | 6 ++ .../014-missing-component-graph/metadata.json | 7 ++ .../v1/schemas/dist/blueprint.schema.json | 6 +- .../v1/schemas/src/blueprint.schema.json | 6 +- specifications/blueprint/v1/spec.md | 45 +++++++++++- tools/src/conformance.ts | 4 ++ tools/src/semantic.ts | 68 +++++++++++++++++++ 26 files changed, 399 insertions(+), 7 deletions(-) create mode 100644 conformance/blueprint/v1/semantic/014-connection-names-unknown-input/diagnostics.json create mode 100644 conformance/blueprint/v1/semantic/014-connection-names-unknown-input/metadata.json create mode 100644 conformance/blueprint/v1/semantic/014-connection-names-unknown-input/tree/acme-wiki/blueprint.yaml create mode 100644 conformance/blueprint/v1/semantic/014-connection-names-unknown-input/tree/acme-wiki/components/postgres.yaml create mode 100644 conformance/blueprint/v1/semantic/014-connection-names-unknown-input/tree/acme-wiki/components/web.yaml create mode 100644 conformance/blueprint/v1/semantic/014-connection-names-unknown-input/tree/acme-wiki/listing.yaml create mode 100644 conformance/blueprint/v1/semantic/015-required-connection-input-unwired/diagnostics.json create mode 100644 conformance/blueprint/v1/semantic/015-required-connection-input-unwired/metadata.json create mode 100644 conformance/blueprint/v1/semantic/015-required-connection-input-unwired/tree/acme-wiki/blueprint.yaml create mode 100644 conformance/blueprint/v1/semantic/015-required-connection-input-unwired/tree/acme-wiki/components/postgres.yaml create mode 100644 conformance/blueprint/v1/semantic/015-required-connection-input-unwired/tree/acme-wiki/components/web.yaml create mode 100644 conformance/blueprint/v1/semantic/015-required-connection-input-unwired/tree/acme-wiki/listing.yaml create mode 100644 conformance/blueprint/v1/structural/013-empty-component-graph/case.yaml create mode 100644 conformance/blueprint/v1/structural/013-empty-component-graph/diagnostics.json create mode 100644 conformance/blueprint/v1/structural/013-empty-component-graph/metadata.json create mode 100644 conformance/blueprint/v1/structural/014-missing-component-graph/case.yaml create mode 100644 conformance/blueprint/v1/structural/014-missing-component-graph/diagnostics.json create mode 100644 conformance/blueprint/v1/structural/014-missing-component-graph/metadata.json diff --git a/conformance/blueprint/v1/cases.json b/conformance/blueprint/v1/cases.json index 7030b61..50a986b 100644 --- a/conformance/blueprint/v1/cases.json +++ b/conformance/blueprint/v1/cases.json @@ -82,6 +82,16 @@ "phase": "structural", "path": "structural/012-invalid-node-name" }, + { + "id": "structural-013-empty-component-graph", + "phase": "structural", + "path": "structural/013-empty-component-graph" + }, + { + "id": "structural-014-missing-component-graph", + "phase": "structural", + "path": "structural/014-missing-component-graph" + }, { "id": "semantic-001-connection-names-unknown-role", "phase": "semantic", @@ -146,6 +156,16 @@ "id": "semantic-013-connection-semantic-type-mismatch", "phase": "semantic", "path": "semantic/013-connection-semantic-type-mismatch" + }, + { + "id": "semantic-014-connection-names-unknown-input", + "phase": "semantic", + "path": "semantic/014-connection-names-unknown-input" + }, + { + "id": "semantic-015-required-connection-input-unwired", + "phase": "semantic", + "path": "semantic/015-required-connection-input-unwired" } ] } diff --git a/conformance/blueprint/v1/semantic/010-well-formed-item/tree/acme-wiki/blueprint.yaml b/conformance/blueprint/v1/semantic/010-well-formed-item/tree/acme-wiki/blueprint.yaml index c57bd49..71397e0 100644 --- a/conformance/blueprint/v1/semantic/010-well-formed-item/tree/acme-wiki/blueprint.yaml +++ b/conformance/blueprint/v1/semantic/010-well-formed-item/tree/acme-wiki/blueprint.yaml @@ -4,8 +4,11 @@ # # The slug is the directory name, the version matches the listing, both # component documents are referenced, both references resolve inside the item, -# the connection names an output `db` really publishes, and the two components -# declare `adminPassword` identically — which §5.2 absorbs rather than reports. +# the connection names an output `db` really publishes and an input `web` +# really declares, the two ends of that wire agree on type and semanticType, +# `web` has no other required CONNECTION input left unwired, and the two +# components declare `adminPassword` identically — which §5.2 absorbs rather +# than reports. specVersion: v1 kind: BLUEPRINT metadata: diff --git a/conformance/blueprint/v1/semantic/010-well-formed-item/tree/acme-wiki/components/web.yaml b/conformance/blueprint/v1/semantic/010-well-formed-item/tree/acme-wiki/components/web.yaml index 6167d2d..e7a72c1 100644 --- a/conformance/blueprint/v1/semantic/010-well-formed-item/tree/acme-wiki/components/web.yaml +++ b/conformance/blueprint/v1/semantic/010-well-formed-item/tree/acme-wiki/components/web.yaml @@ -15,6 +15,12 @@ spec: visibility: PRIVATE contract: inputs: + DATABASE_URL: + schema: + type: STRING + isSensitive: true + suppliedBy: CONNECTION + ui: null adminPassword: schema: type: STRING diff --git a/conformance/blueprint/v1/semantic/014-connection-names-unknown-input/diagnostics.json b/conformance/blueprint/v1/semantic/014-connection-names-unknown-input/diagnostics.json new file mode 100644 index 0000000..73bc5fe --- /dev/null +++ b/conformance/blueprint/v1/semantic/014-connection-names-unknown-input/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_UNKNOWN_INPUT", + "path": "/spec/components/web/connections/DATABASE_URI" + } +] diff --git a/conformance/blueprint/v1/semantic/014-connection-names-unknown-input/metadata.json b/conformance/blueprint/v1/semantic/014-connection-names-unknown-input/metadata.json new file mode 100644 index 0000000..4d107dd --- /dev/null +++ b/conformance/blueprint/v1/semantic/014-connection-names-unknown-input/metadata.json @@ -0,0 +1,8 @@ +{ + "id": "semantic-014-connection-names-unknown-input", + "phase": "semantic", + "expected": "fail", + "clause": "specifications/blueprint/v1/spec.md#connections", + "summary": "A connection whose key names no input of the consuming component is rejected.", + "document": "acme-wiki/blueprint.yaml" +} diff --git a/conformance/blueprint/v1/semantic/014-connection-names-unknown-input/tree/acme-wiki/blueprint.yaml b/conformance/blueprint/v1/semantic/014-connection-names-unknown-input/tree/acme-wiki/blueprint.yaml new file mode 100644 index 0000000..f47da10 --- /dev/null +++ b/conformance/blueprint/v1/semantic/014-connection-names-unknown-input/tree/acme-wiki/blueprint.yaml @@ -0,0 +1,23 @@ +# `db` publishes `connectionString` and `web` declares an input called +# `DATABASE_URL`, but the wire is keyed `DATABASE_URI`. The producer end +# resolves, so ERR_UNKNOWN_OUTPUT does not fire — only the consumer end is +# misspelled, which is the half nothing checked before. +specVersion: v1 +kind: BLUEPRINT +metadata: + slug: acme-wiki + version: 1 +spec: + components: + db: + component: ./components/postgres.yaml + size: general.standard.small + connections: {} + web: + component: ./components/web.yaml + size: general.standard.small + connections: + DATABASE_URI: + fromRole: db + fromOutput: connectionString + parameters: {} diff --git a/conformance/blueprint/v1/semantic/014-connection-names-unknown-input/tree/acme-wiki/components/postgres.yaml b/conformance/blueprint/v1/semantic/014-connection-names-unknown-input/tree/acme-wiki/components/postgres.yaml new file mode 100644 index 0000000..f430be5 --- /dev/null +++ b/conformance/blueprint/v1/semantic/014-connection-names-unknown-input/tree/acme-wiki/components/postgres.yaml @@ -0,0 +1,26 @@ +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: {} + outputs: + connectionString: + schema: + type: STRING + format: CONNECTION_STRING + semanticType: POSTGRES + isSensitive: true + valueFrom: DERIVED + value: null diff --git a/conformance/blueprint/v1/semantic/014-connection-names-unknown-input/tree/acme-wiki/components/web.yaml b/conformance/blueprint/v1/semantic/014-connection-names-unknown-input/tree/acme-wiki/components/web.yaml new file mode 100644 index 0000000..40d0911 --- /dev/null +++ b/conformance/blueprint/v1/semantic/014-connection-names-unknown-input/tree/acme-wiki/components/web.yaml @@ -0,0 +1,27 @@ +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: + DATABASE_URL: + schema: + type: STRING + format: CONNECTION_STRING + semanticType: POSTGRES + isSensitive: true + suppliedBy: CONNECTION + isRequired: false + ui: null + outputs: {} diff --git a/conformance/blueprint/v1/semantic/014-connection-names-unknown-input/tree/acme-wiki/listing.yaml b/conformance/blueprint/v1/semantic/014-connection-names-unknown-input/tree/acme-wiki/listing.yaml new file mode 100644 index 0000000..da5eed4 --- /dev/null +++ b/conformance/blueprint/v1/semantic/014-connection-names-unknown-input/tree/acme-wiki/listing.yaml @@ -0,0 +1,11 @@ +specVersion: v1 +kind: LISTING +metadata: + slug: acme-wiki + version: 1 +spec: + listingKind: BLUEPRINT + displayName: Acme Wiki + summary: A wiki backed by PostgreSQL + category: PRODUCTIVITY + lifecycleStage: STABLE diff --git a/conformance/blueprint/v1/semantic/015-required-connection-input-unwired/diagnostics.json b/conformance/blueprint/v1/semantic/015-required-connection-input-unwired/diagnostics.json new file mode 100644 index 0000000..44bfa11 --- /dev/null +++ b/conformance/blueprint/v1/semantic/015-required-connection-input-unwired/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_UNWIRED_REQUIRED_INPUT", + "path": "/spec/components/web/connections" + } +] diff --git a/conformance/blueprint/v1/semantic/015-required-connection-input-unwired/metadata.json b/conformance/blueprint/v1/semantic/015-required-connection-input-unwired/metadata.json new file mode 100644 index 0000000..a1b7178 --- /dev/null +++ b/conformance/blueprint/v1/semantic/015-required-connection-input-unwired/metadata.json @@ -0,0 +1,8 @@ +{ + "id": "semantic-015-required-connection-input-unwired", + "phase": "semantic", + "expected": "fail", + "clause": "specifications/blueprint/v1/spec.md#connections", + "summary": "A required CONNECTION input that no connection satisfies is rejected.", + "document": "acme-wiki/blueprint.yaml" +} diff --git a/conformance/blueprint/v1/semantic/015-required-connection-input-unwired/tree/acme-wiki/blueprint.yaml b/conformance/blueprint/v1/semantic/015-required-connection-input-unwired/tree/acme-wiki/blueprint.yaml new file mode 100644 index 0000000..b26b636 --- /dev/null +++ b/conformance/blueprint/v1/semantic/015-required-connection-input-unwired/tree/acme-wiki/blueprint.yaml @@ -0,0 +1,19 @@ +# `web` requires DATABASE_URL over a connection, and declares none. A +# CONNECTION input never reaches the install form, so nothing downstream can +# supply it — the composition is already unsatisfiable here. +specVersion: v1 +kind: BLUEPRINT +metadata: + slug: acme-wiki + version: 1 +spec: + components: + db: + component: ./components/postgres.yaml + size: general.standard.small + connections: {} + web: + component: ./components/web.yaml + size: general.standard.small + connections: {} + parameters: {} diff --git a/conformance/blueprint/v1/semantic/015-required-connection-input-unwired/tree/acme-wiki/components/postgres.yaml b/conformance/blueprint/v1/semantic/015-required-connection-input-unwired/tree/acme-wiki/components/postgres.yaml new file mode 100644 index 0000000..f430be5 --- /dev/null +++ b/conformance/blueprint/v1/semantic/015-required-connection-input-unwired/tree/acme-wiki/components/postgres.yaml @@ -0,0 +1,26 @@ +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: {} + outputs: + connectionString: + schema: + type: STRING + format: CONNECTION_STRING + semanticType: POSTGRES + isSensitive: true + valueFrom: DERIVED + value: null diff --git a/conformance/blueprint/v1/semantic/015-required-connection-input-unwired/tree/acme-wiki/components/web.yaml b/conformance/blueprint/v1/semantic/015-required-connection-input-unwired/tree/acme-wiki/components/web.yaml new file mode 100644 index 0000000..8b97f2a --- /dev/null +++ b/conformance/blueprint/v1/semantic/015-required-connection-input-unwired/tree/acme-wiki/components/web.yaml @@ -0,0 +1,26 @@ +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: + DATABASE_URL: + schema: + type: STRING + format: CONNECTION_STRING + semanticType: POSTGRES + isSensitive: true + suppliedBy: CONNECTION + ui: null + outputs: {} diff --git a/conformance/blueprint/v1/semantic/015-required-connection-input-unwired/tree/acme-wiki/listing.yaml b/conformance/blueprint/v1/semantic/015-required-connection-input-unwired/tree/acme-wiki/listing.yaml new file mode 100644 index 0000000..da5eed4 --- /dev/null +++ b/conformance/blueprint/v1/semantic/015-required-connection-input-unwired/tree/acme-wiki/listing.yaml @@ -0,0 +1,11 @@ +specVersion: v1 +kind: LISTING +metadata: + slug: acme-wiki + version: 1 +spec: + listingKind: BLUEPRINT + displayName: Acme Wiki + summary: A wiki backed by PostgreSQL + category: PRODUCTIVITY + lifecycleStage: STABLE diff --git a/conformance/blueprint/v1/structural/013-empty-component-graph/case.yaml b/conformance/blueprint/v1/structural/013-empty-component-graph/case.yaml new file mode 100644 index 0000000..ce17042 --- /dev/null +++ b/conformance/blueprint/v1/structural/013-empty-component-graph/case.yaml @@ -0,0 +1,11 @@ +# A blueprint is the unit of deployment, and §1 makes it a composition of one +# or more component documents. An empty graph deploys nothing while claiming +# to be that unit. +specVersion: v1 +kind: BLUEPRINT +metadata: + slug: acme-wiki + version: 1 +spec: + components: {} + parameters: {} diff --git a/conformance/blueprint/v1/structural/013-empty-component-graph/diagnostics.json b/conformance/blueprint/v1/structural/013-empty-component-graph/diagnostics.json new file mode 100644 index 0000000..e28b1a4 --- /dev/null +++ b/conformance/blueprint/v1/structural/013-empty-component-graph/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_INVALID_VALUE", + "path": "/spec/components" + } +] diff --git a/conformance/blueprint/v1/structural/013-empty-component-graph/metadata.json b/conformance/blueprint/v1/structural/013-empty-component-graph/metadata.json new file mode 100644 index 0000000..323e431 --- /dev/null +++ b/conformance/blueprint/v1/structural/013-empty-component-graph/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-013-empty-component-graph", + "phase": "structural", + "expected": "fail", + "clause": "specifications/blueprint/v1/spec.md#components", + "summary": "A blueprint declaring no node is rejected." +} diff --git a/conformance/blueprint/v1/structural/014-missing-component-graph/case.yaml b/conformance/blueprint/v1/structural/014-missing-component-graph/case.yaml new file mode 100644 index 0000000..0589fc5 --- /dev/null +++ b/conformance/blueprint/v1/structural/014-missing-component-graph/case.yaml @@ -0,0 +1,10 @@ +# An absent graph and an empty one fail differently, and both fail. `spec` +# without `components` is a missing required property rather than a bound +# violation. +specVersion: v1 +kind: BLUEPRINT +metadata: + slug: acme-wiki + version: 1 +spec: + parameters: {} diff --git a/conformance/blueprint/v1/structural/014-missing-component-graph/diagnostics.json b/conformance/blueprint/v1/structural/014-missing-component-graph/diagnostics.json new file mode 100644 index 0000000..759c334 --- /dev/null +++ b/conformance/blueprint/v1/structural/014-missing-component-graph/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_MISSING_FIELD", + "path": "/spec" + } +] diff --git a/conformance/blueprint/v1/structural/014-missing-component-graph/metadata.json b/conformance/blueprint/v1/structural/014-missing-component-graph/metadata.json new file mode 100644 index 0000000..a2ed436 --- /dev/null +++ b/conformance/blueprint/v1/structural/014-missing-component-graph/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-014-missing-component-graph", + "phase": "structural", + "expected": "fail", + "clause": "specifications/blueprint/v1/spec.md#components", + "summary": "A blueprint omitting spec.components is rejected." +} diff --git a/specifications/blueprint/v1/schemas/dist/blueprint.schema.json b/specifications/blueprint/v1/schemas/dist/blueprint.schema.json index 24cae1a..5c6fdb8 100644 --- a/specifications/blueprint/v1/schemas/dist/blueprint.schema.json +++ b/specifications/blueprint/v1/schemas/dist/blueprint.schema.json @@ -431,10 +431,11 @@ "additionalProperties": false, "properties": { "components": { - "description": "Component references composing the graph, keyed by graph-local node name. The name is what a connection's fromRole refers to and what the parameter merge orders the graph by; it means nothing outside this document.", + "description": "Component references composing the graph, keyed by graph-local node name. The name is what a connection's fromRole refers to and what the parameter merge orders the graph by; it means nothing outside this document. At least one node is required — spec.md §1 makes a blueprint a composition of one or more components, and an empty graph deploys nothing.", "additionalProperties": { "$ref": "#/$defs/BlueprintNode" }, + "minProperties": 1, "propertyNames": { "pattern": "^[a-z][a-z0-9-]{0,61}[a-z0-9]$" }, @@ -448,6 +449,9 @@ "type": "object" } }, + "required": [ + "components" + ], "type": "object" } } diff --git a/specifications/blueprint/v1/schemas/src/blueprint.schema.json b/specifications/blueprint/v1/schemas/src/blueprint.schema.json index 7d62391..ef689f1 100644 --- a/specifications/blueprint/v1/schemas/src/blueprint.schema.json +++ b/specifications/blueprint/v1/schemas/src/blueprint.schema.json @@ -434,7 +434,8 @@ "additionalProperties": { "$ref": "#/$defs/BlueprintNode" }, - "description": "Component references composing the graph, keyed by graph-local node name. The name is what a connection's fromRole refers to and what the parameter merge orders the graph by; it means nothing outside this document.", + "description": "Component references composing the graph, keyed by graph-local node name. The name is what a connection's fromRole refers to and what the parameter merge orders the graph by; it means nothing outside this document. At least one node is required — spec.md §1 makes a blueprint a composition of one or more components, and an empty graph deploys nothing.", + "minProperties": 1, "propertyNames": { "pattern": "^[a-z][a-z0-9-]{0,61}[a-z0-9]$" }, @@ -448,6 +449,9 @@ "type": "object" } }, + "required": [ + "components" + ], "type": "object" } } diff --git a/specifications/blueprint/v1/spec.md b/specifications/blueprint/v1/spec.md index 1b4d903..2d0de61 100644 --- a/specifications/blueprint/v1/spec.md +++ b/specifications/blueprint/v1/spec.md @@ -111,6 +111,12 @@ for the same reason. reference. The node name is the identifier used by connections; it is local to this blueprint and carries no meaning outside it. +`spec.components` is REQUIRED and MUST declare at least one node. [§1](#scope) +makes a blueprint a composition of one or more component documents, and an +empty graph deploys nothing while claiming to be the unit of deployment. Both +halves are `structural`: an absent mapping is `ERR_MISSING_FIELD` and an empty +one `ERR_INVALID_VALUE`. + A node name MUST match `^[a-z][a-z0-9-]{0,61}[a-z0-9]$`, the same grammar `metadata.slug` uses. Uniqueness needs no rule of its own: `spec.components` is a mapping, so a repeated node name is `ERR_DUPLICATE_KEY` in the `parser` @@ -186,6 +192,14 @@ unresolvable; it has not been given the means to check. A published reference naming no component, or naming one without the requested `componentVersion`, MUST be rejected with `ERR_UNKNOWN_COMPONENT`. +Resolving is not the whole of it. A component the catalog holds but has not +published is not deployable, and a reference to one MUST be rejected with +`ERR_COMPONENT_NOT_PUBLISHED`. What counts as published is the registry's to +define — this contract says only that the two failures read differently to an +author, since a component that exists and is not yet released is a wait rather +than a typo. Both are `capability`, for the reason above, so neither can carry +a fixture. + A context holding no filesystem location for the document — a document submitted over an API, for example — has no base directory to resolve against. It MUST reject a repo-local reference with `ERR_COMPONENT_NOT_FOUND` rather @@ -225,9 +239,31 @@ Both are `semantic`. The first needs only this document; the second needs the referenced component document, which a repo-local reference makes readable without a network. -The consumer end needs no rule of its own for uniqueness. The map key names the -input being filled and the enclosing node names the consumer, so one input -cannot take two wires — the mapping already makes that structural. +**The consumer end MUST resolve too**, and it is named rather than written: the +map key is the input being filled, and the enclosing node is the consumer. A +key naming no input of the component that node deploys is `ERR_UNKNOWN_INPUT` +— the mirror of `ERR_UNKNOWN_OUTPUT`, needing the same referenced document and +carrying the same argument. A wire whose two ends are each checked and whose +consumer end is not is a wire that can be misspelled at one end only. + +Uniqueness needs no rule. One input cannot take two wires, because the map key +is what names it — a second wire to the same input is a repeated mapping key +and therefore `ERR_DUPLICATE_KEY` in the `parser` phase. That is a property of +how a connection is spelled, not an omission from this section. + +**A required `CONNECTION` input MUST be wired.** An input declaring +`suppliedBy: CONNECTION` is satisfied by a wire and by nothing else +([component §6.1](../../component/v1/spec.md#inputs)) — it never reaches the +install form, so a graph that leaves one unwired has no later chance to supply +it. Where such an input is also `isRequired` — which is its default — and no +connection on that node names it, the blueprint is rejected with +`ERR_UNWIRED_REQUIRED_INPUT`, anchored at the node's `connections`. An optional +`CONNECTION` input MAY be left unwired. + +**What v1 does not constrain.** Nothing stops a connection filling an input +whose `suppliedBy` is `USER`. A wire and the install form would then both claim +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 @@ -399,6 +435,9 @@ family adds: | `ERR_UNKNOWN_COMPONENT` | `capability` | A published `component` reference names no component, or no such `componentVersion`. | | `ERR_UNKNOWN_ROLE` | `semantic` | A connection's `fromRole` names no node in this blueprint. | | `ERR_UNKNOWN_OUTPUT` | `semantic` | A connection's `fromOutput` names no output of the referenced component. | +| `ERR_UNKNOWN_INPUT` | `semantic` | A connection's map key names no input of the consuming node's component. | +| `ERR_UNWIRED_REQUIRED_INPUT` | `semantic` | A node's required `CONNECTION` input is satisfied by no connection. | +| `ERR_COMPONENT_NOT_PUBLISHED` | `capability` | A published `component` reference resolves to a component that is not in a published state. | | `ERR_INCOMPATIBLE_TYPE` | `semantic` | A connection joins an output and an input whose `schema.type`s differ. | | `ERR_INCOMPATIBLE_SEMANTIC_TYPE` | `semantic` | A connection joins an output and an input whose `schema.semanticType`s disagree. | | `ERR_DEPENDENCY_CYCLE` | `semantic` | The connection graph contains a cycle. | diff --git a/tools/src/conformance.ts b/tools/src/conformance.ts index d667787..e5d3094 100644 --- a/tools/src/conformance.ts +++ b/tools/src/conformance.ts @@ -437,6 +437,10 @@ const UNCOVERED: ReadonlyMap = new Map([ 'ERR_VERSION_NOT_MONOTONIC', 'capability — comparing a version against the lineage it extends needs the catalog, and a fixture is one document with no previous release to be greater than', ], + [ + 'ERR_COMPONENT_NOT_PUBLISHED', + 'capability — only the registry holds publication state, so deciding it needs the catalog, and a fixture is a tree of files none of which has one', + ], ]) /** diff --git a/tools/src/semantic.ts b/tools/src/semantic.ts index a11a1d8..3911448 100644 --- a/tools/src/semantic.ts +++ b/tools/src/semantic.ts @@ -683,6 +683,8 @@ function checkGraphAgainstItem( checkUnreferencedComponents(itemRoot, documentPath, referenced, out) checkConnectionOutputs(components, resolved, out) + checkConnectionInputs(components, resolved, out) + checkRequiredConnections(components, resolved, out) checkConnectionCompatibility(components, resolved, out) checkInputMerge(components, resolved, out) } @@ -740,6 +742,72 @@ function checkConnectionOutputs( } } +/** The inputs a node's component declares, keyed by input name. */ +function inputsOf(component: Json | undefined): Json | undefined { + return child(child(child(component, 'spec'), 'contract'), 'inputs') +} + +/** + * Blueprint §4.2 — a connection's map key MUST name an input of the component + * the *consuming* node deploys. The mirror of `ERR_UNKNOWN_OUTPUT`: a wire whose + * two ends are each checked and whose consumer end is not can be misspelled at + * one end only. + */ +function checkConnectionInputs( + components: Json | undefined, + resolved: Map, + out: Diagnostic[], +): void { + for (const node of keysOf(components)) { + const consumer = resolved.get(node) + // An unresolved consumer is already ERR_COMPONENT_NOT_FOUND, or is a + // published reference this phase may not resolve at all. + if (consumer === undefined) continue + + const declared = keysOf(inputsOf(consumer)) + for (const key of keysOf(child(child(components, node), 'connections'))) { + if (declared.includes(key)) continue + out.push({ + code: 'ERR_UNKNOWN_INPUT', + path: `/spec/components/${token(node)}/connections/${token(key)}`, + message: `"${key}" is not an input of the component "${node}" deploys`, + }) + } + } +} + +/** + * Blueprint §4.2 — a required `CONNECTION` input MUST be wired. + * + * `isRequired` defaults to true, so an absent key is a required input. A + * `CONNECTION` input never reaches the install form, so a graph that leaves one + * unwired has no later chance to supply it. + */ +function checkRequiredConnections( + components: Json | undefined, + resolved: Map, + out: Diagnostic[], +): void { + for (const node of keysOf(components)) { + const consumer = resolved.get(node) + if (consumer === undefined) continue + + const wired = new Set(keysOf(child(child(components, node), 'connections'))) + const inputs = inputsOf(consumer) + for (const key of keysOf(inputs)) { + const input = child(inputs, key) + if (child(input, 'suppliedBy') !== 'CONNECTION') continue + if (child(input, 'isRequired') === false) continue + if (wired.has(key)) continue + out.push({ + code: 'ERR_UNWIRED_REQUIRED_INPUT', + path: `/spec/components/${token(node)}/connections`, + message: `required input "${key}" of node "${node}" is satisfied by no connection`, + }) + } + } +} + /** * Blueprint §4.2 — the two ends of a connection MUST fit. * From 86d69cdde16461c71349b63dd4ed30dcbd15bac2 Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Tue, 11 Aug 2026 02:12:25 +0000 Subject: [PATCH 6/6] docs(component): retire the closed TODO entry from known debt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §10 said "every section above marked TODO is currently defined only by the schema's structure". No section is marked TODO any more — §5.3 and §6.2 were the last two, and both now carry prose. What is left is the other half of the seeding, and it is named rather than implied: schema descriptions that speak the platform's vocabulary. "Resolved server-side at snapshot compute" names a pipeline stage, and a Compute Profile slug names a vocabulary this repository does not publish. Neither is normative, which is why it is debt rather than a defect — but a reader who cannot resolve the words is still being sent somewhere they cannot reach. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Justin Merrell --- specifications/component/v1/spec.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/specifications/component/v1/spec.md b/specifications/component/v1/spec.md index 941a956..2e31655 100644 --- a/specifications/component/v1/spec.md +++ b/specifications/component/v1/spec.md @@ -677,8 +677,15 @@ that is declared to fail is a conformance failure. This schema was seeded from the platform's Pydantic-generated catalog schema. The naming that arrived with it — `$defs` keys carrying `Seed…`/`…Request` affixes, and generated `title` values like `Specversion` — has been cleaned, -and `tools/src/lint.ts` now rejects both. The remaining debt MUST be resolved -before v1 is declared stable: - -1. Every section above marked TODO is currently defined only by the schema's - structure, which is not a substitute for prose. +and `tools/src/lint.ts` now rejects both. No section of this document is marked +TODO any longer: every rule it states is stated in prose, and the schema +implements the prose rather than standing in for it. + +One debt remains, and it MUST be resolved before v1 is declared stable. Some +schema `description` fields still speak the platform's vocabulary rather than +this contract's — "resolved server-side at snapshot compute" names a pipeline +stage a reader outside `musher-dev/platform` cannot look up, and `size` values +like `general.standard.small` name a Compute Profile vocabulary this repository +does not publish. Descriptions are explanatory rather than normative, so nothing +in this document turns on them; a reader who cannot resolve the words is still +being told to go somewhere they cannot reach.