diff --git a/GOVERNANCE.md b/GOVERNANCE.md index f89187e..f082ed7 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -58,6 +58,14 @@ Any change that would cause a previously valid document to fail validation is a Adding a required field, narrowing an enum, tightening a pattern, and removing a field are all breaking. Adding an optional field is not. +One narrow exception applies before a family's first release. +[ADR 0005](docs/adr/0005-platform-divergence-reconciliation.md) §1 sets it out: +while a family has no published version, requirements 2 and 3 do not apply, +because the compatibility guarantee is stated against a released version and +there is none to run from. Requirement 1 still applies, as does declaring the +change as breaking. The exception closes for a family the moment its first tag +is created. + ## Changing a controlled vocabulary A field whose value comes from a closed `enum` is a controlled vocabulary this diff --git a/README.md b/README.md index 8c1522f..61f8980 100644 --- a/README.md +++ b/README.md @@ -109,10 +109,17 @@ overwritten. ## Status -`v1` is **pre-stable**. The schemas here were seeded from the platform's -generated catalog schemas and are being brought up to specification quality. -The `$defs` names are now settled; the normative prose is not — see the `TODO` -sections in each `spec.md`, and the open issues. +`v1` is **pre-stable**, and nothing has been released — no tag exists and every +family reads `0.0.0`. The schemas here were seeded from the platform's +generated catalog schemas and have been brought up to specification quality: +the `$defs` names are settled, no `spec.md` carries a `TODO` section, and every +rule each one states is stated in prose with the schema implementing it. + +What remains is recorded rather than outstanding. Each family's **Known debt** +section names its own gaps, and +[ADR 0005](docs/adr/0005-platform-divergence-reconciliation.md) §1 sets out the +window — open only until a family's first tag — in which a rule that rejects a +previously valid document can still be added without a new major. ## License diff --git a/conformance/blueprint/v1/cases.json b/conformance/blueprint/v1/cases.json index a677f20..995b806 100644 --- a/conformance/blueprint/v1/cases.json +++ b/conformance/blueprint/v1/cases.json @@ -188,9 +188,9 @@ "path": "semantic/010-well-formed-item" }, { - "id": "semantic-011-three-node-cycle-reporting", + "id": "semantic-011-three-node-cycle", "phase": "semantic", - "path": "semantic/011-three-node-cycle-reporting" + "path": "semantic/011-three-node-cycle" }, { "id": "semantic-012-connection-type-mismatch", @@ -241,6 +241,11 @@ "id": "semantic-021-published-reference-suppresses-unbound", "phase": "semantic", "path": "semantic/021-published-reference-suppresses-unbound" + }, + { + "id": "semantic-022-identical-input-redeclaration", + "phase": "semantic", + "path": "semantic/022-identical-input-redeclaration" } ] } diff --git a/conformance/blueprint/v1/semantic/002-connection-cycle/case.yaml b/conformance/blueprint/v1/semantic/002-connection-cycle/case.yaml index 36c9fae..afa6bcd 100644 --- a/conformance/blueprint/v1/semantic/002-connection-cycle/case.yaml +++ b/conformance/blueprint/v1/semantic/002-connection-cycle/case.yaml @@ -1,12 +1,10 @@ -# Mutual discovery: `api` reads `web`'s address and `web` reads `api`'s. Every -# output here is a function of its own node, so a two-pass resolver would -# settle this without complaint — the graph is rejected because a -# specification that permits cycles obliges every implementation to be that -# resolver, and forecloses any later rule needing an order. +# Mutual discovery: `api` reads `web`'s address and `web` reads `api`'s. This +# is the composition §4.2 exists to permit, and the regression pin for it. # -# The walk is reported from `api`, the lexicographically smallest node in the -# cycle, so that two implementations finding this cycle name it identically: -# api -> web -> api. +# Every output here is a function of its own node — component §6.2 — so both +# are resolvable before either edge is bound, and the cycle costs an +# implementation nothing. Reintroducing an acyclicity rule rejects this +# document, which is why doing so after publication is a major version. specVersion: v1 kind: BLUEPRINT metadata: diff --git a/conformance/blueprint/v1/semantic/002-connection-cycle/diagnostics.json b/conformance/blueprint/v1/semantic/002-connection-cycle/diagnostics.json deleted file mode 100644 index ab154e3..0000000 --- a/conformance/blueprint/v1/semantic/002-connection-cycle/diagnostics.json +++ /dev/null @@ -1,6 +0,0 @@ -[ - { - "code": "ERR_DEPENDENCY_CYCLE", - "path": "/spec/components/api/connections" - } -] diff --git a/conformance/blueprint/v1/semantic/002-connection-cycle/metadata.json b/conformance/blueprint/v1/semantic/002-connection-cycle/metadata.json index cd9eb5d..b345e24 100644 --- a/conformance/blueprint/v1/semantic/002-connection-cycle/metadata.json +++ b/conformance/blueprint/v1/semantic/002-connection-cycle/metadata.json @@ -1,7 +1,7 @@ { "id": "semantic-002-connection-cycle", "phase": "semantic", - "expected": "fail", + "expected": "pass", "clause": "specifications/blueprint/v1/spec.md#connections", - "summary": "A cyclic connection graph is rejected, and the walk is reported from its smallest node." + "summary": "A cyclic connection graph validates: mutual service discovery is expressible." } diff --git a/conformance/blueprint/v1/semantic/011-three-node-cycle-reporting/diagnostics.json b/conformance/blueprint/v1/semantic/011-three-node-cycle-reporting/diagnostics.json deleted file mode 100644 index 8c7de87..0000000 --- a/conformance/blueprint/v1/semantic/011-three-node-cycle-reporting/diagnostics.json +++ /dev/null @@ -1,6 +0,0 @@ -[ - { - "code": "ERR_DEPENDENCY_CYCLE", - "path": "/spec/components/cache/connections" - } -] diff --git a/conformance/blueprint/v1/semantic/011-three-node-cycle-reporting/metadata.json b/conformance/blueprint/v1/semantic/011-three-node-cycle-reporting/metadata.json deleted file mode 100644 index 1f44bfc..0000000 --- a/conformance/blueprint/v1/semantic/011-three-node-cycle-reporting/metadata.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "id": "semantic-011-three-node-cycle-reporting", - "phase": "semantic", - "expected": "fail", - "clause": "specifications/blueprint/v1/spec.md#connections", - "summary": "A cycle is reported from the lexicographically smallest node, not the first declared." -} diff --git a/conformance/blueprint/v1/semantic/011-three-node-cycle-reporting/case.yaml b/conformance/blueprint/v1/semantic/011-three-node-cycle/case.yaml similarity index 52% rename from conformance/blueprint/v1/semantic/011-three-node-cycle-reporting/case.yaml rename to conformance/blueprint/v1/semantic/011-three-node-cycle/case.yaml index 50285eb..5e67f12 100644 --- a/conformance/blueprint/v1/semantic/011-three-node-cycle-reporting/case.yaml +++ b/conformance/blueprint/v1/semantic/011-three-node-cycle/case.yaml @@ -1,13 +1,10 @@ -# A three-node cycle written in an order that is not its reporting order. The -# nodes appear as queue, db, cache and the walk is required to begin at `cache`, -# the lexicographically smallest node in the cycle. +# A three-node cycle: queue -> db -> cache -> queue. # -# This is the case semantic-002 cannot make: with two nodes, "smallest first" -# and "first declared" can coincide by luck. Here they do not, so an -# implementation that reports from wherever its traversal happened to start -# produces `queue -> db -> cache -> queue` and fails — which is the point. -# The node names in a diagnostic are comparable across implementations only -# because this rule fixes them. +# This is the case semantic-002 cannot make. A two-node cycle is legible on +# sight, and an implementation could conceivably permit one by accident. Three +# nodes, declared in an order that is not the order the edges run in, is a +# cycle only a real traversal finds — so this pins that §4.2 permits cycles as +# such rather than tolerating the smallest of them. specVersion: v1 kind: BLUEPRINT metadata: diff --git a/conformance/blueprint/v1/semantic/011-three-node-cycle/metadata.json b/conformance/blueprint/v1/semantic/011-three-node-cycle/metadata.json new file mode 100644 index 0000000..f53b90c --- /dev/null +++ b/conformance/blueprint/v1/semantic/011-three-node-cycle/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "semantic-011-three-node-cycle", + "phase": "semantic", + "expected": "pass", + "clause": "specifications/blueprint/v1/spec.md#connections", + "summary": "A cycle longer than two nodes validates, and no traversal order is imposed on it." +} diff --git a/conformance/blueprint/v1/semantic/022-identical-input-redeclaration/metadata.json b/conformance/blueprint/v1/semantic/022-identical-input-redeclaration/metadata.json new file mode 100644 index 0000000..c5eb030 --- /dev/null +++ b/conformance/blueprint/v1/semantic/022-identical-input-redeclaration/metadata.json @@ -0,0 +1,8 @@ +{ + "id": "semantic-022-identical-input-redeclaration", + "phase": "semantic", + "expected": "pass", + "clause": "specifications/blueprint/v1/spec.md#merge", + "summary": "An identical redeclaration is absorbed: ui, isRequired, key order and written-out defaults are not conflicts.", + "document": "acme-wiki/blueprint.yaml" +} diff --git a/conformance/blueprint/v1/semantic/022-identical-input-redeclaration/tree/acme-wiki/blueprint.yaml b/conformance/blueprint/v1/semantic/022-identical-input-redeclaration/tree/acme-wiki/blueprint.yaml new file mode 100644 index 0000000..8d9100a --- /dev/null +++ b/conformance/blueprint/v1/semantic/022-identical-input-redeclaration/tree/acme-wiki/blueprint.yaml @@ -0,0 +1,25 @@ +# The other half of §5.2. semantic-009 pins that a *differing* redeclaration is +# rejected; this pins that an identical one is absorbed in silence, and that the +# three things §5.2 says are not compared are in fact not compared. +# +# `api` and `db` both declare `adminPassword`. They differ in `ui` (a different +# label), in `isRequired`, in the order the schema's keys are written, and in +# whether the schema's defaults are written out or left implicit. None of that +# is a conflict: the two schemas are equal once defaults are applied, which is +# the test §5.2 states. +specVersion: v1 +kind: BLUEPRINT +metadata: + slug: acme-wiki + version: 1 +spec: + components: + api: + component: ./components/api.yaml + size: general.standard.small + connections: {} + db: + component: ./components/postgres.yaml + size: general.standard.small + connections: {} + parameters: {} diff --git a/conformance/blueprint/v1/semantic/022-identical-input-redeclaration/tree/acme-wiki/components/api.yaml b/conformance/blueprint/v1/semantic/022-identical-input-redeclaration/tree/acme-wiki/components/api.yaml new file mode 100644 index 0000000..dfc6ca3 --- /dev/null +++ b/conformance/blueprint/v1/semantic/022-identical-input-redeclaration/tree/acme-wiki/components/api.yaml @@ -0,0 +1,28 @@ +# `api` sorts first, so this is the declaration that stands. It leaves every +# schema default implicit. +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: + adminPassword: + schema: + type: STRING + isSensitive: true + suppliedBy: USER + isRequired: true + ui: + label: Administrator password + outputs: {} diff --git a/conformance/blueprint/v1/semantic/022-identical-input-redeclaration/tree/acme-wiki/components/postgres.yaml b/conformance/blueprint/v1/semantic/022-identical-input-redeclaration/tree/acme-wiki/components/postgres.yaml new file mode 100644 index 0000000..88df7c8 --- /dev/null +++ b/conformance/blueprint/v1/semantic/022-identical-input-redeclaration/tree/acme-wiki/components/postgres.yaml @@ -0,0 +1,35 @@ +# The same declaration, written differently. `isSensitive` comes first here and +# `semanticType` is written out at the value it defaults to, so a byte-for-byte +# or key-order-sensitive comparison of the two schema blocks calls these +# different. §5.2's test is equality once defaults are applied, and by that test +# they are the same declaration. +# +# `ui` and `isRequired` both differ, and §5.2 excludes both: they describe how a +# value is asked for, not what it is. +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: + adminPassword: + schema: + isSensitive: true + semanticType: null + type: STRING + suppliedBy: USER + isRequired: false + ui: + label: Database superuser password + outputs: {} diff --git a/conformance/blueprint/v1/semantic/022-identical-input-redeclaration/tree/acme-wiki/listing.yaml b/conformance/blueprint/v1/semantic/022-identical-input-redeclaration/tree/acme-wiki/listing.yaml new file mode 100644 index 0000000..da5eed4 --- /dev/null +++ b/conformance/blueprint/v1/semantic/022-identical-input-redeclaration/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/component/v1/cases.json b/conformance/component/v1/cases.json index 397c14d..1d9b7b0 100644 --- a/conformance/component/v1/cases.json +++ b/conformance/component/v1/cases.json @@ -182,6 +182,31 @@ "phase": "structural", "path": "structural/032-env-var-key-not-uppercase" }, + { + "id": "structural-033-service-without-endpoints", + "phase": "structural", + "path": "structural/033-service-without-endpoints" + }, + { + "id": "structural-034-service-with-empty-endpoints", + "phase": "structural", + "path": "structural/034-service-with-empty-endpoints" + }, + { + "id": "structural-035-public-address-default", + "phase": "structural", + "path": "structural/035-public-address-default" + }, + { + "id": "structural-036-public-port-on-udp-endpoint", + "phase": "structural", + "path": "structural/036-public-port-on-udp-endpoint" + }, + { + "id": "structural-037-public-hostname-default", + "phase": "structural", + "path": "structural/037-public-hostname-default" + }, { "id": "semantic-001-floating-tag-image-reference", "phase": "semantic", @@ -236,6 +261,16 @@ "id": "semantic-011-env-key-claimed-twice", "phase": "semantic", "path": "semantic/011-env-key-claimed-twice" + }, + { + "id": "semantic-012-public-address-on-http-endpoint", + "phase": "semantic", + "path": "semantic/012-public-address-on-http-endpoint" + }, + { + "id": "semantic-013-platform-default-elects-primary", + "phase": "semantic", + "path": "semantic/013-platform-default-elects-primary" } ] } diff --git a/conformance/component/v1/semantic/001-floating-tag-image-reference/case.yaml b/conformance/component/v1/semantic/001-floating-tag-image-reference/case.yaml index 0a087da..782a941 100644 --- a/conformance/component/v1/semantic/001-floating-tag-image-reference/case.yaml +++ b/conformance/component/v1/semantic/001-floating-tag-image-reference/case.yaml @@ -11,3 +11,8 @@ spec: source: type: IMAGE ref: ghcr.io/musher-dev/api:nightly + endpoints: + api: + containerPort: 8080 + protocol: HTTP + visibility: PRIVATE diff --git a/conformance/component/v1/semantic/007-probe-on-endpointless-workload/case.yaml b/conformance/component/v1/semantic/007-probe-on-endpointless-workload/case.yaml index f39eff4..efa1ff1 100644 --- a/conformance/component/v1/semantic/007-probe-on-endpointless-workload/case.yaml +++ b/conformance/component/v1/semantic/007-probe-on-endpointless-workload/case.yaml @@ -1,14 +1,18 @@ -# Section 5 permits a SERVICE to declare no endpoint. A probe on one polls a -# port that does not exist: the election finds no primary here for the -# opposite reason it finds none among two PUBLIC endpoints, and rejects it -# just the same. +# A probe on a workload that declares no endpoint polls a port that does not +# exist: the election finds no primary here for the opposite reason it finds +# none among two PUBLIC endpoints, and rejects it just the same. +# +# The kind is WORKER because §5 now puts this shape beyond a SERVICE, which +# must declare at least one endpoint. The other three kinds declare none and +# may still carry a probe, so the case survives there — which is the whole +# reason §5.2 still has to say what happens when nothing is elected. specVersion: v1 kind: COMPONENT metadata: version: 1 spec: workload: - kind: SERVICE + kind: WORKER source: type: IMAGE ref: nginx:1.29.4-alpine 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 index 084e6e2..310e353 100644 --- a/conformance/component/v1/semantic/010-duplicate-env-var-key/case.yaml +++ b/conformance/component/v1/semantic/010-duplicate-env-var-key/case.yaml @@ -11,6 +11,11 @@ spec: source: type: IMAGE ref: nginx:1.29.4-alpine + endpoints: + web: + containerPort: 8080 + protocol: HTTP + visibility: PRIVATE envVars: - key: LOG_LEVEL value: 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 index b16d4b7..76798d8 100644 --- a/conformance/component/v1/semantic/011-env-key-claimed-twice/case.yaml +++ b/conformance/component/v1/semantic/011-env-key-claimed-twice/case.yaml @@ -12,6 +12,11 @@ spec: source: type: IMAGE ref: postgres:17.10-alpine + endpoints: + db: + containerPort: 5432 + protocol: TCP + visibility: PRIVATE envVars: - key: DATABASE_URL value: diff --git a/conformance/component/v1/semantic/012-public-address-on-http-endpoint/case.yaml b/conformance/component/v1/semantic/012-public-address-on-http-endpoint/case.yaml new file mode 100644 index 0000000..44a0795 --- /dev/null +++ b/conformance/component/v1/semantic/012-public-address-on-http-endpoint/case.yaml @@ -0,0 +1,37 @@ +# The mirror of semantic-009. PUBLIC_ADDRESS derives the host:port a TCP or UDP +# endpoint publishes on an allocated edge port; an HTTP-family endpoint is +# published through the shared ingress as a URL, so there is no such port to +# name. +# +# The endpoint is declared and PUBLIC and still cannot answer, which is the +# same shape semantic-009 has and the reason the two codes name the axis that +# failed rather than sharing one. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + endpoints: + api: + containerPort: 8080 + protocol: HTTP + visibility: PUBLIC + health: + readiness: + path: /healthz + endpoint: api + contract: + inputs: + apiAddress: + schema: + type: STRING + ui: + label: API address + platformDefault: + source: PUBLIC_ADDRESS + endpoint: api diff --git a/conformance/component/v1/semantic/012-public-address-on-http-endpoint/diagnostics.json b/conformance/component/v1/semantic/012-public-address-on-http-endpoint/diagnostics.json new file mode 100644 index 0000000..061c73e --- /dev/null +++ b/conformance/component/v1/semantic/012-public-address-on-http-endpoint/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_ENDPOINT_NOT_L4", + "path": "/spec/contract/inputs/apiAddress/platformDefault/endpoint" + } +] diff --git a/conformance/component/v1/semantic/012-public-address-on-http-endpoint/metadata.json b/conformance/component/v1/semantic/012-public-address-on-http-endpoint/metadata.json new file mode 100644 index 0000000..481b51c --- /dev/null +++ b/conformance/component/v1/semantic/012-public-address-on-http-endpoint/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "semantic-012-public-address-on-http-endpoint", + "phase": "semantic", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#inputs", + "summary": "PUBLIC_ADDRESS naming an HTTP-family endpoint has no edge port to derive." +} diff --git a/conformance/component/v1/semantic/013-platform-default-elects-primary/case.yaml b/conformance/component/v1/semantic/013-platform-default-elects-primary/case.yaml new file mode 100644 index 0000000..1a11ebb --- /dev/null +++ b/conformance/component/v1/semantic/013-platform-default-elects-primary/case.yaml @@ -0,0 +1,35 @@ +# A platform default naming no endpoint. §6.1 makes null select the primary +# §5.2 elects, and the workload's sole PUBLIC endpoint is it — so the L4 rule +# is applied to the elected endpoint exactly as it would be to a named one. +# +# No case exercised the elected path for a platform default before; every other +# platformDefault fixture names its endpoint outright. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: ghcr.io/musher-dev/broker:3.13.7 + endpoints: + amqp: + containerPort: 5672 + protocol: TCP + visibility: PUBLIC + metrics: + containerPort: 9090 + protocol: HTTP + visibility: PRIVATE + contract: + inputs: + brokerAddress: + schema: + type: STRING + ui: + label: Broker address + platformDefault: + source: PUBLIC_ADDRESS + outputs: {} diff --git a/conformance/component/v1/semantic/013-platform-default-elects-primary/metadata.json b/conformance/component/v1/semantic/013-platform-default-elects-primary/metadata.json new file mode 100644 index 0000000..bda3f9d --- /dev/null +++ b/conformance/component/v1/semantic/013-platform-default-elects-primary/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "semantic-013-platform-default-elects-primary", + "phase": "semantic", + "expected": "pass", + "clause": "specifications/component/v1/spec.md#inputs", + "summary": "A platform default naming no endpoint resolves to the elected primary." +} diff --git a/conformance/component/v1/structural/001-minimal-valid/case.yaml b/conformance/component/v1/structural/001-minimal-valid/case.yaml index 2f01cc6..40ad0c6 100644 --- a/conformance/component/v1/structural/001-minimal-valid/case.yaml +++ b/conformance/component/v1/structural/001-minimal-valid/case.yaml @@ -8,3 +8,8 @@ spec: source: type: IMAGE ref: nginx:1.29.4-alpine + endpoints: + web: + containerPort: 8080 + protocol: HTTP + visibility: PRIVATE diff --git a/conformance/component/v1/structural/002-wrong-kind/case.yaml b/conformance/component/v1/structural/002-wrong-kind/case.yaml index 487272e..092fb67 100644 --- a/conformance/component/v1/structural/002-wrong-kind/case.yaml +++ b/conformance/component/v1/structural/002-wrong-kind/case.yaml @@ -7,6 +7,11 @@ metadata: spec: workload: kind: SERVICE + endpoints: + web: + containerPort: 8080 + protocol: HTTP + visibility: PRIVATE source: type: IMAGE ref: nginx:1.29.4-alpine diff --git a/conformance/component/v1/structural/003-unknown-field/case.yaml b/conformance/component/v1/structural/003-unknown-field/case.yaml index 78f2080..fd2b10e 100644 --- a/conformance/component/v1/structural/003-unknown-field/case.yaml +++ b/conformance/component/v1/structural/003-unknown-field/case.yaml @@ -7,6 +7,11 @@ metadata: spec: workload: kind: SERVICE + endpoints: + web: + containerPort: 8080 + protocol: HTTP + visibility: PRIVATE source: type: IMAGE ref: nginx:1.29.4-alpine diff --git a/conformance/component/v1/structural/004-unsupported-spec-version/case.yaml b/conformance/component/v1/structural/004-unsupported-spec-version/case.yaml index 965acdd..6cd902f 100644 --- a/conformance/component/v1/structural/004-unsupported-spec-version/case.yaml +++ b/conformance/component/v1/structural/004-unsupported-spec-version/case.yaml @@ -7,6 +7,11 @@ metadata: spec: workload: kind: SERVICE + endpoints: + web: + containerPort: 8080 + protocol: HTTP + visibility: PRIVATE source: type: IMAGE ref: nginx:1.29.4-alpine diff --git a/conformance/component/v1/structural/005-nested-unknown-field/case.yaml b/conformance/component/v1/structural/005-nested-unknown-field/case.yaml index 955337a..2d58114 100644 --- a/conformance/component/v1/structural/005-nested-unknown-field/case.yaml +++ b/conformance/component/v1/structural/005-nested-unknown-field/case.yaml @@ -8,6 +8,11 @@ metadata: spec: workload: kind: SERVICE + endpoints: + web: + containerPort: 8080 + protocol: HTTP + visibility: PRIVATE source: type: IMAGE ref: nginx:1.29.4-alpine diff --git a/conformance/component/v1/structural/011-schedule-on-service/case.yaml b/conformance/component/v1/structural/011-schedule-on-service/case.yaml index e2f269c..f1d7d3a 100644 --- a/conformance/component/v1/structural/011-schedule-on-service/case.yaml +++ b/conformance/component/v1/structural/011-schedule-on-service/case.yaml @@ -8,6 +8,11 @@ metadata: spec: workload: kind: SERVICE + endpoints: + web: + containerPort: 8080 + protocol: HTTP + visibility: PRIVATE source: type: IMAGE ref: nginx:1.29.4-alpine diff --git a/conformance/component/v1/structural/012-unpinned-image-reference/case.yaml b/conformance/component/v1/structural/012-unpinned-image-reference/case.yaml index 3361f39..b0db0ef 100644 --- a/conformance/component/v1/structural/012-unpinned-image-reference/case.yaml +++ b/conformance/component/v1/structural/012-unpinned-image-reference/case.yaml @@ -7,6 +7,11 @@ metadata: spec: workload: kind: SERVICE + endpoints: + web: + containerPort: 8080 + protocol: HTTP + visibility: PRIVATE source: type: IMAGE ref: nginx diff --git a/conformance/component/v1/structural/013-digest-pinned-image-reference/case.yaml b/conformance/component/v1/structural/013-digest-pinned-image-reference/case.yaml index 54cc745..2c98b89 100644 --- a/conformance/component/v1/structural/013-digest-pinned-image-reference/case.yaml +++ b/conformance/component/v1/structural/013-digest-pinned-image-reference/case.yaml @@ -11,3 +11,8 @@ spec: source: type: IMAGE ref: localhost:5000/musher-dev/api@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + endpoints: + api: + containerPort: 8080 + protocol: HTTP + visibility: PRIVATE diff --git a/conformance/component/v1/structural/014-git-source-without-build/case.yaml b/conformance/component/v1/structural/014-git-source-without-build/case.yaml index ec9b6ed..e8d1625 100644 --- a/conformance/component/v1/structural/014-git-source-without-build/case.yaml +++ b/conformance/component/v1/structural/014-git-source-without-build/case.yaml @@ -8,6 +8,11 @@ metadata: spec: workload: kind: SERVICE + endpoints: + web: + containerPort: 8080 + protocol: HTTP + visibility: PRIVATE source: type: GIT repositoryUrl: https://github.com/musher-dev/examples diff --git a/conformance/component/v1/structural/017-relative-mount-path/case.yaml b/conformance/component/v1/structural/017-relative-mount-path/case.yaml index f8b8062..d7d4c20 100644 --- a/conformance/component/v1/structural/017-relative-mount-path/case.yaml +++ b/conformance/component/v1/structural/017-relative-mount-path/case.yaml @@ -7,6 +7,11 @@ metadata: spec: workload: kind: SERVICE + endpoints: + web: + containerPort: 8080 + protocol: HTTP + visibility: PRIVATE source: type: IMAGE ref: postgres:17.10-alpine diff --git a/conformance/component/v1/structural/018-generated-input-not-sensitive/case.yaml b/conformance/component/v1/structural/018-generated-input-not-sensitive/case.yaml index 1885300..160e015 100644 --- a/conformance/component/v1/structural/018-generated-input-not-sensitive/case.yaml +++ b/conformance/component/v1/structural/018-generated-input-not-sensitive/case.yaml @@ -8,6 +8,11 @@ metadata: spec: workload: kind: SERVICE + endpoints: + web: + containerPort: 8080 + protocol: HTTP + visibility: PRIVATE source: type: IMAGE ref: nginx:1.29.4-alpine diff --git a/conformance/component/v1/structural/019-connection-input-with-ui/case.yaml b/conformance/component/v1/structural/019-connection-input-with-ui/case.yaml index 5781a0c..5ab9bb4 100644 --- a/conformance/component/v1/structural/019-connection-input-with-ui/case.yaml +++ b/conformance/component/v1/structural/019-connection-input-with-ui/case.yaml @@ -7,6 +7,11 @@ metadata: spec: workload: kind: SERVICE + endpoints: + web: + containerPort: 8080 + protocol: HTTP + visibility: PRIVATE source: type: IMAGE ref: nginx:1.29.4-alpine diff --git a/conformance/component/v1/structural/020-user-input-without-ui/case.yaml b/conformance/component/v1/structural/020-user-input-without-ui/case.yaml index 1c3bb57..487168c 100644 --- a/conformance/component/v1/structural/020-user-input-without-ui/case.yaml +++ b/conformance/component/v1/structural/020-user-input-without-ui/case.yaml @@ -7,6 +7,11 @@ metadata: spec: workload: kind: SERVICE + endpoints: + web: + containerPort: 8080 + protocol: HTTP + visibility: PRIVATE source: type: IMAGE ref: nginx:1.29.4-alpine diff --git a/conformance/component/v1/structural/021-derived-output-with-value/case.yaml b/conformance/component/v1/structural/021-derived-output-with-value/case.yaml index a40599b..87f0fad 100644 --- a/conformance/component/v1/structural/021-derived-output-with-value/case.yaml +++ b/conformance/component/v1/structural/021-derived-output-with-value/case.yaml @@ -7,6 +7,11 @@ metadata: spec: workload: kind: SERVICE + endpoints: + web: + containerPort: 8080 + protocol: HTTP + visibility: PRIVATE source: type: IMAGE ref: postgres:17.10-alpine diff --git a/conformance/component/v1/structural/023-field-from-newer-release/case.yaml b/conformance/component/v1/structural/023-field-from-newer-release/case.yaml index 90f501c..f95808c 100644 --- a/conformance/component/v1/structural/023-field-from-newer-release/case.yaml +++ b/conformance/component/v1/structural/023-field-from-newer-release/case.yaml @@ -10,6 +10,11 @@ metadata: spec: workload: kind: SERVICE + endpoints: + web: + containerPort: 8080 + protocol: HTTP + visibility: PRIVATE terminationGracePeriodSeconds: 30 source: type: IMAGE 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 index d9e914a..3bc266b 100644 --- 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 @@ -7,6 +7,11 @@ metadata: spec: workload: kind: SERVICE + endpoints: + web: + containerPort: 8080 + protocol: HTTP + visibility: PRIVATE source: type: IMAGE ref: nginx:1.29.4-alpine diff --git a/conformance/component/v1/structural/033-service-without-endpoints/case.yaml b/conformance/component/v1/structural/033-service-without-endpoints/case.yaml new file mode 100644 index 0000000..0256ea1 --- /dev/null +++ b/conformance/component/v1/structural/033-service-without-endpoints/case.yaml @@ -0,0 +1,15 @@ +# Section 5 requires a SERVICE to declare at least one endpoint. A service that +# exposes nothing is a WORKER wearing another kind's name, and permitting it +# leaves `kind` carrying no information. +# +# The block is absent here, so the failure is a missing field. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine diff --git a/conformance/component/v1/structural/033-service-without-endpoints/diagnostics.json b/conformance/component/v1/structural/033-service-without-endpoints/diagnostics.json new file mode 100644 index 0000000..36f0b65 --- /dev/null +++ b/conformance/component/v1/structural/033-service-without-endpoints/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_MISSING_FIELD", + "path": "/spec/workload" + } +] diff --git a/conformance/component/v1/structural/033-service-without-endpoints/metadata.json b/conformance/component/v1/structural/033-service-without-endpoints/metadata.json new file mode 100644 index 0000000..070eae1 --- /dev/null +++ b/conformance/component/v1/structural/033-service-without-endpoints/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-033-service-without-endpoints", + "phase": "structural", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#workload", + "summary": "A SERVICE declaring no endpoints block is rejected." +} diff --git a/conformance/component/v1/structural/034-service-with-empty-endpoints/case.yaml b/conformance/component/v1/structural/034-service-with-empty-endpoints/case.yaml new file mode 100644 index 0000000..251d376 --- /dev/null +++ b/conformance/component/v1/structural/034-service-with-empty-endpoints/case.yaml @@ -0,0 +1,15 @@ +# The same rule reached the other way. The block is present and empty, so this +# is a value the schema rejects rather than a field it misses — the two are +# separate diagnostics because they read differently to an author. One forgot +# the block; the other wrote it and put nothing in it. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + endpoints: {} diff --git a/conformance/component/v1/structural/034-service-with-empty-endpoints/diagnostics.json b/conformance/component/v1/structural/034-service-with-empty-endpoints/diagnostics.json new file mode 100644 index 0000000..0682289 --- /dev/null +++ b/conformance/component/v1/structural/034-service-with-empty-endpoints/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_INVALID_VALUE", + "path": "/spec/workload/endpoints" + } +] diff --git a/conformance/component/v1/structural/034-service-with-empty-endpoints/metadata.json b/conformance/component/v1/structural/034-service-with-empty-endpoints/metadata.json new file mode 100644 index 0000000..f829265 --- /dev/null +++ b/conformance/component/v1/structural/034-service-with-empty-endpoints/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-034-service-with-empty-endpoints", + "phase": "structural", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#workload", + "summary": "A SERVICE declaring an empty endpoints mapping is rejected." +} diff --git a/conformance/component/v1/structural/035-public-address-default/case.yaml b/conformance/component/v1/structural/035-public-address-default/case.yaml new file mode 100644 index 0000000..cc6751f --- /dev/null +++ b/conformance/component/v1/structural/035-public-address-default/case.yaml @@ -0,0 +1,31 @@ +# The edge address, referenced. A PUBLIC TCP endpoint publishes host:port +# (§5.2), and PUBLIC_ADDRESS is the source that derives it — the case a sibling +# needing a database's edge address at install time could not express before. +# +# No readiness probe: §5.4 compels one only on a PUBLIC HTTP-family endpoint. +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: + databaseAddress: + schema: + type: STRING + ui: + label: Database address + platformDefault: + source: PUBLIC_ADDRESS + endpoint: db + outputs: {} diff --git a/conformance/component/v1/structural/035-public-address-default/metadata.json b/conformance/component/v1/structural/035-public-address-default/metadata.json new file mode 100644 index 0000000..42e6618 --- /dev/null +++ b/conformance/component/v1/structural/035-public-address-default/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-035-public-address-default", + "phase": "structural", + "expected": "pass", + "clause": "specifications/component/v1/spec.md#inputs", + "summary": "PUBLIC_ADDRESS derives the host:port of a PUBLIC TCP endpoint." +} diff --git a/conformance/component/v1/structural/036-public-port-on-udp-endpoint/case.yaml b/conformance/component/v1/structural/036-public-port-on-udp-endpoint/case.yaml new file mode 100644 index 0000000..9ee2bc6 --- /dev/null +++ b/conformance/component/v1/structural/036-public-port-on-udp-endpoint/case.yaml @@ -0,0 +1,30 @@ +# PUBLIC_PORT on a PUBLIC UDP endpoint. Two things at once, both deliberate: +# the port is derivable on its own, for a consumer that takes host and port as +# separate settings rather than one string; and UDP is an L4 protocol equally +# with TCP, which no other case exercises. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: ghcr.io/musher-dev/game:1.4.2 + endpoints: + game: + containerPort: 27015 + protocol: UDP + visibility: PUBLIC + contract: + inputs: + gamePort: + schema: + type: INTEGER + ui: + label: Game server port + platformDefault: + source: PUBLIC_PORT + endpoint: game + outputs: {} diff --git a/conformance/component/v1/structural/036-public-port-on-udp-endpoint/metadata.json b/conformance/component/v1/structural/036-public-port-on-udp-endpoint/metadata.json new file mode 100644 index 0000000..5d33483 --- /dev/null +++ b/conformance/component/v1/structural/036-public-port-on-udp-endpoint/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-036-public-port-on-udp-endpoint", + "phase": "structural", + "expected": "pass", + "clause": "specifications/component/v1/spec.md#inputs", + "summary": "PUBLIC_PORT derives the allocated edge port of a PUBLIC UDP endpoint." +} diff --git a/conformance/component/v1/structural/037-public-hostname-default/case.yaml b/conformance/component/v1/structural/037-public-hostname-default/case.yaml new file mode 100644 index 0000000..04fb292 --- /dev/null +++ b/conformance/component/v1/structural/037-public-hostname-default/case.yaml @@ -0,0 +1,33 @@ +# PUBLIC_HOSTNAME, which no case exercised. It takes the host part of the URL a +# PUBLIC HTTP-family endpoint publishes, so unlike PUBLIC_ADDRESS it is an +# HTTP-family source and this endpoint answers it. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + endpoints: + api: + containerPort: 8080 + protocol: HTTPS + visibility: PUBLIC + health: + readiness: + path: /healthz + endpoint: api + contract: + inputs: + apiHostname: + schema: + type: STRING + ui: + label: API hostname + platformDefault: + source: PUBLIC_HOSTNAME + endpoint: api + outputs: {} diff --git a/conformance/component/v1/structural/037-public-hostname-default/metadata.json b/conformance/component/v1/structural/037-public-hostname-default/metadata.json new file mode 100644 index 0000000..d727d60 --- /dev/null +++ b/conformance/component/v1/structural/037-public-hostname-default/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-037-public-hostname-default", + "phase": "structural", + "expected": "pass", + "clause": "specifications/component/v1/spec.md#inputs", + "summary": "PUBLIC_HOSTNAME derives the hostname of a PUBLIC HTTP-family endpoint." +} diff --git a/conformance/listing/v1/semantic/002-slug-disagrees-with-directory/tree/acme-wiki/components/postgres.yaml b/conformance/listing/v1/semantic/002-slug-disagrees-with-directory/tree/acme-wiki/components/postgres.yaml index 9a8c959..00eadd1 100644 --- a/conformance/listing/v1/semantic/002-slug-disagrees-with-directory/tree/acme-wiki/components/postgres.yaml +++ b/conformance/listing/v1/semantic/002-slug-disagrees-with-directory/tree/acme-wiki/components/postgres.yaml @@ -8,3 +8,8 @@ spec: source: type: IMAGE ref: postgres:17.10-alpine + endpoints: + db: + containerPort: 5432 + protocol: TCP + visibility: PRIVATE diff --git a/conformance/listing/v1/semantic/003-version-disagrees-with-blueprint/tree/acme-wiki/components/postgres.yaml b/conformance/listing/v1/semantic/003-version-disagrees-with-blueprint/tree/acme-wiki/components/postgres.yaml index 9a8c959..00eadd1 100644 --- a/conformance/listing/v1/semantic/003-version-disagrees-with-blueprint/tree/acme-wiki/components/postgres.yaml +++ b/conformance/listing/v1/semantic/003-version-disagrees-with-blueprint/tree/acme-wiki/components/postgres.yaml @@ -8,3 +8,8 @@ spec: source: type: IMAGE ref: postgres:17.10-alpine + endpoints: + db: + containerPort: 5432 + protocol: TCP + visibility: PRIVATE diff --git a/conformance/listing/v1/semantic/004-media-file-absent/tree/acme-wiki/components/postgres.yaml b/conformance/listing/v1/semantic/004-media-file-absent/tree/acme-wiki/components/postgres.yaml index 9a8c959..00eadd1 100644 --- a/conformance/listing/v1/semantic/004-media-file-absent/tree/acme-wiki/components/postgres.yaml +++ b/conformance/listing/v1/semantic/004-media-file-absent/tree/acme-wiki/components/postgres.yaml @@ -8,3 +8,8 @@ spec: source: type: IMAGE ref: postgres:17.10-alpine + endpoints: + db: + containerPort: 5432 + protocol: TCP + visibility: PRIVATE diff --git a/conformance/listing/v1/semantic/005-media-symlink-escaping-the-item/tree/acme-wiki/components/postgres.yaml b/conformance/listing/v1/semantic/005-media-symlink-escaping-the-item/tree/acme-wiki/components/postgres.yaml index 9a8c959..00eadd1 100644 --- a/conformance/listing/v1/semantic/005-media-symlink-escaping-the-item/tree/acme-wiki/components/postgres.yaml +++ b/conformance/listing/v1/semantic/005-media-symlink-escaping-the-item/tree/acme-wiki/components/postgres.yaml @@ -8,3 +8,8 @@ spec: source: type: IMAGE ref: postgres:17.10-alpine + endpoints: + db: + containerPort: 5432 + protocol: TCP + visibility: PRIVATE diff --git a/conformance/listing/v1/semantic/006-well-formed-item/tree/acme-wiki/components/postgres.yaml b/conformance/listing/v1/semantic/006-well-formed-item/tree/acme-wiki/components/postgres.yaml index 9a8c959..00eadd1 100644 --- a/conformance/listing/v1/semantic/006-well-formed-item/tree/acme-wiki/components/postgres.yaml +++ b/conformance/listing/v1/semantic/006-well-formed-item/tree/acme-wiki/components/postgres.yaml @@ -8,3 +8,8 @@ spec: source: type: IMAGE ref: postgres:17.10-alpine + endpoints: + db: + containerPort: 5432 + protocol: TCP + visibility: PRIVATE diff --git a/conformance/listing/v1/semantic/007-component-item-version-independent/tree/pgvector/components/pgvector.yaml b/conformance/listing/v1/semantic/007-component-item-version-independent/tree/pgvector/components/pgvector.yaml index 88be8e2..9b534e7 100644 --- a/conformance/listing/v1/semantic/007-component-item-version-independent/tree/pgvector/components/pgvector.yaml +++ b/conformance/listing/v1/semantic/007-component-item-version-independent/tree/pgvector/components/pgvector.yaml @@ -8,3 +8,8 @@ spec: source: type: IMAGE ref: pgvector/pgvector:0.8.1-pg17 + endpoints: + db: + containerPort: 5432 + protocol: TCP + visibility: PRIVATE diff --git a/conformance/listing/v1/semantic/007-component-item-version-independent/tree/pgvector/components/postgres.yaml b/conformance/listing/v1/semantic/007-component-item-version-independent/tree/pgvector/components/postgres.yaml index 226bf1e..64259cc 100644 --- a/conformance/listing/v1/semantic/007-component-item-version-independent/tree/pgvector/components/postgres.yaml +++ b/conformance/listing/v1/semantic/007-component-item-version-independent/tree/pgvector/components/postgres.yaml @@ -8,3 +8,8 @@ spec: source: type: IMAGE ref: postgres:17.10-alpine + endpoints: + db: + containerPort: 5432 + protocol: TCP + visibility: PRIVATE diff --git a/docs/adr/0005-platform-divergence-reconciliation.md b/docs/adr/0005-platform-divergence-reconciliation.md new file mode 100644 index 0000000..a534377 --- /dev/null +++ b/docs/adr/0005-platform-divergence-reconciliation.md @@ -0,0 +1,222 @@ +# ADR 0005: Reconciling the specification against implemented platform behaviour + +- **Status:** Accepted +- **Date:** 2026-08-17 +- **Extends:** [ADR 0001](0001-canonical-repository-architecture.md) §1 +- **Closes:** [ADR 0002](0002-conformance-case-trees.md) follow-up 2 + +## Context + +[ADR 0002](0002-conformance-case-trees.md) closed with a follow-up naming three +points where this specification and `musher-dev/platform` deliberately disagree — +cycle detection, parameter-merge conflicts, and whether a `SERVICE` must expose an +endpoint — to be reconciled before v1 is declared stable. ADRs are immutable, so +that follow-up is discharged here rather than edited there. + +Each is a live divergence rather than an unfilled gap: an implementation conforming +to this repository and the platform as it stands today return different verdicts on +the same document. All three arrived together, in the pass that filled the `spec.md` +`TODO` markers from implemented behaviour, and in each case the specification decided +against the implementation with its reasoning recorded in the prose. + +[ADR 0001](0001-canonical-repository-architecture.md) §1 is the grounds each one +cited: *"Where an implementation and the conformance suite disagree, the +implementation is defective."* That rule is not in question. But it decides **who +changes**, not **whether the specification chose correctly**, and a rule invoked to +settle three disagreements in a single pass deserves to be tested against each of +them separately before the contract is published and the answers become expensive. + +A fourth question is settled in the same pass. Component §5.2 records that v1 gives +the contract no way to read the edge address of a `PUBLIC` `TCP` or `UDP` endpoint, +"a question this version declines to answer rather than one it overlooked". The +platform ships that capability today, so a component whose sibling needs a broker's +or a database's edge address at install time cannot express it. Declining to answer +was reasonable while the question was new; leaving it unanswered through publication +is not. + +### Why now + +The timing is not incidental — it is the whole reason this ADR exists at this +moment rather than later. + +`git tag -l` is empty, `.github/release-please/manifest.json` reads `0.0.0` for all +three families, and the three `1.0.0` release pull requests are still open. Two of +the four decisions below reject documents that validate today, which +[GOVERNANCE.md § Compatibility review](../../GOVERNANCE.md) makes a breaking change +requiring a new `v` directory and a migration note. + +## Decision + +### 1. The pre-publication window, and what closes it + +**While no version of a family has been published, a change to that family that +would reject a previously valid document does not require a new major directory.** + +Component §3 guarantees that a document validating against `v1.0.0` also validates +against every later `v1.x.y`. The guarantee is stated against a released version, and +where no such version exists there is no document anywhere that was validated against +a published v1 and would now fail. A migration note would have nothing to migrate +from, and a `v2` directory would be the second major of a specification whose first +was never served. + +The window is narrow and its closing condition is exact: **it closes for a family the +moment that family's first tag is created.** After that, every rule below that +narrows validation is a `v2` change on the ordinary terms, with no discretion left in +it. + +This is written down because it has already been relied upon twice — the endpoint, +environment-variable and graph-rule tightenings each landed on this reasoning — and +because a rule that decides whether a change is free or costs a major version should +be citable rather than reconstructed from pull request descriptions. + +Nothing here weakens GOVERNANCE.md. Breaking changes still need maintainer approval +and still must be declared as breaking in the commit trailer; what the window removes +is only the `v` directory and the migration note, and only while there is nothing +to migrate. + +### 2. Cycle detection: the platform is right, and the rule is withdrawn + +**The connection graph MAY contain a cycle.** `ERR_DEPENDENCY_CYCLE` is withdrawn. + +Blueprint §4.2 required acyclicity while conceding, in the same clause, that +"acyclicity is not a resolution hazard". That concession is load-bearing and it is +correct. [Component §6.2](../../specifications/component/v1/spec.md#outputs) makes an +output a function of its own node and nothing else — an invariant this contract +states normatively, carries a diagnostic for, and is not going to withdraw. Every +output in a graph is therefore resolvable before any edge is bound, so a resolver +needs no topological order and does not diverge on a cycle. + +The rule was held anyway on three arguments, and each is weaker than it looked: + +- *It obliges every implementation to be a two-pass resolver in perpetuity.* It does + not oblige anything. The two-pass shape is a **consequence** of component §6.2, not + a burden acyclicity would have lifted — an implementation that resolves outputs + before binding edges is doing what the contract already describes, and one that + interleaves them is reading a rule that was never written. +- *It forecloses any later rule that needs an order.* This is real, and it is what is + being given up. See Consequences. +- *It obliges every reader to work out whether a six-node graph terminates.* Nothing + in a blueprint graph fails to terminate. Resolution is a single pass over a finite + node set, and the question the reader is being asked to answer does not arise. + +Against those stands a capability with a name and a live consumer: **mutual service +discovery**, two services that each need the other's address. The acyclicity rule +made it inexpressible, and it is the platform's recorded reason for permitting cycles +in the first place. A specification that spends a working capability to hold options +open for rules nobody has proposed is paying now for later. + +ADR 0001 §1 still decides who changes when the two disagree. It is simply not a +reason to keep a rule that does not earn its cost. + +### 3. Parameter-merge conflicts: the specification is right, and the rule stands + +**A conflicting input redeclaration remains `ERR_CONFLICTING_INPUT_SCHEMA`.** + +The platform's `merge_user_inputs` takes the first declaration of a repeated key and +discards a differing second one in silence. Blueprint §5.2 rejects the composition +instead, and that reasoning is unchanged by this ADR: silent first-wins settles the +ambiguity without telling anyone there was one, and hands the second component a +value validated against the first component's rules — a bare `STRING` where it +required an enum member, a 64-byte secret where its pattern allowed 32. Nothing fails +at validation time. It fails at deploy time, inside the consuming workload, a long +way from the two documents that disagreed. + +This is the one divergence the reconciliation leaves standing, and ADR 0001 §1 +resolves it without further ceremony: the implementation is defective. The merge +**order** changes with it — `(ordering, componentId)` names two fields this contract +does not have, and lexicographic node name is the only total order a document itself +supplies. + +### 4. A `SERVICE` MUST declare at least one endpoint + +**The platform's rule is adopted.** `spec.workload.endpoints` is REQUIRED and +non-empty where `kind` is `SERVICE`. + +This is the divergence that ran the other way. Every other disagreement in this ADR +has the specification rejecting a document the platform accepts, where an author +following the contract is safe and the implementation is the thing that must catch +up. Here the specification was **looser**: it called a document valid that the +platform will not deploy, so an author who followed it exactly was led into a +deploy-time failure with a conformant document in hand. That is the one direction a +contract must not be wrong in. + +Permitting an endpointless `SERVICE` also left `kind` carrying no information. §5 +already concedes that such a workload and a `WORKER` "are operationally much the same +thing"; a discriminator whose branches describe the same thing is not discriminating. + +The rule was declined originally because adopting it invalidates +`examples/minimal.yaml` and the `structural-001-minimal-valid` fixture. That is a +cost in fixtures, not in contract quality, and §1 above is why it is affordable now +and not later. + +### 5. The edge address is exposed, by two new sources + +`platformDefault.source` gains **`PUBLIC_ADDRESS`** — the full `host:port` — and +**`PUBLIC_PORT`**, the allocated edge port alone. Both are additive: no document that +validates today names either. + +Two sources rather than one because a consumer that takes host and port as separate +settings should not have to split a string that this contract composed. The pairing +with §5.2's two address forms is exact and exclusive: `PUBLIC_URL` and +`PUBLIC_HOSTNAME` require an HTTP-family endpoint, `PUBLIC_ADDRESS` and `PUBLIC_PORT` +require a `TCP` or `UDP` one, and naming the wrong family is `ERR_ENDPOINT_NOT_HTTP` +or the new `ERR_ENDPOINT_NOT_L4`. + +An HTTP-family endpoint is reachable at a host and a port too, and admitting it here +was rejected deliberately. It is published through the shared ingress rather than an +allocated edge port, so what a `host:port` derivation would yield is the ingress +address on port 443 — a true statement about where the endpoint answers, and not the +thing an author asking for an edge address is asking for. A source that returns a +defensible value nobody wanted is worse than one that rejects the document. + +The alternative shape issue #32 raised — a `self.publicAddress.` reference +namespace — is rejected. This contract has no expression language; references are +structural fields, and introducing a namespace to answer one question would be a far +larger change than the question warrants. + +### 6. No deviation register is created + +Issue #24 names "this repository records an accepted deviation" as one of three ways +each divergence could close. No such mechanism exists in GOVERNANCE.md or in these +ADRs, and none is created here. + +After the four decisions above, the only surviving disagreement is §3, where ADR 0001 +§1 already supplies the answer and the platform has a defect to fix. A register would +be a place to record permanent, blessed non-conformance, and a specification that +maintains one has conceded that its conformance suite is advisory. The absence is the +position, and it is recorded here so that a reader who goes looking for the register +issue #24 assumed finds this paragraph instead of a gap. + +## Consequences + +**A later rule that needs an order is now breaking.** This is the real price of §2. +An ordered rollout, a health-gated start, or a value that legitimately depends on an +inbound edge would each need an acyclic graph, and re-introducing that requirement +after publication rejects compositions v1 accepts. Anyone proposing such a rule is +proposing a major version, and should know that before starting. + +**Two implementations of the platform's behaviour must change**, in opposite +directions. `merge_user_inputs` gains a conflict check and a new sort key; the +composition path loses nothing, because permitting cycles is what it already does. + +**Documents are rejected that validate today.** An endpointless `SERVICE` is the only +new rejection, and it is confined to the component family. Everything else in this +ADR either loosens validation or adds vocabulary no existing document uses. + +**The conformance corpus absorbs the `SERVICE` change unevenly.** A case that already +fails at or before the `structural` phase is unaffected, because the runner matches +declared diagnostics as a subset. A passing case, a `semantic`-phase case, or a +component document reached through a cross-family tree must declare an endpoint or it +breaks — including `semantic-007`, whose entire premise was a probe on an endpointless +`SERVICE` and which is re-based onto a `WORKER`. + +**The window in §1 is spent.** These are the last free tightenings. The release pull +requests should merge after this lands, and every narrowing proposed afterwards is a +`v2` directory. + +## Follow-ups + +1. File the two platform defects this ADR leaves open: the merge conflict check and + the merge sort key. +2. Component §10's remaining debt is untouched — schema `description` fields that + still speak the platform's vocabulary. It is a wording gap, not a divergence. diff --git a/specifications/blueprint/v1/spec.md b/specifications/blueprint/v1/spec.md index 14d7ae2..9f1ea11 100644 --- a/specifications/blueprint/v1/spec.md +++ b/specifications/blueprint/v1/spec.md @@ -306,32 +306,31 @@ 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`. - -This is the canonical rule JSON Schema cannot express, and it is worth being -straight about what it costs, because a resolver does not need it. An output -is a function of its own node and nothing else -([component §6.2](../../component/v1/spec.md#outputs)), so an implementation -that resolves every output before binding any edge needs no topological order -and does not fail on a cycle. Acyclicity is not a resolution hazard. - -It is required anyway. A specification that permits cycles obliges every -implementation, in every language, to be that two-pass resolver in perpetuity, -and forecloses any later rule that needs an order — an ordered rollout, a -health-gated start, a value that legitimately does depend on an inbound edge. -It also obliges every reader of a blueprint to work out for themselves whether -the composition in front of them terminates. A two-node cycle is legible; a -six-node one is not. The rule costs one traversal, which is less than the -option it keeps open. - -**Reporting a cycle.** The diagnostic MUST name the participating nodes as a -closed walk, beginning at the lexicographically smallest node name in the -cycle and following edges from there — `db → cache → queue → db`. Two -implementations that find the same cycle then report the same walk, which is -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`. +**The connection graph MAY contain a cycle.** Two nodes MAY each consume an +output of the other, and an implementation MUST NOT reject a composition for +that reason alone. + +The rule follows from one already stated. An output is a function of its own +node and nothing else +([component §6.2](../../component/v1/spec.md#outputs)), so every output in the +graph is resolvable before any edge is bound. Resolution is one pass over a +finite set of nodes: it needs no topological order, and there is no order for a +cycle to contradict. A cyclic graph is not a resolution hazard, and rejecting +one would be rejecting a document nothing in this contract cannot process. + +What it permits has a name. **Mutual service discovery** — two services that +each need the other's address — is a composition an author writes deliberately, +and it is expressible only if a cycle is legal. A rule that forbade it would be +spending a working capability. + +**What this forecloses, stated rather than discovered later.** No rule in this +version depends on the order in which nodes are materialised, and a later one +that did — an ordered rollout, a health-gated start, a value that legitimately +depends on an inbound edge — would need an acyclic graph to be meaningful. +Requiring acyclicity after publication rejects compositions that validate +today, so such a rule is a new major version rather than an addition to this +one. Anyone proposing one should know that before starting rather than +afterwards. ### 4.3 Node compute @@ -648,7 +647,8 @@ over without a directory. As defined in [component §7](../../component/v1/spec.md#validation-layers). Blueprint documents exercise the `semantic` phase more heavily than any other -family — repo-local reference resolution and cycle detection both live there. +family — repo-local reference resolution, connection compatibility, and the +parameter merge all live there. Published reference resolution is the exception: it needs the catalog, so it belongs to `capability`. A blueprint composed entirely of repo-local references @@ -672,7 +672,6 @@ family adds: | `ERR_UNKNOWN_COMPUTE_PROFILE` | `capability` | A node's `size` names a Compute Profile the catalog does not offer. | | `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. | | `ERR_UNREFERENCED_COMPONENT` | `semantic` | A component document in the item is referenced by no node. | diff --git a/specifications/component/v1/examples/minimal.yaml b/specifications/component/v1/examples/minimal.yaml index e248279..f07e84b 100644 --- a/specifications/component/v1/examples/minimal.yaml +++ b/specifications/component/v1/examples/minimal.yaml @@ -1,6 +1,9 @@ # yaml-language-server: $schema=https://schemas.musher.dev/component/v1/component.schema.json # -# The smallest component that validates: a service running a pinned image. +# The smallest component that validates: a service running a pinned image and +# serving one private port. §5 requires the endpoint — a SERVICE that exposes +# nothing is a WORKER — and PRIVATE keeps it minimal, since §5.4 compels a +# readiness probe only on a PUBLIC HTTP-family endpoint. specVersion: v1 kind: COMPONENT metadata: @@ -11,3 +14,8 @@ spec: source: type: IMAGE ref: nginx:1.29.4-alpine + endpoints: + web: + containerPort: 8080 + protocol: HTTP + visibility: PRIVATE diff --git a/specifications/component/v1/examples/multi-endpoint.yaml b/specifications/component/v1/examples/multi-endpoint.yaml index 4e00fee..cdd18d0 100644 --- a/specifications/component/v1/examples/multi-endpoint.yaml +++ b/specifications/component/v1/examples/multi-endpoint.yaml @@ -2,10 +2,12 @@ # # 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. +# component's URL" — every reference names the endpoint it means. +# +# Both of §5.2's address forms are read. `console` publishes a URL, so +# PUBLIC_URL takes it. `wire` is a PUBLIC TCP endpoint published at a host:port +# edge address, which compels no readiness probe and which PUBLIC_ADDRESS — +# never PUBLIC_URL — is the source for. specVersion: v1 kind: COMPONENT metadata: @@ -49,4 +51,14 @@ spec: label: Console URL target: envVarKey: CONSOLE_URL + wireAddress: + schema: + type: STRING + platformDefault: + source: PUBLIC_ADDRESS + endpoint: wire + ui: + label: Wire protocol address + target: + envVarKey: WIRE_ADDRESS outputs: {} diff --git a/specifications/component/v1/schemas/dist/component.schema.json b/specifications/component/v1/schemas/dist/component.schema.json index ef9feb2..24e8184 100644 --- a/specifications/component/v1/schemas/dist/component.schema.json +++ b/specifications/component/v1/schemas/dist/component.schema.json @@ -636,10 +636,13 @@ "default": null }, "source": { - "description": "What is derived: PUBLIC_HOSTNAME for the endpoint's hostname alone, PUBLIC_URL for its full URL.", + "description": "What is derived. PUBLIC_HOSTNAME for the endpoint's hostname alone and PUBLIC_URL for its full URL, both from the URL an HTTP-family endpoint publishes; PUBLIC_ADDRESS for the full host:port and PUBLIC_PORT for the port alone, both from the edge address a TCP or UDP endpoint publishes.", + "$comment": "spec.md §6.1. Each source pairs with one of §5.2's two address forms, and the pairing is exclusive: the first two require an HTTP-family endpoint, the last two a TCP or UDP one. Which is why the mismatch carries two codes rather than one.", "enum": [ "PUBLIC_HOSTNAME", - "PUBLIC_URL" + "PUBLIC_URL", + "PUBLIC_ADDRESS", + "PUBLIC_PORT" ], "type": "string" } @@ -920,6 +923,29 @@ } } }, + { + "$comment": "spec.md §5. The converse of the branch above: a SERVICE is the kind that serves, so it declares at least one endpoint. An absent block is a missing field and an empty one is an invalid value, which is the same two-spelling distinction the forbidden direction already makes.", + "if": { + "properties": { + "kind": { + "const": "SERVICE" + } + }, + "required": [ + "kind" + ] + }, + "then": { + "properties": { + "endpoints": { + "minProperties": 1 + } + }, + "required": [ + "endpoints" + ] + } + }, { "if": { "properties": { diff --git a/specifications/component/v1/schemas/src/component.schema.json b/specifications/component/v1/schemas/src/component.schema.json index cf0e96d..e782c82 100644 --- a/specifications/component/v1/schemas/src/component.schema.json +++ b/specifications/component/v1/schemas/src/component.schema.json @@ -636,10 +636,13 @@ "description": "Name of the PUBLIC endpoint the value is derived from. Null selects the primary endpoint spec.md §5.2 elects." }, "source": { - "description": "What is derived: PUBLIC_HOSTNAME for the endpoint's hostname alone, PUBLIC_URL for its full URL.", + "$comment": "spec.md §6.1. Each source pairs with one of §5.2's two address forms, and the pairing is exclusive: the first two require an HTTP-family endpoint, the last two a TCP or UDP one. Which is why the mismatch carries two codes rather than one.", + "description": "What is derived. PUBLIC_HOSTNAME for the endpoint's hostname alone and PUBLIC_URL for its full URL, both from the URL an HTTP-family endpoint publishes; PUBLIC_ADDRESS for the full host:port and PUBLIC_PORT for the port alone, both from the edge address a TCP or UDP endpoint publishes.", "enum": [ "PUBLIC_HOSTNAME", - "PUBLIC_URL" + "PUBLIC_URL", + "PUBLIC_ADDRESS", + "PUBLIC_PORT" ], "type": "string" } @@ -920,6 +923,29 @@ } } }, + { + "$comment": "spec.md §5. The converse of the branch above: a SERVICE is the kind that serves, so it declares at least one endpoint. An absent block is a missing field and an empty one is an invalid value, which is the same two-spelling distinction the forbidden direction already makes.", + "if": { + "properties": { + "kind": { + "const": "SERVICE" + } + }, + "required": [ + "kind" + ] + }, + "then": { + "properties": { + "endpoints": { + "minProperties": 1 + } + }, + "required": [ + "endpoints" + ] + } + }, { "if": { "properties": { diff --git a/specifications/component/v1/spec.md b/specifications/component/v1/spec.md index 2d6cd37..55d1ddf 100644 --- a/specifications/component/v1/spec.md +++ b/specifications/component/v1/spec.md @@ -186,7 +186,7 @@ and the shape decides which of the remaining fields carry meaning. | Field | `SERVICE` | `WORKER` | `JOB` | `CRON` | |---|---|---|---|---| -| `endpoints` | permitted | forbidden | forbidden | forbidden | +| `endpoints` | REQUIRED, ≥ 1 | forbidden | forbidden | forbidden | | `command` | permitted | permitted | REQUIRED | REQUIRED | | `schedule` | forbidden | forbidden | forbidden | REQUIRED | | `health.readiness` | see [§5.4](#health) | permitted | permitted | permitted | @@ -211,11 +211,22 @@ nothing to run without it, so it is REQUIRED on both. On a `SERVICE` or a `WORKER` it overrides the image's default command, which is meaningful rather than meaningless, so it stays permitted. -**A `SERVICE` MAY declare no endpoint.** The smallest component that validates -is a service running a pinned image and nothing else. A service exposing no -endpoint and a worker are operationally much the same thing, so requiring at -least one endpoint is a defensible rule. It is simply not this version's rule, -and adopting it later rejects documents v1 accepts. +**A `SERVICE` MUST declare at least one endpoint.** A service is the kind that +serves, and one exposing nothing is a `WORKER` under another name. Permitting +it would leave `kind` describing nothing: two documents would differ in the +word they use for a workload that runs identically, and a reader could not tell +from the `kind` whether anything could reach it. + +Both spellings of "none" are rejected, and they carry different codes because +they read differently to an author. An absent `endpoints` block is +`ERR_MISSING_FIELD` — the author has not said how the service is reached. A +block written and left empty is `ERR_INVALID_VALUE` — the author has said, and +said nothing. Both are `structural`. + +This is the one rule in this section where a workload that runs perfectly well +is refused. A container that listens on no port and is meant to stay up is a +real thing to want; it is a `WORKER`, and writing it as one costs an author a +single word. Input and output keys are unique within a component because `contract.inputs` and `contract.outputs` are mappings. A repeated key is `ERR_DUPLICATE_KEY` in @@ -321,9 +332,10 @@ two rules below turn on: 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. +which references may name such an endpoint. [§5.4](#health)'s probe reads a URL +and may name only the first row. [§6.1](#inputs)'s platform default reads +either, and each of its four sources is tied to the row it takes its value +from. **A component MAY declare more than one `PUBLIC` endpoint**, and each one publishes its own address. A component fronting an API on one port and a console @@ -351,9 +363,9 @@ elsewhere in the document. "Nothing" is reached two ways and both are rejected, though they read differently to an author. A workload declaring several candidates has too many and must choose. A workload declaring no endpoint at all has none, and a probe -on it polls a port that does not exist — which [§5](#workload) permits the -workload to be, since a `SERVICE` MAY declare no endpoint, but which no probe -can survive. +on it polls a port that does not exist. [§5](#workload) puts the second beyond +a `SERVICE`, which MUST declare at least one endpoint — but the other three +kinds declare none and may still carry a probe, so the case survives there. **Why that is an error rather than a tiebreak.** Electing the first name in sort order would give every document an answer, and would let a new endpoint called @@ -367,11 +379,10 @@ 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. +The edge address of a `PUBLIC` `TCP` or `UDP` endpoint **is** readable from the +contract. [§6.1](#inputs)'s `PUBLIC_ADDRESS` derives the whole `host:port` and +`PUBLIC_PORT` the allocated port alone, so a component whose sibling needs a +broker's or a database's edge address at install time can say so. ### 5.3 Environment variables @@ -541,9 +552,23 @@ marked sensitive is echoed back into logs and interfaces. The value rides on a **A platform default derives the value from the component's own addressing.** `platformDefault` is OPTIONAL and null by default. Where it is present, `source` -is REQUIRED and selects what is derived — `PUBLIC_URL` for the full URL of a -public endpoint, `PUBLIC_HOSTNAME` for its hostname alone — and `endpoint` names -which endpoint it is derived from. +is REQUIRED and selects what is derived, and `endpoint` names which endpoint it +is derived from. + +There are four sources, and they come in two pairs because +[§5.2](#endpoints) gives a `PUBLIC` endpoint two address forms: + +| `source` | Derives | From an endpoint publishing | +|---|---|---| +| `PUBLIC_URL` | The full URL. | a **URL** — `HTTP`, `HTTPS`, `WS`, `GRPC` | +| `PUBLIC_HOSTNAME` | The host part of that URL. | " | +| `PUBLIC_ADDRESS` | The full `host:port`. | a **`host:port`** — `TCP`, `UDP` | +| `PUBLIC_PORT` | The allocated edge port alone. | " | + +Each pair reads one address form. `PUBLIC_PORT` and `PUBLIC_HOSTNAME` exist +beside the whole they are part of because a consumer that takes host and port +as separate settings should not have to split a string this contract had +already composed. `endpoint` is null by default and selects the primary endpoint [§5.2](#endpoints) elects. Since a component MAY expose several `PUBLIC` @@ -553,14 +578,30 @@ and is rejected with `ERR_AMBIGUOUS_ENDPOINT`. 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. +`ERR_ENDPOINT_NOT_PUBLIC`: every source derives an externally reachable +address, and a `PRIVATE` endpoint has none to give. + +The third is the pairing above, enforced. **A source MUST name an endpoint +publishing the address form it reads.** A `URL` source naming a `TCP` or `UDP` +endpoint is `ERR_ENDPOINT_NOT_HTTP`, the same code a probe on such an endpoint +carries. A `host:port` source naming an `HTTP`-family endpoint is +`ERR_ENDPOINT_NOT_L4`. Two codes rather than one so a diagnostic names the axis +that failed, which is the same reason [blueprint §4.2](../../blueprint/v1/spec.md#connections) +splits its two compatibility codes. + +**Why an HTTP-family endpoint does not answer `PUBLIC_ADDRESS`.** It is +reachable at a host and a port like anything else, so admitting it would be +easy and is refused deliberately. Such an endpoint is published through the +shared ingress rather than on a port allocated to it, so what the derivation +would yield is the ingress address on the ingress port — true, and not the +thing an author asking for an edge address is asking for. They want the port +their broker was given. A source that returns a defensible value nobody wanted +is worse than one that rejects the document, because the first failure is +silent and arrives at runtime. + +Both rules apply to the endpoint a reference *resolves to*, elected or named, +for the reason [§5.2](#endpoints) gives: the primary is what null selects, so a +rule about the endpoint a reference means reaches it equally. **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 @@ -584,9 +625,11 @@ connection. That constraint is what makes an output referenceable at all: a consumer can read a producer's output without the producer having first been told anything. -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. +It is also what lets two components consume each other. +[Blueprint §4.2](../../blueprint/v1/spec.md#connections) permits a cyclic +connection graph, and this rule is why it can: every output in a composition is +resolvable before any connection is bound, so a cycle among the connections +leaves nothing unresolved. **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 @@ -654,7 +697,8 @@ 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_ENDPOINT_NOT_HTTP` | `semantic` | A probe, or a platform default deriving from a URL, resolves to an endpoint whose protocol is not in the HTTP family. | +| `ERR_ENDPOINT_NOT_L4` | `semantic` | A platform default deriving an edge address resolves to an endpoint whose protocol is 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 7e441b3..957cb39 100644 --- a/tools/src/semantic.ts +++ b/tools/src/semantic.ts @@ -83,9 +83,10 @@ const FLOATING_TAGS = new Set([ /** * 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. + * `UDP` endpoint publishes a `host:port` address instead, and every reference + * reads one form or the other: §5.4's probes and §6.1's PUBLIC_URL and + * PUBLIC_HOSTNAME need this set, PUBLIC_ADDRESS and PUBLIC_PORT need its + * complement. */ const HTTP_FAMILY = new Set(['HTTP', 'HTTPS', 'WS', 'GRPC']) @@ -139,10 +140,25 @@ function primaryEndpoint(endpoints: Json | undefined): string | undefined { return publicNames.length === 1 ? publicNames[0] : undefined } +/** + * Which of §5.2's two address forms a reference reads. A probe and the two + * URL-derived platform-default sources need `http`; the two edge-address + * sources need `l4`. + */ +type AddressForm = 'http' | 'l4' + +/** §6.1 — the address form each platform-default source reads. */ +const SOURCE_ADDRESS_FORM: Record = { + PUBLIC_URL: 'http', + PUBLIC_HOSTNAME: 'http', + PUBLIC_ADDRESS: 'l4', + PUBLIC_PORT: 'l4', +} + /** * One place a document names an endpoint: a probe's `endpoint` (§5.4) or a - * platform default's (§6.1). `mustBePublic` is what separates them — both - * platform-default sources derive an externally reachable address. + * platform default's (§6.1). `mustBePublic` is what separates them — every + * platform-default source derives an externally reachable address. */ interface EndpointReference { /** The raw value, so an explicit null and an absent key are one case. */ @@ -150,6 +166,13 @@ interface EndpointReference { readonly path: string readonly subject: string readonly mustBePublic: boolean + /** + * The address form this reference reads, or undefined where the document + * named a source the schema does not define — the structural phase has + * already rejected that, and guessing a form here would report a second + * diagnostic about it. + */ + readonly addressForm: AddressForm | undefined } /** @@ -188,17 +211,29 @@ function checkEndpointReference( 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. + // §5.4 and §6.1 — a reference reads one of §5.2's two address forms, and the + // endpoint has to publish that one. A probe polls an HTTP path; PUBLIC_URL + // and PUBLIC_HOSTNAME take a URL; PUBLIC_ADDRESS and PUBLIC_PORT take the + // edge address only a TCP or UDP endpoint is allocated. 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 + if (protocol !== undefined && reference.addressForm !== undefined) { + const isHttp = HTTP_FAMILY.has(protocol) + if (reference.addressForm === 'http' && !isHttp) { + out.push({ + code: 'ERR_ENDPOINT_NOT_HTTP', + path: reference.path, + message: `${reference.subject} resolves to endpoint "${selected}", which serves ${protocol}`, + }) + return + } + if (reference.addressForm === 'l4' && isHttp) { + out.push({ + code: 'ERR_ENDPOINT_NOT_L4', + path: reference.path, + message: `${reference.subject} resolves to endpoint "${selected}", which serves ${protocol} and is allocated no edge port`, + }) + return + } } if (reference.mustBePublic && child(declared, 'visibility') !== 'PUBLIC') { @@ -223,17 +258,20 @@ function endpointReferences(document: Json): EndpointReference[] { path: `/spec/workload/health/${token(probe)}/endpoint`, subject: `${probe} probe`, mustBePublic: false, + addressForm: 'http', }) } for (const input of keysOf(inputs)) { const platformDefault = child(child(inputs, input), 'platformDefault') if (!isObject(platformDefault)) continue + const source = asString(child(platformDefault, 'source')) references.push({ value: child(platformDefault, 'endpoint'), path: `/spec/contract/inputs/${token(input)}/platformDefault/endpoint`, subject: `platform default on input "${input}"`, mustBePublic: true, + addressForm: source === undefined ? undefined : SOURCE_ADDRESS_FORM[source], }) } @@ -305,22 +343,6 @@ function checkEnvVarKeys(document: Json, out: Diagnostic[]): void { const LOCAL_REFERENCE = /^\.\.?\// -/** Consumer-anchored edges: node → the nodes it reads a value from. */ -function connectionGraph(document: Json): Map { - const components = child(child(document, 'spec'), 'components') - const graph = new Map() - for (const node of keysOf(components)) { - const connections = child(child(components, node), 'connections') - const targets: string[] = [] - for (const key of keysOf(connections)) { - const from = asString(child(child(connections, key), 'fromRole')) - if (from !== undefined) targets.push(from) - } - graph.set(node, targets) - } - return graph -} - /** Blueprint §4.2 — `fromRole` MUST name a node in this blueprint. */ function checkConnectionRoles(document: Json, out: Diagnostic[]): void { const components = child(child(document, 'spec'), 'components') @@ -340,118 +362,6 @@ function checkConnectionRoles(document: Json, out: Diagnostic[]): void { } } -/** - * Blueprint §4.2 — the connection graph MUST be acyclic. - * - * Reporting is normative and pinned to a canonical form: the walk begins at the - * lexicographically smallest node in the cycle. Two implementations that find - * the same cycle then name it identically, instead of leaking whichever node - * their traversal happened to start from into a comparable diagnostic. - * - * Cycles are found as strongly connected components, then reported one per - * component. An SCC is the right granularity: a knot of four mutually reachable - * nodes is one problem, not one per elementary cycle through it. - */ -function checkCycles(document: Json, out: Diagnostic[]): void { - const graph = connectionGraph(document) - const nodes = [...graph.keys()].sort() - - // Iterative Tarjan — a blueprint is small, but a recursive walk would put the - // stack depth at the mercy of the document. - const index = new Map() - const low = new Map() - const onStack = new Set() - const stack: string[] = [] - const components: string[][] = [] - let counter = 0 - - for (const root of nodes) { - if (index.has(root)) continue - const work: { node: string; next: number }[] = [{ node: root, next: 0 }] - index.set(root, counter) - low.set(root, counter) - counter += 1 - stack.push(root) - onStack.add(root) - - while (work.length > 0) { - const frame = work[work.length - 1] - if (frame === undefined) break - const successors = (graph.get(frame.node) ?? []).filter((n) => graph.has(n)).sort() - - if (frame.next < successors.length) { - const successor = successors[frame.next] as string - frame.next += 1 - if (!index.has(successor)) { - index.set(successor, counter) - low.set(successor, counter) - counter += 1 - stack.push(successor) - onStack.add(successor) - work.push({ node: successor, next: 0 }) - } else if (onStack.has(successor)) { - low.set(frame.node, Math.min(low.get(frame.node) ?? 0, index.get(successor) ?? 0)) - } - continue - } - - work.pop() - const parent = work[work.length - 1] - if (parent !== undefined) { - low.set(parent.node, Math.min(low.get(parent.node) ?? 0, low.get(frame.node) ?? 0)) - } - if (low.get(frame.node) === index.get(frame.node)) { - const component: string[] = [] - for (;;) { - const popped = stack.pop() - if (popped === undefined) break - onStack.delete(popped) - component.push(popped) - if (popped === frame.node) break - } - components.push(component) - } - } - } - - for (const component of components) { - const members = new Set(component) - const selfLoop = - component.length === 1 && - (graph.get(component[0] as string) ?? []).includes(component[0] as string) - if (component.length < 2 && !selfLoop) continue - - const first = [...members].sort()[0] as string - out.push({ - code: 'ERR_DEPENDENCY_CYCLE', - path: `/spec/components/${token(first)}/connections`, - message: `connection cycle: ${closedWalk(graph, members, first).join(' -> ')}`, - }) - } -} - -/** - * A closed walk through `members` starting and ending at `first`, choosing the - * lexicographically smallest unvisited successor at each step so the walk is a - * property of the graph rather than of the traversal. - */ -function closedWalk(graph: Map, members: Set, first: string): string[] { - const walk = [first] - const seen = new Set([first]) - let current = first - for (;;) { - const successors = (graph.get(current) ?? []).filter((n) => members.has(n)).sort() - if (successors.includes(first) && walk.length > 1) break - const next = successors.find((n) => !seen.has(n)) - if (next === undefined) break - seen.add(next) - walk.push(next) - current = next - } - walk.push(first) - return walk -} - // =========================================================================== // listing // =========================================================================== @@ -1001,6 +911,12 @@ function checkConnectionCompatibility( * * `ui` and `isRequired` are deliberately not compared. They describe how a value * is asked for, not what it is. + * + * The comparison is over the *canonical* form of each schema block, because + * §5.2's test is equality "once defaults are applied". Serialising the block as + * written would make two identical declarations differ over the order their keys + * happen to appear in and over whether a default was spelled out or left + * implicit — neither of which is a disagreement about the value. */ function checkInputMerge( components: Json | undefined, @@ -1020,7 +936,7 @@ function checkInputMerge( // so it never reaches the merge (§5.1). if (child(input, 'suppliedBy') === 'CONNECTION') continue - const schema = JSON.stringify(child(input, 'schema') ?? null) + const schema = canonicalValueSchema(child(input, 'schema')) const earlier = taken.get(key) if (earlier === undefined) { taken.set(key, { node, schema }) @@ -1036,6 +952,42 @@ function checkInputMerge( } } +/** + * The defaults component §6.1's `schema` block carries. A property left out + * declares the same thing as one written at its default, so both have to reach + * the same canonical form before two blocks are compared. + */ +const VALUE_SCHEMA_DEFAULTS: Record = { + default: null, + format: null, + isSensitive: false, + pattern: null, + semanticType: null, +} + +/** + * A schema block reduced to a form that depends on what it declares rather than + * on how it was written: defaults filled in, keys emitted in a fixed order. + */ +function canonicalValueSchema(schema: Json | undefined): string { + if ( + schema === undefined || + schema === null || + typeof schema !== 'object' || + Array.isArray(schema) + ) { + return JSON.stringify(schema ?? null) + } + const merged: Record = { ...VALUE_SCHEMA_DEFAULTS } + for (const key of keysOf(schema)) { + const value = child(schema, key) + if (value !== undefined) merged[key] = value + } + const canonical: Record = {} + for (const key of Object.keys(merged).sort()) canonical[key] = merged[key] as Json + return JSON.stringify(canonical) +} + /** Present and not null. An optional property spelled `null` sets nothing. */ function isSet(value: Json | undefined): boolean { return value !== undefined && value !== null @@ -1174,7 +1126,6 @@ export function semanticDiagnostics( } if (family.name === 'blueprint') { checkConnectionRoles(document, out) - checkCycles(document, out) } if (family.name === 'listing') { checkScreenshotBasenames(document, out)