diff --git a/conformance/README.md b/conformance/README.md
index 2a56523..a7e3549 100644
--- a/conformance/README.md
+++ b/conformance/README.md
@@ -163,12 +163,13 @@ later-phase diagnostic before the earlier phases pass.
## Coverage status
`parser`, `structural` and `semantic` are covered. Every diagnostic code the
-three `spec.md` files declare is exercised by at least one case, with one
-exception:
+three `spec.md` files declare is exercised by at least one case, with two
+exceptions, both `capability`:
| Code | Why it has no case |
|---|---|
| `ERR_UNKNOWN_COMPONENT` | `capability` — resolving a published reference needs the catalog, and no phase a client runs may reach the network |
+| `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 |
That table is not prose anyone has to remember to update.
`task check:conformance` derives it: every `ERR_*` row in a family's own
diff --git a/conformance/component/v1/cases.json b/conformance/component/v1/cases.json
index 6d45beb..e52f0bb 100644
--- a/conformance/component/v1/cases.json
+++ b/conformance/component/v1/cases.json
@@ -137,6 +137,41 @@
"phase": "structural",
"path": "structural/023-field-from-newer-release"
},
+ {
+ "id": "structural-024-container-port-below-range",
+ "phase": "structural",
+ "path": "structural/024-container-port-below-range"
+ },
+ {
+ "id": "structural-025-container-port-above-range",
+ "phase": "structural",
+ "path": "structural/025-container-port-above-range"
+ },
+ {
+ "id": "structural-026-privileged-container-port",
+ "phase": "structural",
+ "path": "structural/026-privileged-container-port"
+ },
+ {
+ "id": "structural-027-public-tcp-endpoint",
+ "phase": "structural",
+ "path": "structural/027-public-tcp-endpoint"
+ },
+ {
+ "id": "structural-028-public-grpc-endpoint",
+ "phase": "structural",
+ "path": "structural/028-public-grpc-endpoint"
+ },
+ {
+ "id": "structural-029-endpoint-name-not-a-slug",
+ "phase": "structural",
+ "path": "structural/029-endpoint-name-not-a-slug"
+ },
+ {
+ "id": "structural-030-two-public-endpoints",
+ "phase": "structural",
+ "path": "structural/030-two-public-endpoints"
+ },
{
"id": "semantic-001-floating-tag-image-reference",
"phase": "semantic",
@@ -146,6 +181,31 @@
"id": "semantic-002-probe-names-unknown-endpoint",
"phase": "semantic",
"path": "semantic/002-probe-names-unknown-endpoint"
+ },
+ {
+ "id": "semantic-003-platform-default-names-unknown-endpoint",
+ "phase": "semantic",
+ "path": "semantic/003-platform-default-names-unknown-endpoint"
+ },
+ {
+ "id": "semantic-004-platform-default-names-private-endpoint",
+ "phase": "semantic",
+ "path": "semantic/004-platform-default-names-private-endpoint"
+ },
+ {
+ "id": "semantic-005-no-primary-endpoint-elected",
+ "phase": "semantic",
+ "path": "semantic/005-no-primary-endpoint-elected"
+ },
+ {
+ "id": "semantic-006-primary-endpoint-elected",
+ "phase": "semantic",
+ "path": "semantic/006-primary-endpoint-elected"
+ },
+ {
+ "id": "semantic-007-probe-on-endpointless-workload",
+ "phase": "semantic",
+ "path": "semantic/007-probe-on-endpointless-workload"
}
]
}
diff --git a/conformance/component/v1/semantic/003-platform-default-names-unknown-endpoint/case.yaml b/conformance/component/v1/semantic/003-platform-default-names-unknown-endpoint/case.yaml
new file mode 100644
index 0000000..3855e47
--- /dev/null
+++ b/conformance/component/v1/semantic/003-platform-default-names-unknown-endpoint/case.yaml
@@ -0,0 +1,33 @@
+# The platform default derives from "console", and the only endpoint is "api".
+# JSON Schema cannot catch it: the endpoint names are mapping keys elsewhere
+# in the document.
+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:
+ consoleUrl:
+ schema:
+ type: STRING
+ platformDefault:
+ source: PUBLIC_URL
+ endpoint: console
+ ui:
+ label: Console URL
+ outputs: {}
diff --git a/conformance/component/v1/semantic/003-platform-default-names-unknown-endpoint/diagnostics.json b/conformance/component/v1/semantic/003-platform-default-names-unknown-endpoint/diagnostics.json
new file mode 100644
index 0000000..068a4ad
--- /dev/null
+++ b/conformance/component/v1/semantic/003-platform-default-names-unknown-endpoint/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_UNKNOWN_ENDPOINT",
+ "path": "/spec/contract/inputs/consoleUrl/platformDefault/endpoint"
+ }
+]
diff --git a/conformance/component/v1/semantic/003-platform-default-names-unknown-endpoint/metadata.json b/conformance/component/v1/semantic/003-platform-default-names-unknown-endpoint/metadata.json
new file mode 100644
index 0000000..ea2fe00
--- /dev/null
+++ b/conformance/component/v1/semantic/003-platform-default-names-unknown-endpoint/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "semantic-003-platform-default-names-unknown-endpoint",
+ "phase": "semantic",
+ "expected": "fail",
+ "clause": "specifications/component/v1/spec.md#inputs",
+ "summary": "A platform default naming an endpoint the workload does not declare is rejected."
+}
diff --git a/conformance/component/v1/semantic/004-platform-default-names-private-endpoint/case.yaml b/conformance/component/v1/semantic/004-platform-default-names-private-endpoint/case.yaml
new file mode 100644
index 0000000..8dcc154
--- /dev/null
+++ b/conformance/component/v1/semantic/004-platform-default-names-private-endpoint/case.yaml
@@ -0,0 +1,28 @@
+# PUBLIC_URL derives an externally reachable address, and a PRIVATE endpoint
+# has none to give.
+specVersion: v1
+kind: COMPONENT
+metadata:
+ version: 1
+spec:
+ workload:
+ kind: SERVICE
+ source:
+ type: IMAGE
+ ref: nginx:1.29.4-alpine
+ endpoints:
+ internal:
+ containerPort: 8080
+ protocol: HTTP
+ visibility: PRIVATE
+ contract:
+ inputs:
+ selfUrl:
+ schema:
+ type: STRING
+ platformDefault:
+ source: PUBLIC_URL
+ endpoint: internal
+ ui:
+ label: Own URL
+ outputs: {}
diff --git a/conformance/component/v1/semantic/004-platform-default-names-private-endpoint/diagnostics.json b/conformance/component/v1/semantic/004-platform-default-names-private-endpoint/diagnostics.json
new file mode 100644
index 0000000..80d36e0
--- /dev/null
+++ b/conformance/component/v1/semantic/004-platform-default-names-private-endpoint/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_ENDPOINT_NOT_PUBLIC",
+ "path": "/spec/contract/inputs/selfUrl/platformDefault/endpoint"
+ }
+]
diff --git a/conformance/component/v1/semantic/004-platform-default-names-private-endpoint/metadata.json b/conformance/component/v1/semantic/004-platform-default-names-private-endpoint/metadata.json
new file mode 100644
index 0000000..d2efe6c
--- /dev/null
+++ b/conformance/component/v1/semantic/004-platform-default-names-private-endpoint/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "semantic-004-platform-default-names-private-endpoint",
+ "phase": "semantic",
+ "expected": "fail",
+ "clause": "specifications/component/v1/spec.md#inputs",
+ "summary": "A platform default deriving a public address from a PRIVATE endpoint is rejected."
+}
diff --git a/conformance/component/v1/semantic/005-no-primary-endpoint-elected/case.yaml b/conformance/component/v1/semantic/005-no-primary-endpoint-elected/case.yaml
new file mode 100644
index 0000000..48a8a2a
--- /dev/null
+++ b/conformance/component/v1/semantic/005-no-primary-endpoint-elected/case.yaml
@@ -0,0 +1,25 @@
+# Two PUBLIC endpoints, and the probe names neither. Section 5.2 elects no
+# primary here and the reference is rejected rather than resolved by sort
+# order, which would let a new endpoint silently re-point this probe.
+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
+ console:
+ containerPort: 8081
+ protocol: HTTP
+ visibility: PUBLIC
+ health:
+ readiness:
+ path: /healthz
diff --git a/conformance/component/v1/semantic/005-no-primary-endpoint-elected/diagnostics.json b/conformance/component/v1/semantic/005-no-primary-endpoint-elected/diagnostics.json
new file mode 100644
index 0000000..5c6aeaf
--- /dev/null
+++ b/conformance/component/v1/semantic/005-no-primary-endpoint-elected/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_AMBIGUOUS_ENDPOINT",
+ "path": "/spec/workload/health/readiness/endpoint"
+ }
+]
diff --git a/conformance/component/v1/semantic/005-no-primary-endpoint-elected/metadata.json b/conformance/component/v1/semantic/005-no-primary-endpoint-elected/metadata.json
new file mode 100644
index 0000000..616e251
--- /dev/null
+++ b/conformance/component/v1/semantic/005-no-primary-endpoint-elected/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "semantic-005-no-primary-endpoint-elected",
+ "phase": "semantic",
+ "expected": "fail",
+ "clause": "specifications/component/v1/spec.md#endpoints",
+ "summary": "A reference omitting the endpoint is rejected where the workload elects no primary."
+}
diff --git a/conformance/component/v1/semantic/006-primary-endpoint-elected/case.yaml b/conformance/component/v1/semantic/006-primary-endpoint-elected/case.yaml
new file mode 100644
index 0000000..ab2b96a
--- /dev/null
+++ b/conformance/component/v1/semantic/006-primary-endpoint-elected/case.yaml
@@ -0,0 +1,24 @@
+# One PUBLIC endpoint beside a PRIVATE one. The sole PUBLIC endpoint is the
+# primary, so the probe naming none resolves to "web".
+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: PUBLIC
+ metrics:
+ containerPort: 9090
+ protocol: HTTP
+ visibility: PRIVATE
+ health:
+ readiness:
+ path: /healthz
diff --git a/conformance/component/v1/semantic/006-primary-endpoint-elected/metadata.json b/conformance/component/v1/semantic/006-primary-endpoint-elected/metadata.json
new file mode 100644
index 0000000..e26b4ed
--- /dev/null
+++ b/conformance/component/v1/semantic/006-primary-endpoint-elected/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "semantic-006-primary-endpoint-elected",
+ "phase": "semantic",
+ "expected": "pass",
+ "clause": "specifications/component/v1/spec.md#endpoints",
+ "summary": "The sole PUBLIC endpoint is the primary a probe naming none resolves to."
+}
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
new file mode 100644
index 0000000..f39eff4
--- /dev/null
+++ b/conformance/component/v1/semantic/007-probe-on-endpointless-workload/case.yaml
@@ -0,0 +1,17 @@
+# 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.
+specVersion: v1
+kind: COMPONENT
+metadata:
+ version: 1
+spec:
+ workload:
+ kind: SERVICE
+ source:
+ type: IMAGE
+ ref: nginx:1.29.4-alpine
+ health:
+ liveness:
+ path: /healthz
diff --git a/conformance/component/v1/semantic/007-probe-on-endpointless-workload/diagnostics.json b/conformance/component/v1/semantic/007-probe-on-endpointless-workload/diagnostics.json
new file mode 100644
index 0000000..eebe861
--- /dev/null
+++ b/conformance/component/v1/semantic/007-probe-on-endpointless-workload/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_AMBIGUOUS_ENDPOINT",
+ "path": "/spec/workload/health/liveness/endpoint"
+ }
+]
diff --git a/conformance/component/v1/semantic/007-probe-on-endpointless-workload/metadata.json b/conformance/component/v1/semantic/007-probe-on-endpointless-workload/metadata.json
new file mode 100644
index 0000000..322b821
--- /dev/null
+++ b/conformance/component/v1/semantic/007-probe-on-endpointless-workload/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "semantic-007-probe-on-endpointless-workload",
+ "phase": "semantic",
+ "expected": "fail",
+ "clause": "specifications/component/v1/spec.md#endpoints",
+ "summary": "A probe on a workload declaring no endpoint has no port to poll."
+}
diff --git a/conformance/component/v1/structural/024-container-port-below-range/case.yaml b/conformance/component/v1/structural/024-container-port-below-range/case.yaml
new file mode 100644
index 0000000..5798ff4
--- /dev/null
+++ b/conformance/component/v1/structural/024-container-port-below-range/case.yaml
@@ -0,0 +1,16 @@
+# Port 0 binds nothing. The bound is a grammar, so the schema carries it.
+specVersion: v1
+kind: COMPONENT
+metadata:
+ version: 1
+spec:
+ workload:
+ kind: SERVICE
+ source:
+ type: IMAGE
+ ref: nginx:1.29.4-alpine
+ endpoints:
+ web:
+ containerPort: 0
+ protocol: HTTP
+ visibility: PRIVATE
diff --git a/conformance/component/v1/structural/024-container-port-below-range/diagnostics.json b/conformance/component/v1/structural/024-container-port-below-range/diagnostics.json
new file mode 100644
index 0000000..2bc7a78
--- /dev/null
+++ b/conformance/component/v1/structural/024-container-port-below-range/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_INVALID_VALUE",
+ "path": "/spec/workload/endpoints/web/containerPort"
+ }
+]
diff --git a/conformance/component/v1/structural/024-container-port-below-range/metadata.json b/conformance/component/v1/structural/024-container-port-below-range/metadata.json
new file mode 100644
index 0000000..7bf5c73
--- /dev/null
+++ b/conformance/component/v1/structural/024-container-port-below-range/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-024-container-port-below-range",
+ "phase": "structural",
+ "expected": "fail",
+ "clause": "specifications/component/v1/spec.md#endpoints",
+ "summary": "A containerPort below 1 is rejected."
+}
diff --git a/conformance/component/v1/structural/025-container-port-above-range/case.yaml b/conformance/component/v1/structural/025-container-port-above-range/case.yaml
new file mode 100644
index 0000000..eb63685
--- /dev/null
+++ b/conformance/component/v1/structural/025-container-port-above-range/case.yaml
@@ -0,0 +1,16 @@
+# 65535 is the last port there is; 65536 names one that cannot exist.
+specVersion: v1
+kind: COMPONENT
+metadata:
+ version: 1
+spec:
+ workload:
+ kind: SERVICE
+ source:
+ type: IMAGE
+ ref: nginx:1.29.4-alpine
+ endpoints:
+ web:
+ containerPort: 65536
+ protocol: HTTP
+ visibility: PRIVATE
diff --git a/conformance/component/v1/structural/025-container-port-above-range/diagnostics.json b/conformance/component/v1/structural/025-container-port-above-range/diagnostics.json
new file mode 100644
index 0000000..2bc7a78
--- /dev/null
+++ b/conformance/component/v1/structural/025-container-port-above-range/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_INVALID_VALUE",
+ "path": "/spec/workload/endpoints/web/containerPort"
+ }
+]
diff --git a/conformance/component/v1/structural/025-container-port-above-range/metadata.json b/conformance/component/v1/structural/025-container-port-above-range/metadata.json
new file mode 100644
index 0000000..40bc6db
--- /dev/null
+++ b/conformance/component/v1/structural/025-container-port-above-range/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-025-container-port-above-range",
+ "phase": "structural",
+ "expected": "fail",
+ "clause": "specifications/component/v1/spec.md#endpoints",
+ "summary": "A containerPort above 65535 is rejected."
+}
diff --git a/conformance/component/v1/structural/026-privileged-container-port/case.yaml b/conformance/component/v1/structural/026-privileged-container-port/case.yaml
new file mode 100644
index 0000000..846a651
--- /dev/null
+++ b/conformance/component/v1/structural/026-privileged-container-port/case.yaml
@@ -0,0 +1,18 @@
+# A port below 1024 is discouraged and not rejected: whether the container can
+# bind one depends on a capability the runtime grants, which this document
+# cannot see. This case is what keeps that a SHOULD NOT.
+specVersion: v1
+kind: COMPONENT
+metadata:
+ version: 1
+spec:
+ workload:
+ kind: SERVICE
+ source:
+ type: IMAGE
+ ref: nginx:1.29.4-alpine
+ endpoints:
+ web:
+ containerPort: 80
+ protocol: HTTP
+ visibility: PRIVATE
diff --git a/conformance/component/v1/structural/026-privileged-container-port/metadata.json b/conformance/component/v1/structural/026-privileged-container-port/metadata.json
new file mode 100644
index 0000000..6c073f0
--- /dev/null
+++ b/conformance/component/v1/structural/026-privileged-container-port/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-026-privileged-container-port",
+ "phase": "structural",
+ "expected": "pass",
+ "clause": "specifications/component/v1/spec.md#endpoints",
+ "summary": "A privileged containerPort is discouraged but validates."
+}
diff --git a/conformance/component/v1/structural/027-public-tcp-endpoint/case.yaml b/conformance/component/v1/structural/027-public-tcp-endpoint/case.yaml
new file mode 100644
index 0000000..9526c5e
--- /dev/null
+++ b/conformance/component/v1/structural/027-public-tcp-endpoint/case.yaml
@@ -0,0 +1,22 @@
+# 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.
+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/structural/027-public-tcp-endpoint/diagnostics.json b/conformance/component/v1/structural/027-public-tcp-endpoint/diagnostics.json
new file mode 100644
index 0000000..a531e51
--- /dev/null
+++ b/conformance/component/v1/structural/027-public-tcp-endpoint/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "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
new file mode 100644
index 0000000..ee2a2ed
--- /dev/null
+++ b/conformance/component/v1/structural/027-public-tcp-endpoint/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-027-public-tcp-endpoint",
+ "phase": "structural",
+ "expected": "fail",
+ "clause": "specifications/component/v1/spec.md#endpoints",
+ "summary": "A TCP endpoint may not be PUBLIC."
+}
diff --git a/conformance/component/v1/structural/028-public-grpc-endpoint/case.yaml b/conformance/component/v1/structural/028-public-grpc-endpoint/case.yaml
new file mode 100644
index 0000000..b3ce514
--- /dev/null
+++ b/conformance/component/v1/structural/028-public-grpc-endpoint/case.yaml
@@ -0,0 +1,21 @@
+# GRPC rides on HTTP/2, so it is one of the four protocols a PUBLIC endpoint
+# may serve.
+specVersion: v1
+kind: COMPONENT
+metadata:
+ version: 1
+spec:
+ workload:
+ kind: SERVICE
+ source:
+ type: IMAGE
+ ref: nginx:1.29.4-alpine
+ endpoints:
+ rpc:
+ containerPort: 8080
+ protocol: GRPC
+ visibility: PUBLIC
+ health:
+ readiness:
+ path: /healthz
+ endpoint: rpc
diff --git a/conformance/component/v1/structural/028-public-grpc-endpoint/metadata.json b/conformance/component/v1/structural/028-public-grpc-endpoint/metadata.json
new file mode 100644
index 0000000..85a6938
--- /dev/null
+++ b/conformance/component/v1/structural/028-public-grpc-endpoint/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-028-public-grpc-endpoint",
+ "phase": "structural",
+ "expected": "pass",
+ "clause": "specifications/component/v1/spec.md#endpoints",
+ "summary": "GRPC is one of the protocols a PUBLIC endpoint may serve."
+}
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
new file mode 100644
index 0000000..fc4a46a
--- /dev/null
+++ b/conformance/component/v1/structural/029-endpoint-name-not-a-slug/case.yaml
@@ -0,0 +1,17 @@
+# 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.
+specVersion: v1
+kind: COMPONENT
+metadata:
+ version: 1
+spec:
+ workload:
+ kind: SERVICE
+ source:
+ type: IMAGE
+ ref: nginx:1.29.4-alpine
+ endpoints:
+ web.api:
+ containerPort: 8080
+ protocol: HTTP
+ visibility: PRIVATE
diff --git a/conformance/component/v1/structural/029-endpoint-name-not-a-slug/diagnostics.json b/conformance/component/v1/structural/029-endpoint-name-not-a-slug/diagnostics.json
new file mode 100644
index 0000000..0682289
--- /dev/null
+++ b/conformance/component/v1/structural/029-endpoint-name-not-a-slug/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_INVALID_VALUE",
+ "path": "/spec/workload/endpoints"
+ }
+]
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
new file mode 100644
index 0000000..fd05591
--- /dev/null
+++ b/conformance/component/v1/structural/029-endpoint-name-not-a-slug/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-029-endpoint-name-not-a-slug",
+ "phase": "structural",
+ "expected": "fail",
+ "clause": "specifications/component/v1/spec.md#endpoints",
+ "summary": "An endpoint name outside the slug grammar is rejected."
+}
diff --git a/conformance/component/v1/structural/030-two-public-endpoints/case.yaml b/conformance/component/v1/structural/030-two-public-endpoints/case.yaml
new file mode 100644
index 0000000..82ba1f9
--- /dev/null
+++ b/conformance/component/v1/structural/030-two-public-endpoints/case.yaml
@@ -0,0 +1,36 @@
+# A component may front two public URLs. Each is addressed by name, which is
+# what makes the second one no harder than the first.
+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
+ console:
+ containerPort: 8081
+ protocol: HTTP
+ visibility: PUBLIC
+ health:
+ readiness:
+ path: /healthz
+ endpoint: api
+ contract:
+ inputs:
+ consoleUrl:
+ schema:
+ type: STRING
+ platformDefault:
+ source: PUBLIC_URL
+ endpoint: console
+ ui:
+ label: Console URL
+ outputs: {}
diff --git a/conformance/component/v1/structural/030-two-public-endpoints/metadata.json b/conformance/component/v1/structural/030-two-public-endpoints/metadata.json
new file mode 100644
index 0000000..7697ef9
--- /dev/null
+++ b/conformance/component/v1/structural/030-two-public-endpoints/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-030-two-public-endpoints",
+ "phase": "structural",
+ "expected": "pass",
+ "clause": "specifications/component/v1/spec.md#endpoints",
+ "summary": "Two PUBLIC endpoints validate when each public address names one."
+}
diff --git a/specifications/component/v1/examples/multi-endpoint.yaml b/specifications/component/v1/examples/multi-endpoint.yaml
new file mode 100644
index 0000000..3671d22
--- /dev/null
+++ b/specifications/component/v1/examples/multi-endpoint.yaml
@@ -0,0 +1,45 @@
+# 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.
+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
+ console:
+ containerPort: 8081
+ protocol: HTTP
+ visibility: PUBLIC
+ metrics:
+ containerPort: 9090
+ protocol: HTTP
+ visibility: PRIVATE
+ health:
+ readiness:
+ path: /healthz
+ endpoint: api
+ contract:
+ inputs:
+ consoleUrl:
+ schema:
+ type: STRING
+ platformDefault:
+ source: PUBLIC_URL
+ endpoint: console
+ ui:
+ label: Console URL
+ target:
+ envVarKey: CONSOLE_URL
+ outputs: {}
diff --git a/specifications/component/v1/schemas/dist/component.schema.json b/specifications/component/v1/schemas/dist/component.schema.json
index a42462d..055178d 100644
--- a/specifications/component/v1/schemas/dist/component.schema.json
+++ b/specifications/component/v1/schemas/dist/component.schema.json
@@ -125,9 +125,38 @@
},
"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"
+ ]
+ }
+ }
+ }
+ }
+ ],
"properties": {
"containerPort": {
- "description": "Port the workload listens on inside the container.",
+ "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.",
+ "maximum": 65535,
+ "minimum": 1,
"type": "integer"
},
"protocol": {
@@ -415,14 +444,10 @@
"type": "boolean"
},
"platformDefault": {
- "description": "Platform-derived default bound into the target env var — PUBLIC_HOSTNAME injects the component's own public endpoint hostname, PUBLIC_URL the full https URL of that endpoint; null when the value is user-supplied, wired, or generated.",
+ "description": "Default derived from the component's own addressing and bound into the target env var; null when the value is user-supplied, wired, or generated.",
"anyOf": [
{
- "enum": [
- "PUBLIC_HOSTNAME",
- "PUBLIC_URL"
- ],
- "type": "string"
+ "$ref": "#/$defs/ComponentPlatformDefault"
},
{
"type": "null"
@@ -619,13 +644,45 @@
"type": "object",
"x-additionalPropertiesName": "outputName"
},
+ "ComponentPlatformDefault": {
+ "description": "Default derived from the component's own public addressing. Which endpoint it derives from is named rather than assumed, because spec.md §5.2 permits more than one PUBLIC endpoint.",
+ "additionalProperties": false,
+ "properties": {
+ "endpoint": {
+ "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]$",
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null
+ },
+ "source": {
+ "description": "What is derived: PUBLIC_HOSTNAME for the endpoint's hostname alone, PUBLIC_URL for its full URL.",
+ "enum": [
+ "PUBLIC_HOSTNAME",
+ "PUBLIC_URL"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "source"
+ ],
+ "type": "object"
+ },
"ComponentProbe": {
"additionalProperties": false,
"properties": {
"endpoint": {
- "description": "Name of the endpoint whose port the probe targets. Null selects the primary endpoint.",
+ "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]$",
"type": "string"
},
{
@@ -1008,6 +1065,10 @@
"additionalProperties": {
"$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]$"
+ },
"type": "object"
},
"envVars": {
diff --git a/specifications/component/v1/schemas/src/component.schema.json b/specifications/component/v1/schemas/src/component.schema.json
index e14b4cc..5f1df05 100644
--- a/specifications/component/v1/schemas/src/component.schema.json
+++ b/specifications/component/v1/schemas/src/component.schema.json
@@ -125,9 +125,38 @@
},
"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"
+ ]
+ }
+ }
+ }
+ }
+ ],
"properties": {
"containerPort": {
- "description": "Port the workload listens on inside the container.",
+ "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.",
+ "maximum": 65535,
+ "minimum": 1,
"type": "integer"
},
"protocol": {
@@ -417,18 +446,14 @@
"platformDefault": {
"anyOf": [
{
- "enum": [
- "PUBLIC_HOSTNAME",
- "PUBLIC_URL"
- ],
- "type": "string"
+ "$ref": "#/$defs/ComponentPlatformDefault"
},
{
"type": "null"
}
],
"default": null,
- "description": "Platform-derived default bound into the target env var — PUBLIC_HOSTNAME injects the component's own public endpoint hostname, PUBLIC_URL the full https URL of that endpoint; null when the value is user-supplied, wired, or generated."
+ "description": "Default derived from the component's own addressing and bound into the target env var; null when the value is user-supplied, wired, or generated."
},
"schema": {
"$ref": "#/$defs/ComponentValueSchema",
@@ -619,12 +644,44 @@
"type": "object",
"x-additionalPropertiesName": "outputName"
},
+ "ComponentPlatformDefault": {
+ "additionalProperties": false,
+ "description": "Default derived from the component's own public addressing. Which endpoint it derives from is named rather than assumed, because spec.md §5.2 permits more than one PUBLIC endpoint.",
+ "properties": {
+ "endpoint": {
+ "anyOf": [
+ {
+ "pattern": "^[a-z][a-z0-9-]{0,61}[a-z0-9]$",
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "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.",
+ "enum": [
+ "PUBLIC_HOSTNAME",
+ "PUBLIC_URL"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "source"
+ ],
+ "type": "object"
+ },
"ComponentProbe": {
"additionalProperties": false,
"properties": {
"endpoint": {
"anyOf": [
{
+ "pattern": "^[a-z][a-z0-9-]{0,61}[a-z0-9]$",
"type": "string"
},
{
@@ -632,7 +689,7 @@
}
],
"default": null,
- "description": "Name of the endpoint whose port the probe targets. Null selects the primary endpoint."
+ "description": "Name of the endpoint whose port the probe targets. Null selects the primary endpoint spec.md §5.2 elects."
},
"failureThreshold": {
"default": 3,
@@ -1008,6 +1065,10 @@
"$ref": "#/$defs/ComponentEndpoint"
},
"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]$"
+ },
"type": "object"
},
"envVars": {
diff --git a/specifications/component/v1/spec.md b/specifications/component/v1/spec.md
index b298bbe..cedab2c 100644
--- a/specifications/component/v1/spec.md
+++ b/specifications/component/v1/spec.md
@@ -115,9 +115,69 @@ release its consumers hold.
## 4. Metadata
-> **TODO** — Define `metadata.version` semantics: monotonicity requirements, the
-> relationship to the containing item's version, and whether reuse of a version
-> number with different content is an error.
+`metadata` carries `version` and nothing else. A component document has no
+`slug`. A [blueprint](../../blueprint/v1/spec.md#identity) and its listing each
+name the item they are two halves of; a component is not the item, and the name
+it answers to is the stem of the file that holds it — which is what a repo-local
+reference spells out in full
+([blueprint §4.1](../../blueprint/v1/spec.md#component-reference)). Any other
+property is `ERR_UNKNOWN_FIELD`, as [§2](#envelope) requires at every level.
+
+`version` is an integer, 1 or greater. It is REQUIRED and never defaulted, so
+what a node deploys is a function of this file alone.
+
+**The version names a position in one component's lineage.** It is not a SemVer
+triple and carries no compatibility meaning: nothing is derivable from the
+distance between 2 and 7, and nothing is promised about how one version behaves
+against another. It orders, and that is the whole of its job.
+
+**Two reference forms pin it, and only one writes it down.**
+
+| Reference form | What pins the version |
+|---|---|
+| Repo-local | The referenced document's own `metadata.version`. `componentVersion` MUST NOT be present. |
+| Published | `componentVersion` on the node. |
+
+**A version is used once.** Each publication of a component MUST carry a version
+strictly greater than the highest already published for that component. Gaps are
+permitted — 1 to 7 is a release and not an error — but a version that does not
+increase is rejected in the `capability` phase with `ERR_VERSION_NOT_MONOTONIC`.
+
+Reuse is the case the rule exists for. `componentVersion: 3` on a published node
+is the whole of what that node deploys, and a registry that let 3 mean two
+different documents would make the pin name nothing. The repo-local form has the
+same problem one step removed: a blueprint that deployed version 3 last month
+and version 3 today, with different bytes behind it, has no way to say so.
+
+**Why the phase is `capability`.** Deciding the rule needs to know what was
+published before, which needs the catalog, which needs the network — and
+[§7](#validation-layers) forbids the `parser`, `structural` and `semantic`
+phases from requiring it. A client validating a file it has just written cannot
+see the lineage and MUST NOT report this rule. Offline validation is therefore
+exactly as strict as it was.
+
+Whether a registry treats an identical re-submission as a no-op rather than as a
+publication is outside this contract. This document orders publications; it does
+not define when two YAML files are the same document.
+
+**The version is not the item's version.**
+[Blueprint §3](../../blueprint/v1/spec.md#identity) pins a blueprint to its
+sibling listing, and neither is pinned to any component beneath it. The two
+numbers count different things: an item's version counts releases of the item, a
+component's counts releases of the component, and in the published form one
+component is deployed by many items at once.
+
+A release of a component an item deploys SHOULD be accompanied by a release of
+the item, because the listing describes what would be installed and a component
+that has moved makes that description stale. It is a SHOULD and carries no
+diagnostic: the disagreement is visible only across two revisions, and a
+validator is handed one.
+
+**What v1 does not constrain.** Nothing orders one component's versions against
+another's — two components in the same item sitting at 4 and 11 mean nothing
+worth reading into. Nothing checks a version offline at all: `minimum: 1` is the
+whole of the `structural` rule, and every other statement in this section is
+either `capability` or a SHOULD.
## 5. Workload
@@ -201,8 +261,82 @@ resolves. The floating-tag rule applies only to a reference carrying no digest.
### 5.2 Endpoints
-> **TODO** — Port range, protocol/visibility interaction, and whether more than
-> one `PUBLIC` endpoint is permitted.
+`endpoints` is a mapping from endpoint name to one port the workload listens
+on. `containerPort`, `protocol` and `visibility` are all REQUIRED — an endpoint
+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
+`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.
+
+**`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`.
+
+A port below 1024 SHOULD NOT be used. Binding one needs a capability the runtime
+grants to the container, and a workload that does not hold it fails at deploy
+time with nothing in the document to blame. It stays advice rather than a
+rejection because whether that grant exists is a fact about the runtime this
+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`.
+
+[§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.
+
+**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.
+
+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
+"the component's URL". [§6.1](#inputs) is where that bites, and where the
+selector lives.
+
+**The primary endpoint.** A reference MAY omit the endpoint it targets — a
+probe's `endpoint` and a platform default's both admit null — and the primary
+endpoint is what null selects. It is:
+
+1. the workload's sole endpoint, where it declares exactly one; failing that
+2. its sole `PUBLIC` endpoint, where it declares exactly one; failing that
+3. nothing.
+
+Where it is nothing, a reference that omits the endpoint is rejected in the
+`semantic` phase with `ERR_AMBIGUOUS_ENDPOINT`. The schema cannot express this
+for the reason [§5.4](#health) gives: the endpoint names are mapping keys
+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.
+
+**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
+`api` silently re-point a probe that has worked for a year. A rule that changes
+what an unedited line means is the failure [§2](#envelope) rejects a misspelled
+optional field to avoid.
+
+**What v1 does not constrain.** Two endpoints MAY declare the same
+`containerPort`, and nothing says which of them anything routing to that port
+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.
### 5.3 Environment variables
@@ -225,10 +359,12 @@ polls an HTTP path.
`successThreshold: 1`, `failureThreshold: 3`.
`endpoint` names the endpoint whose port the probe targets; null selects the
-primary endpoint. A probe naming an endpoint the workload does not declare is
-rejected in the `semantic` phase with `ERR_UNKNOWN_ENDPOINT`. The schema cannot
-express it — the endpoint names are mapping keys elsewhere in the document, and
-JSON Schema cannot constrain a value against a sibling's keys.
+primary endpoint [§5.2](#endpoints) elects, and is rejected with
+`ERR_AMBIGUOUS_ENDPOINT` where that section elects none. A probe naming an
+endpoint the workload does not declare is rejected with `ERR_UNKNOWN_ENDPOINT`.
+Both are `semantic`, and the schema can express neither — the endpoint names are
+mapping keys elsewhere in the document, and JSON Schema cannot constrain a value
+against a sibling's keys.
**`readiness` is REQUIRED for a `SERVICE` exposing at least one `PUBLIC`
endpoint**, and OPTIONAL everywhere else, including on a `SERVICE` whose
@@ -291,6 +427,28 @@ marked sensitive is echoed back into logs and interfaces. The value rides on a
`USER` input because that is the slot the install form already reserves for it
— the user simply does not have to type it.
+**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.
+
+`endpoint` is null by default and selects the primary endpoint
+[§5.2](#endpoints) elects. Since a component MAY expose several `PUBLIC`
+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
+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.
+
+**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
+`platformDefault` in v1 — a gap, recorded rather than described as a decision.
+
### 6.2 Outputs
`schema` and `valueFrom` are REQUIRED.
@@ -372,13 +530,16 @@ 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_UNKNOWN_ENDPOINT` | `semantic` | A probe names an endpoint the workload does not declare. |
+| `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_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
[blueprint](../../blueprint/v1/spec.md#diagnostics) and
[listing](../../listing/v1/spec.md#diagnostics) families declare themselves
-additions to this table rather than restating it. The two `semantic` codes are
-this family's own.
+additions to this table rather than restating it. The `semantic` and
+`capability` rows are this family's own.
## 9. Conformance
diff --git a/tools/src/conformance.ts b/tools/src/conformance.ts
index 73dde3e..d667787 100644
--- a/tools/src/conformance.ts
+++ b/tools/src/conformance.ts
@@ -433,6 +433,10 @@ const UNCOVERED: ReadonlyMap = new Map([
'ERR_UNKNOWN_COMPONENT',
'capability — resolving a published reference needs the catalog, and no phase this repository runs may reach the network',
],
+ [
+ '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',
+ ],
])
/**
diff --git a/tools/src/semantic.ts b/tools/src/semantic.ts
index 3522a4a..b13f4c9 100644
--- a/tools/src/semantic.ts
+++ b/tools/src/semantic.ts
@@ -113,26 +113,113 @@ function checkImageRef(document: Json, out: Diagnostic[]): void {
}
/**
- * Component §5.4 — a probe's `endpoint` MUST name a declared endpoint. JSON
- * Schema cannot: the names are mapping keys elsewhere in the document, and no
- * keyword constrains a value against a sibling's keys.
+ * Component §5.2 — the endpoint a null reference selects. The sole endpoint
+ * where the workload declares exactly one, failing that its sole PUBLIC one,
+ * failing that nothing.
+ *
+ * §5.2 chose "nothing" over a sort-order tiebreak deliberately: a tiebreak
+ * lets a new endpoint named `api` silently re-point a probe that already works.
*/
-function checkProbeEndpoints(document: Json, out: Diagnostic[]): void {
- const workload = child(child(document, 'spec'), 'workload')
- const health = child(workload, 'health')
- if (!isObject(health)) return
+function primaryEndpoint(endpoints: Json | undefined): string | undefined {
+ const names = keysOf(endpoints)
+ if (names.length === 1) return names[0]
+
+ const publicNames = names.filter(
+ (name) => child(child(endpoints, name), 'visibility') === 'PUBLIC',
+ )
+ return publicNames.length === 1 ? publicNames[0] : undefined
+}
- const declared = new Set(keysOf(child(workload, 'endpoints')))
- for (const probe of keysOf(health)) {
- // null selects the primary endpoint, so only a named one can be wrong.
- const endpoint = asString(child(child(health, probe), 'endpoint'))
- if (endpoint !== undefined && !declared.has(endpoint)) {
+/**
+ * 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.
+ */
+interface EndpointReference {
+ /** The raw value, so an explicit null and an absent key are one case. */
+ readonly value: Json | undefined
+ readonly path: string
+ readonly subject: string
+ readonly mustBePublic: boolean
+}
+
+/**
+ * Component §5.2, §5.4 and §6.1. JSON Schema can express none of this: the
+ * endpoint names are mapping keys elsewhere in the document, and no keyword
+ * constrains a value against a sibling's keys.
+ */
+function checkEndpointReference(
+ reference: EndpointReference,
+ endpoints: Json | undefined,
+ out: Diagnostic[],
+): void {
+ 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_UNKNOWN_ENDPOINT',
- path: `/spec/workload/health/${token(probe)}/endpoint`,
- message: `probe targets endpoint "${endpoint}", which the workload does not declare`,
+ 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)
+ if (declared === undefined) {
+ out.push({
+ code: 'ERR_UNKNOWN_ENDPOINT',
+ path: reference.path,
+ message: `${reference.subject} targets endpoint "${named}", which the workload does not declare`,
+ })
+ return
+ }
+
+ if (reference.mustBePublic && child(declared, 'visibility') !== 'PUBLIC') {
+ out.push({
+ code: 'ERR_ENDPOINT_NOT_PUBLIC',
+ path: reference.path,
+ message: `${reference.subject} derives a public address from endpoint "${named}", which is PRIVATE`,
+ })
+ }
+}
+
+/** Every endpoint a component document names, in document order. */
+function endpointReferences(document: Json): EndpointReference[] {
+ const spec = child(document, 'spec')
+ const health = child(child(spec, 'workload'), 'health')
+ const inputs = child(child(spec, 'contract'), 'inputs')
+ const references: EndpointReference[] = []
+
+ for (const probe of keysOf(health)) {
+ references.push({
+ value: child(child(health, probe), 'endpoint'),
+ path: `/spec/workload/health/${token(probe)}/endpoint`,
+ subject: `${probe} probe`,
+ mustBePublic: false,
+ })
+ }
+
+ for (const input of keysOf(inputs)) {
+ const platformDefault = child(child(inputs, input), 'platformDefault')
+ if (!isObject(platformDefault)) continue
+ references.push({
+ value: child(platformDefault, 'endpoint'),
+ path: `/spec/contract/inputs/${token(input)}/platformDefault/endpoint`,
+ subject: `platform default on input "${input}"`,
+ mustBePublic: true,
+ })
+ }
+
+ return references
+}
+
+function checkEndpointReferences(document: Json, out: Diagnostic[]): void {
+ const endpoints = child(child(child(document, 'spec'), 'workload'), 'endpoints')
+ for (const reference of endpointReferences(document)) {
+ checkEndpointReference(reference, endpoints, out)
}
}
@@ -638,7 +725,7 @@ export function semanticDiagnostics(
if (family.name === 'component') {
checkImageRef(document, out)
- checkProbeEndpoints(document, out)
+ checkEndpointReferences(document, out)
}
if (family.name === 'blueprint') {
checkConnectionRoles(document, out)