diff --git a/conformance/README.md b/conformance/README.md
index d9c9750..6d064dd 100644
--- a/conformance/README.md
+++ b/conformance/README.md
@@ -101,10 +101,29 @@ later-phase diagnostic before the earlier phases pass.
## Coverage status
-Cases exist today for the `parser` and `structural` phases only. `semantic` and
-`capability` cases land once the corresponding rules are written into `spec.md`
-— a fixture without a normative clause to cite is an assertion about an
-implementation, not about the specification.
+`parser` and `structural` are covered. `semantic` is covered for every rule a
+single document can express — a floating image tag, a probe naming an endpoint
+that is not there, a connection naming a node that is not there, a cyclic
+graph, two screenshots sharing a basename. `capability` has no cases.
+
+The gap is not which rules are written down; it is what a case can say. Every
+remaining `semantic` rule is about a document's surroundings rather than its
+contents:
+
+| Rule | Needs |
+|---|---|
+| `ERR_SLUG_MISMATCH` | a directory with a name |
+| `ERR_VERSION_MISMATCH` | a sibling document |
+| `ERR_UNREFERENCED_COMPONENT` | the item's other files |
+| `ERR_COMPONENT_NOT_FOUND`, `ERR_REFERENCE_ESCAPE` | a resolvable target |
+| `ERR_UNKNOWN_OUTPUT` | the referenced component document |
+| `ERR_MEDIA_NOT_FOUND`, `ERR_PATH_ESCAPE` | a file on disk |
+
+A case is one `case.yaml`, so none of those is expressible. Extending the
+fixture contract to a case tree is an ADR-gated change — GOVERNANCE.md lists
+"Changing the conformance fixture contract" among the changes needing one —
+and those cases land against that ADR rather than being approximated in the
+meantime.
An adapter encountering a phase it does not implement SHOULD skip the case and
report it as skipped. It MUST NOT report it as passed.
@@ -119,3 +138,11 @@ report it as skipped. It MUST NOT report it as passed.
A case that does not cite a `clause` will be questioned in review. Fixtures
exist to pin down prose, not to freeze current implementation behaviour.
+
+`task check:conformance` checks two separate things, and only the first of them
+needs an implemented phase. Whether or not a case can be *executed* here, its
+metadata is validated: the `id` leads with its phase, `cases.json` and
+`metadata.json` agree on that phase, the `clause` resolves to an anchor that
+exists in the cited `spec.md`, and every declared `code` appears in a
+diagnostics table reachable from the family's own — at the phase that table
+assigns it. A `semantic` fixture is skipped for execution but not for this.
diff --git a/conformance/blueprint/v1/cases.json b/conformance/blueprint/v1/cases.json
index 30fb155..74eb5da 100644
--- a/conformance/blueprint/v1/cases.json
+++ b/conformance/blueprint/v1/cases.json
@@ -61,6 +61,21 @@
"id": "structural-011-traversal-inside-local-reference",
"phase": "structural",
"path": "structural/011-traversal-inside-local-reference"
+ },
+ {
+ "id": "structural-012-invalid-node-name",
+ "phase": "structural",
+ "path": "structural/012-invalid-node-name"
+ },
+ {
+ "id": "semantic-001-connection-names-unknown-role",
+ "phase": "semantic",
+ "path": "semantic/001-connection-names-unknown-role"
+ },
+ {
+ "id": "semantic-002-connection-cycle",
+ "phase": "semantic",
+ "path": "semantic/002-connection-cycle"
}
]
}
diff --git a/conformance/blueprint/v1/semantic/001-connection-names-unknown-role/case.yaml b/conformance/blueprint/v1/semantic/001-connection-names-unknown-role/case.yaml
new file mode 100644
index 0000000..710fc50
--- /dev/null
+++ b/conformance/blueprint/v1/semantic/001-connection-names-unknown-role/case.yaml
@@ -0,0 +1,23 @@
+# `fromRole: database` names no node — the only other node is `db`. A
+# connection cannot reach outside the graph it is written in, so there is
+# nowhere else this could have meant. The whole graph is in this one document,
+# which is why the check needs no sibling file.
+specVersion: v1
+kind: BLUEPRINT
+metadata:
+ slug: web-and-database
+ version: 1
+spec:
+ components:
+ db:
+ component: ./components/postgres.yaml
+ size: general.standard.small
+ connections: {}
+ web:
+ component: ./components/web.yaml
+ size: general.standard.small
+ connections:
+ DATABASE_URL:
+ fromRole: database
+ fromOutput: connectionString
+ parameters: {}
diff --git a/conformance/blueprint/v1/semantic/001-connection-names-unknown-role/diagnostics.json b/conformance/blueprint/v1/semantic/001-connection-names-unknown-role/diagnostics.json
new file mode 100644
index 0000000..040b968
--- /dev/null
+++ b/conformance/blueprint/v1/semantic/001-connection-names-unknown-role/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_UNKNOWN_ROLE",
+ "path": "/spec/components/web/connections/DATABASE_URL/fromRole"
+ }
+]
diff --git a/conformance/blueprint/v1/semantic/001-connection-names-unknown-role/metadata.json b/conformance/blueprint/v1/semantic/001-connection-names-unknown-role/metadata.json
new file mode 100644
index 0000000..fcc8792
--- /dev/null
+++ b/conformance/blueprint/v1/semantic/001-connection-names-unknown-role/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "semantic-001-connection-names-unknown-role",
+ "phase": "semantic",
+ "expected": "fail",
+ "clause": "specifications/blueprint/v1/spec.md#connections",
+ "summary": "A connection whose fromRole names no node in this blueprint is rejected."
+}
diff --git a/conformance/blueprint/v1/semantic/002-connection-cycle/case.yaml b/conformance/blueprint/v1/semantic/002-connection-cycle/case.yaml
new file mode 100644
index 0000000..36c9fae
--- /dev/null
+++ b/conformance/blueprint/v1/semantic/002-connection-cycle/case.yaml
@@ -0,0 +1,31 @@
+# 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.
+#
+# 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.
+specVersion: v1
+kind: BLUEPRINT
+metadata:
+ slug: mutual-discovery
+ version: 1
+spec:
+ components:
+ api:
+ component: ./components/api.yaml
+ size: general.standard.small
+ connections:
+ WEB_URL:
+ fromRole: web
+ fromOutput: publicUrl
+ web:
+ component: ./components/web.yaml
+ size: general.standard.small
+ connections:
+ API_URL:
+ fromRole: api
+ fromOutput: privateAddress
+ parameters: {}
diff --git a/conformance/blueprint/v1/semantic/002-connection-cycle/diagnostics.json b/conformance/blueprint/v1/semantic/002-connection-cycle/diagnostics.json
new file mode 100644
index 0000000..ab154e3
--- /dev/null
+++ b/conformance/blueprint/v1/semantic/002-connection-cycle/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "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
new file mode 100644
index 0000000..cd9eb5d
--- /dev/null
+++ b/conformance/blueprint/v1/semantic/002-connection-cycle/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "semantic-002-connection-cycle",
+ "phase": "semantic",
+ "expected": "fail",
+ "clause": "specifications/blueprint/v1/spec.md#connections",
+ "summary": "A cyclic connection graph is rejected, and the walk is reported from its smallest node."
+}
diff --git a/conformance/blueprint/v1/structural/012-invalid-node-name/case.yaml b/conformance/blueprint/v1/structural/012-invalid-node-name/case.yaml
new file mode 100644
index 0000000..9a79130
--- /dev/null
+++ b/conformance/blueprint/v1/structural/012-invalid-node-name/case.yaml
@@ -0,0 +1,15 @@
+# `Web_Server` is not a node name. The grammar is the one `metadata.slug`
+# uses, so a name that reads as an identifier in one language and not in
+# another cannot be what a connection's `fromRole` refers to.
+specVersion: v1
+kind: BLUEPRINT
+metadata:
+ slug: web-and-database
+ version: 1
+spec:
+ components:
+ Web_Server:
+ component: ./components/web.yaml
+ size: general.standard.small
+ connections: {}
+ parameters: {}
diff --git a/conformance/blueprint/v1/structural/012-invalid-node-name/diagnostics.json b/conformance/blueprint/v1/structural/012-invalid-node-name/diagnostics.json
new file mode 100644
index 0000000..e28b1a4
--- /dev/null
+++ b/conformance/blueprint/v1/structural/012-invalid-node-name/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_INVALID_VALUE",
+ "path": "/spec/components"
+ }
+]
diff --git a/conformance/blueprint/v1/structural/012-invalid-node-name/metadata.json b/conformance/blueprint/v1/structural/012-invalid-node-name/metadata.json
new file mode 100644
index 0000000..0b4eedc
--- /dev/null
+++ b/conformance/blueprint/v1/structural/012-invalid-node-name/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-012-invalid-node-name",
+ "phase": "structural",
+ "expected": "fail",
+ "clause": "specifications/blueprint/v1/spec.md#components",
+ "summary": "A node name outside the slug grammar is rejected."
+}
diff --git a/conformance/component/v1/cases.json b/conformance/component/v1/cases.json
index f872981..95d57e1 100644
--- a/conformance/component/v1/cases.json
+++ b/conformance/component/v1/cases.json
@@ -31,6 +31,101 @@
"id": "structural-005-nested-unknown-field",
"phase": "structural",
"path": "structural/005-nested-unknown-field"
+ },
+ {
+ "id": "structural-006-non-service-with-endpoints",
+ "phase": "structural",
+ "path": "structural/006-non-service-with-endpoints"
+ },
+ {
+ "id": "structural-007-non-service-with-empty-endpoints",
+ "phase": "structural",
+ "path": "structural/007-non-service-with-empty-endpoints"
+ },
+ {
+ "id": "structural-008-job-without-command",
+ "phase": "structural",
+ "path": "structural/008-job-without-command"
+ },
+ {
+ "id": "structural-009-cron-workload",
+ "phase": "structural",
+ "path": "structural/009-cron-workload"
+ },
+ {
+ "id": "structural-010-cron-without-schedule",
+ "phase": "structural",
+ "path": "structural/010-cron-without-schedule"
+ },
+ {
+ "id": "structural-011-schedule-on-service",
+ "phase": "structural",
+ "path": "structural/011-schedule-on-service"
+ },
+ {
+ "id": "structural-012-unpinned-image-reference",
+ "phase": "structural",
+ "path": "structural/012-unpinned-image-reference"
+ },
+ {
+ "id": "structural-013-digest-pinned-image-reference",
+ "phase": "structural",
+ "path": "structural/013-digest-pinned-image-reference"
+ },
+ {
+ "id": "structural-014-git-source-without-build",
+ "phase": "structural",
+ "path": "structural/014-git-source-without-build"
+ },
+ {
+ "id": "structural-015-public-service-without-readiness",
+ "phase": "structural",
+ "path": "structural/015-public-service-without-readiness"
+ },
+ {
+ "id": "structural-016-private-service-without-readiness",
+ "phase": "structural",
+ "path": "structural/016-private-service-without-readiness"
+ },
+ {
+ "id": "structural-017-relative-mount-path",
+ "phase": "structural",
+ "path": "structural/017-relative-mount-path"
+ },
+ {
+ "id": "structural-018-generated-input-not-sensitive",
+ "phase": "structural",
+ "path": "structural/018-generated-input-not-sensitive"
+ },
+ {
+ "id": "structural-019-connection-input-with-ui",
+ "phase": "structural",
+ "path": "structural/019-connection-input-with-ui"
+ },
+ {
+ "id": "structural-020-user-input-without-ui",
+ "phase": "structural",
+ "path": "structural/020-user-input-without-ui"
+ },
+ {
+ "id": "structural-021-derived-output-with-value",
+ "phase": "structural",
+ "path": "structural/021-derived-output-with-value"
+ },
+ {
+ "id": "structural-022-contract-both-output-forms",
+ "phase": "structural",
+ "path": "structural/022-contract-both-output-forms"
+ },
+ {
+ "id": "semantic-001-floating-tag-image-reference",
+ "phase": "semantic",
+ "path": "semantic/001-floating-tag-image-reference"
+ },
+ {
+ "id": "semantic-002-probe-names-unknown-endpoint",
+ "phase": "semantic",
+ "path": "semantic/002-probe-names-unknown-endpoint"
}
]
}
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
new file mode 100644
index 0000000..0a087da
--- /dev/null
+++ b/conformance/component/v1/semantic/001-floating-tag-image-reference/case.yaml
@@ -0,0 +1,13 @@
+# The tag satisfies the structural grammar — it is a tag, not a bare name —
+# but it floats. The blocklist is held in the semantic phase rather than in a
+# pattern so it can be extended without a major version.
+specVersion: v1
+kind: COMPONENT
+metadata:
+ version: 1
+spec:
+ workload:
+ kind: SERVICE
+ source:
+ type: IMAGE
+ ref: ghcr.io/musher-dev/api:nightly
diff --git a/conformance/component/v1/semantic/001-floating-tag-image-reference/diagnostics.json b/conformance/component/v1/semantic/001-floating-tag-image-reference/diagnostics.json
new file mode 100644
index 0000000..e078224
--- /dev/null
+++ b/conformance/component/v1/semantic/001-floating-tag-image-reference/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_UNPINNED_IMAGE",
+ "path": "/spec/workload/source/ref"
+ }
+]
diff --git a/conformance/component/v1/semantic/001-floating-tag-image-reference/metadata.json b/conformance/component/v1/semantic/001-floating-tag-image-reference/metadata.json
new file mode 100644
index 0000000..db8b571
--- /dev/null
+++ b/conformance/component/v1/semantic/001-floating-tag-image-reference/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "semantic-001-floating-tag-image-reference",
+ "phase": "semantic",
+ "expected": "fail",
+ "clause": "specifications/component/v1/spec.md#source",
+ "summary": "An image reference whose tag is in the floating set is rejected."
+}
diff --git a/conformance/component/v1/semantic/002-probe-names-unknown-endpoint/case.yaml b/conformance/component/v1/semantic/002-probe-names-unknown-endpoint/case.yaml
new file mode 100644
index 0000000..7d92983
--- /dev/null
+++ b/conformance/component/v1/semantic/002-probe-names-unknown-endpoint/case.yaml
@@ -0,0 +1,22 @@
+# The probe targets "api", but the only endpoint is "web". JSON Schema cannot
+# catch this: the endpoint names are mapping keys elsewhere in the document,
+# and no keyword constrains a value against a sibling's keys.
+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
+ health:
+ readiness:
+ path: /healthz
+ endpoint: api
diff --git a/conformance/component/v1/semantic/002-probe-names-unknown-endpoint/diagnostics.json b/conformance/component/v1/semantic/002-probe-names-unknown-endpoint/diagnostics.json
new file mode 100644
index 0000000..54b2be6
--- /dev/null
+++ b/conformance/component/v1/semantic/002-probe-names-unknown-endpoint/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_UNKNOWN_ENDPOINT",
+ "path": "/spec/workload/health/readiness/endpoint"
+ }
+]
diff --git a/conformance/component/v1/semantic/002-probe-names-unknown-endpoint/metadata.json b/conformance/component/v1/semantic/002-probe-names-unknown-endpoint/metadata.json
new file mode 100644
index 0000000..72eb9f5
--- /dev/null
+++ b/conformance/component/v1/semantic/002-probe-names-unknown-endpoint/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "semantic-002-probe-names-unknown-endpoint",
+ "phase": "semantic",
+ "expected": "fail",
+ "clause": "specifications/component/v1/spec.md#health",
+ "summary": "A probe naming an endpoint the workload does not declare is rejected."
+}
diff --git a/conformance/component/v1/structural/006-non-service-with-endpoints/case.yaml b/conformance/component/v1/structural/006-non-service-with-endpoints/case.yaml
new file mode 100644
index 0000000..b9a4637
--- /dev/null
+++ b/conformance/component/v1/structural/006-non-service-with-endpoints/case.yaml
@@ -0,0 +1,20 @@
+# Only a SERVICE may expose an endpoint. A WORKER that declares one has
+# either the wrong kind or a misplaced endpoint, and both are worth saying out
+# loud — accepting it silently leaves an author expecting traffic that will
+# never arrive.
+specVersion: v1
+kind: COMPONENT
+metadata:
+ version: 1
+spec:
+ workload:
+ kind: WORKER
+ command: /bin/consume
+ source:
+ type: IMAGE
+ ref: ghcr.io/musher-dev/worker:1.4.0
+ endpoints:
+ web:
+ containerPort: 8080
+ protocol: HTTP
+ visibility: PRIVATE
diff --git a/conformance/component/v1/structural/006-non-service-with-endpoints/diagnostics.json b/conformance/component/v1/structural/006-non-service-with-endpoints/diagnostics.json
new file mode 100644
index 0000000..0682289
--- /dev/null
+++ b/conformance/component/v1/structural/006-non-service-with-endpoints/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_INVALID_VALUE",
+ "path": "/spec/workload/endpoints"
+ }
+]
diff --git a/conformance/component/v1/structural/006-non-service-with-endpoints/metadata.json b/conformance/component/v1/structural/006-non-service-with-endpoints/metadata.json
new file mode 100644
index 0000000..e529252
--- /dev/null
+++ b/conformance/component/v1/structural/006-non-service-with-endpoints/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-006-non-service-with-endpoints",
+ "phase": "structural",
+ "expected": "fail",
+ "clause": "specifications/component/v1/spec.md#workload",
+ "summary": "A WORKER declaring an endpoint is rejected rather than having it ignored."
+}
diff --git a/conformance/component/v1/structural/007-non-service-with-empty-endpoints/case.yaml b/conformance/component/v1/structural/007-non-service-with-empty-endpoints/case.yaml
new file mode 100644
index 0000000..1724f1b
--- /dev/null
+++ b/conformance/component/v1/structural/007-non-service-with-empty-endpoints/case.yaml
@@ -0,0 +1,15 @@
+# "Forbidden" bounds what the field may contain, not whether the key may be
+# written. An empty mapping declares nothing, so it is accepted — the same
+# spelling the blueprint examples use for an empty connections block.
+specVersion: v1
+kind: COMPONENT
+metadata:
+ version: 1
+spec:
+ workload:
+ kind: WORKER
+ command: /bin/consume
+ endpoints: {}
+ source:
+ type: IMAGE
+ ref: ghcr.io/musher-dev/worker:1.4.0
diff --git a/conformance/component/v1/structural/007-non-service-with-empty-endpoints/metadata.json b/conformance/component/v1/structural/007-non-service-with-empty-endpoints/metadata.json
new file mode 100644
index 0000000..0e2bc8f
--- /dev/null
+++ b/conformance/component/v1/structural/007-non-service-with-empty-endpoints/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-007-non-service-with-empty-endpoints",
+ "phase": "structural",
+ "expected": "pass",
+ "clause": "specifications/component/v1/spec.md#workload",
+ "summary": "A forbidden mapping may still be written empty; only a declared entry is an error."
+}
diff --git a/conformance/component/v1/structural/008-job-without-command/case.yaml b/conformance/component/v1/structural/008-job-without-command/case.yaml
new file mode 100644
index 0000000..b51b9ec
--- /dev/null
+++ b/conformance/component/v1/structural/008-job-without-command/case.yaml
@@ -0,0 +1,13 @@
+# A JOB is a one-shot command. Without one there is no workload to run, and
+# the image's default command is not a substitute — a job that silently ran a
+# service's entrypoint would never terminate.
+specVersion: v1
+kind: COMPONENT
+metadata:
+ version: 1
+spec:
+ workload:
+ kind: JOB
+ source:
+ type: IMAGE
+ ref: ghcr.io/musher-dev/migrate:2.0.1
diff --git a/conformance/component/v1/structural/008-job-without-command/diagnostics.json b/conformance/component/v1/structural/008-job-without-command/diagnostics.json
new file mode 100644
index 0000000..36f0b65
--- /dev/null
+++ b/conformance/component/v1/structural/008-job-without-command/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_MISSING_FIELD",
+ "path": "/spec/workload"
+ }
+]
diff --git a/conformance/component/v1/structural/008-job-without-command/metadata.json b/conformance/component/v1/structural/008-job-without-command/metadata.json
new file mode 100644
index 0000000..b665f13
--- /dev/null
+++ b/conformance/component/v1/structural/008-job-without-command/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-008-job-without-command",
+ "phase": "structural",
+ "expected": "fail",
+ "clause": "specifications/component/v1/spec.md#workload",
+ "summary": "A JOB omitting command is rejected; there is nothing for it to run."
+}
diff --git a/conformance/component/v1/structural/009-cron-workload/case.yaml b/conformance/component/v1/structural/009-cron-workload/case.yaml
new file mode 100644
index 0000000..8000ef1
--- /dev/null
+++ b/conformance/component/v1/structural/009-cron-workload/case.yaml
@@ -0,0 +1,13 @@
+specVersion: v1
+kind: COMPONENT
+metadata:
+ version: 1
+spec:
+ workload:
+ kind: CRON
+ command: /bin/backup
+ schedule:
+ cron: "0 3 * * *"
+ source:
+ type: IMAGE
+ ref: ghcr.io/musher-dev/backup:1.0.2
diff --git a/conformance/component/v1/structural/009-cron-workload/metadata.json b/conformance/component/v1/structural/009-cron-workload/metadata.json
new file mode 100644
index 0000000..90621fe
--- /dev/null
+++ b/conformance/component/v1/structural/009-cron-workload/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-009-cron-workload",
+ "phase": "structural",
+ "expected": "pass",
+ "clause": "specifications/component/v1/spec.md#workload",
+ "summary": "A CRON carrying both command and schedule validates."
+}
diff --git a/conformance/component/v1/structural/010-cron-without-schedule/case.yaml b/conformance/component/v1/structural/010-cron-without-schedule/case.yaml
new file mode 100644
index 0000000..6816419
--- /dev/null
+++ b/conformance/component/v1/structural/010-cron-without-schedule/case.yaml
@@ -0,0 +1,13 @@
+# A CRON with no schedule names no time to run at. Defaulting one would be
+# the platform choosing when someone else's job fires.
+specVersion: v1
+kind: COMPONENT
+metadata:
+ version: 1
+spec:
+ workload:
+ kind: CRON
+ command: /bin/backup
+ source:
+ type: IMAGE
+ ref: ghcr.io/musher-dev/backup:1.0.2
diff --git a/conformance/component/v1/structural/010-cron-without-schedule/diagnostics.json b/conformance/component/v1/structural/010-cron-without-schedule/diagnostics.json
new file mode 100644
index 0000000..36f0b65
--- /dev/null
+++ b/conformance/component/v1/structural/010-cron-without-schedule/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_MISSING_FIELD",
+ "path": "/spec/workload"
+ }
+]
diff --git a/conformance/component/v1/structural/010-cron-without-schedule/metadata.json b/conformance/component/v1/structural/010-cron-without-schedule/metadata.json
new file mode 100644
index 0000000..ae09788
--- /dev/null
+++ b/conformance/component/v1/structural/010-cron-without-schedule/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-010-cron-without-schedule",
+ "phase": "structural",
+ "expected": "fail",
+ "clause": "specifications/component/v1/spec.md#workload",
+ "summary": "A CRON omitting schedule is rejected; nothing says when it runs."
+}
diff --git a/conformance/component/v1/structural/011-schedule-on-service/case.yaml b/conformance/component/v1/structural/011-schedule-on-service/case.yaml
new file mode 100644
index 0000000..e2f269c
--- /dev/null
+++ b/conformance/component/v1/structural/011-schedule-on-service/case.yaml
@@ -0,0 +1,15 @@
+# A SERVICE is not scheduled, so this schedule means nothing. Ignoring it
+# would leave the author believing their service runs at 03:00, which is
+# exactly the failure mode §2 refuses for any other misplaced field.
+specVersion: v1
+kind: COMPONENT
+metadata:
+ version: 1
+spec:
+ workload:
+ kind: SERVICE
+ source:
+ type: IMAGE
+ ref: nginx:1.29.4-alpine
+ schedule:
+ cron: "0 3 * * *"
diff --git a/conformance/component/v1/structural/011-schedule-on-service/diagnostics.json b/conformance/component/v1/structural/011-schedule-on-service/diagnostics.json
new file mode 100644
index 0000000..1175d08
--- /dev/null
+++ b/conformance/component/v1/structural/011-schedule-on-service/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_INVALID_TYPE",
+ "path": "/spec/workload/schedule"
+ }
+]
diff --git a/conformance/component/v1/structural/011-schedule-on-service/metadata.json b/conformance/component/v1/structural/011-schedule-on-service/metadata.json
new file mode 100644
index 0000000..57fbae4
--- /dev/null
+++ b/conformance/component/v1/structural/011-schedule-on-service/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-011-schedule-on-service",
+ "phase": "structural",
+ "expected": "fail",
+ "clause": "specifications/component/v1/spec.md#workload",
+ "summary": "A schedule on a SERVICE is rejected rather than ignored."
+}
diff --git a/conformance/component/v1/structural/012-unpinned-image-reference/case.yaml b/conformance/component/v1/structural/012-unpinned-image-reference/case.yaml
new file mode 100644
index 0000000..3361f39
--- /dev/null
+++ b/conformance/component/v1/structural/012-unpinned-image-reference/case.yaml
@@ -0,0 +1,12 @@
+# A bare name is an implicit :latest. The deployed image would then change
+# whenever the registry moved the tag, with no change to any document here.
+specVersion: v1
+kind: COMPONENT
+metadata:
+ version: 1
+spec:
+ workload:
+ kind: SERVICE
+ source:
+ type: IMAGE
+ ref: nginx
diff --git a/conformance/component/v1/structural/012-unpinned-image-reference/diagnostics.json b/conformance/component/v1/structural/012-unpinned-image-reference/diagnostics.json
new file mode 100644
index 0000000..8469060
--- /dev/null
+++ b/conformance/component/v1/structural/012-unpinned-image-reference/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_INVALID_VALUE",
+ "path": "/spec/workload/source/ref"
+ }
+]
diff --git a/conformance/component/v1/structural/012-unpinned-image-reference/metadata.json b/conformance/component/v1/structural/012-unpinned-image-reference/metadata.json
new file mode 100644
index 0000000..b04a66e
--- /dev/null
+++ b/conformance/component/v1/structural/012-unpinned-image-reference/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-012-unpinned-image-reference",
+ "phase": "structural",
+ "expected": "fail",
+ "clause": "specifications/component/v1/spec.md#source",
+ "summary": "An image reference carrying neither tag nor digest is rejected."
+}
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
new file mode 100644
index 0000000..54cc745
--- /dev/null
+++ b/conformance/component/v1/structural/013-digest-pinned-image-reference/case.yaml
@@ -0,0 +1,13 @@
+# Both accepted spellings of a pin, and the case the grammar has to get
+# right: the colon in localhost:5000 is a registry port, not a tag, so the tag
+# is the colon after the final slash.
+specVersion: v1
+kind: COMPONENT
+metadata:
+ version: 1
+spec:
+ workload:
+ kind: SERVICE
+ source:
+ type: IMAGE
+ ref: localhost:5000/musher-dev/api@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
diff --git a/conformance/component/v1/structural/013-digest-pinned-image-reference/metadata.json b/conformance/component/v1/structural/013-digest-pinned-image-reference/metadata.json
new file mode 100644
index 0000000..627185d
--- /dev/null
+++ b/conformance/component/v1/structural/013-digest-pinned-image-reference/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-013-digest-pinned-image-reference",
+ "phase": "structural",
+ "expected": "pass",
+ "clause": "specifications/component/v1/spec.md#source",
+ "summary": "A digest pin validates, including behind a registry host carrying a port."
+}
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
new file mode 100644
index 0000000..ec9b6ed
--- /dev/null
+++ b/conformance/component/v1/structural/014-git-source-without-build/case.yaml
@@ -0,0 +1,13 @@
+# A git repository is not an image. Leaving the build strategy implicit makes
+# the resulting image a property of whichever builder happened to run, not of
+# this document.
+specVersion: v1
+kind: COMPONENT
+metadata:
+ version: 1
+spec:
+ workload:
+ kind: SERVICE
+ source:
+ type: GIT
+ repositoryUrl: https://github.com/musher-dev/examples
diff --git a/conformance/component/v1/structural/014-git-source-without-build/diagnostics.json b/conformance/component/v1/structural/014-git-source-without-build/diagnostics.json
new file mode 100644
index 0000000..38e8fef
--- /dev/null
+++ b/conformance/component/v1/structural/014-git-source-without-build/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_MISSING_FIELD",
+ "path": "/spec/workload/source"
+ }
+]
diff --git a/conformance/component/v1/structural/014-git-source-without-build/metadata.json b/conformance/component/v1/structural/014-git-source-without-build/metadata.json
new file mode 100644
index 0000000..c3fd4a5
--- /dev/null
+++ b/conformance/component/v1/structural/014-git-source-without-build/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-014-git-source-without-build",
+ "phase": "structural",
+ "expected": "fail",
+ "clause": "specifications/component/v1/spec.md#source",
+ "summary": "A GIT source omitting build is rejected; nothing says how the image is made."
+}
diff --git a/conformance/component/v1/structural/015-public-service-without-readiness/case.yaml b/conformance/component/v1/structural/015-public-service-without-readiness/case.yaml
new file mode 100644
index 0000000..51c6485
--- /dev/null
+++ b/conformance/component/v1/structural/015-public-service-without-readiness/case.yaml
@@ -0,0 +1,18 @@
+# Without a readiness gate the public URL routes to a replica that is running
+# but not yet serving, so the first request a user makes is the one that
+# fails. A browser does not retry.
+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
diff --git a/conformance/component/v1/structural/015-public-service-without-readiness/diagnostics.json b/conformance/component/v1/structural/015-public-service-without-readiness/diagnostics.json
new file mode 100644
index 0000000..36f0b65
--- /dev/null
+++ b/conformance/component/v1/structural/015-public-service-without-readiness/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_MISSING_FIELD",
+ "path": "/spec/workload"
+ }
+]
diff --git a/conformance/component/v1/structural/015-public-service-without-readiness/metadata.json b/conformance/component/v1/structural/015-public-service-without-readiness/metadata.json
new file mode 100644
index 0000000..cf47fb3
--- /dev/null
+++ b/conformance/component/v1/structural/015-public-service-without-readiness/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-015-public-service-without-readiness",
+ "phase": "structural",
+ "expected": "fail",
+ "clause": "specifications/component/v1/spec.md#health",
+ "summary": "A SERVICE exposing a PUBLIC endpoint must declare a readiness probe."
+}
diff --git a/conformance/component/v1/structural/016-private-service-without-readiness/case.yaml b/conformance/component/v1/structural/016-private-service-without-readiness/case.yaml
new file mode 100644
index 0000000..97d47a3
--- /dev/null
+++ b/conformance/component/v1/structural/016-private-service-without-readiness/case.yaml
@@ -0,0 +1,18 @@
+# The readiness rule is deliberately narrow. A private consumer inside the
+# mesh retries; nothing user-facing depends on this replica being ready the
+# first time, so the probe stays optional.
+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
diff --git a/conformance/component/v1/structural/016-private-service-without-readiness/metadata.json b/conformance/component/v1/structural/016-private-service-without-readiness/metadata.json
new file mode 100644
index 0000000..271c87b
--- /dev/null
+++ b/conformance/component/v1/structural/016-private-service-without-readiness/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-016-private-service-without-readiness",
+ "phase": "structural",
+ "expected": "pass",
+ "clause": "specifications/component/v1/spec.md#health",
+ "summary": "A SERVICE whose endpoints are all PRIVATE is exempt from the readiness rule."
+}
diff --git a/conformance/component/v1/structural/017-relative-mount-path/case.yaml b/conformance/component/v1/structural/017-relative-mount-path/case.yaml
new file mode 100644
index 0000000..f8b8062
--- /dev/null
+++ b/conformance/component/v1/structural/017-relative-mount-path/case.yaml
@@ -0,0 +1,16 @@
+# A mount path is resolved by the container runtime, not relative to anything
+# in this document. A relative path has no meaning to resolve against.
+specVersion: v1
+kind: COMPONENT
+metadata:
+ version: 1
+spec:
+ workload:
+ kind: SERVICE
+ source:
+ type: IMAGE
+ ref: postgres:17.10-alpine
+ volumes:
+ data:
+ sizeGib: 10
+ mountPath: var/lib/postgresql/data
diff --git a/conformance/component/v1/structural/017-relative-mount-path/diagnostics.json b/conformance/component/v1/structural/017-relative-mount-path/diagnostics.json
new file mode 100644
index 0000000..83e7639
--- /dev/null
+++ b/conformance/component/v1/structural/017-relative-mount-path/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_INVALID_VALUE",
+ "path": "/spec/workload/volumes/data/mountPath"
+ }
+]
diff --git a/conformance/component/v1/structural/017-relative-mount-path/metadata.json b/conformance/component/v1/structural/017-relative-mount-path/metadata.json
new file mode 100644
index 0000000..f05ddfb
--- /dev/null
+++ b/conformance/component/v1/structural/017-relative-mount-path/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-017-relative-mount-path",
+ "phase": "structural",
+ "expected": "fail",
+ "clause": "specifications/component/v1/spec.md#volumes",
+ "summary": "A relative volume mountPath is rejected."
+}
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
new file mode 100644
index 0000000..1885300
--- /dev/null
+++ b/conformance/component/v1/structural/018-generated-input-not-sensitive/case.yaml
@@ -0,0 +1,23 @@
+# A platform-minted value is secret material. isSensitive defaults to false,
+# so leaving it unwritten is not a neutral omission — it is the wrong answer,
+# and the generated value would be echoed back into logs and interfaces.
+specVersion: v1
+kind: COMPONENT
+metadata:
+ version: 1
+spec:
+ workload:
+ kind: SERVICE
+ source:
+ type: IMAGE
+ ref: nginx:1.29.4-alpine
+ contract:
+ inputs:
+ apiKey:
+ schema:
+ type: STRING
+ suppliedBy: USER
+ generator:
+ encoding: HEX
+ ui:
+ label: API key
diff --git a/conformance/component/v1/structural/018-generated-input-not-sensitive/diagnostics.json b/conformance/component/v1/structural/018-generated-input-not-sensitive/diagnostics.json
new file mode 100644
index 0000000..71849a1
--- /dev/null
+++ b/conformance/component/v1/structural/018-generated-input-not-sensitive/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_MISSING_FIELD",
+ "path": "/spec/contract/inputs/apiKey/schema"
+ }
+]
diff --git a/conformance/component/v1/structural/018-generated-input-not-sensitive/metadata.json b/conformance/component/v1/structural/018-generated-input-not-sensitive/metadata.json
new file mode 100644
index 0000000..817b10c
--- /dev/null
+++ b/conformance/component/v1/structural/018-generated-input-not-sensitive/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-018-generated-input-not-sensitive",
+ "phase": "structural",
+ "expected": "fail",
+ "clause": "specifications/component/v1/spec.md#inputs",
+ "summary": "An input carrying a generator must declare schema.isSensitive true."
+}
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
new file mode 100644
index 0000000..5781a0c
--- /dev/null
+++ b/conformance/component/v1/structural/019-connection-input-with-ui/case.yaml
@@ -0,0 +1,20 @@
+# A CONNECTION input is wired from an upstream output and never reaches the
+# install form. A label on one describes a field that will never render.
+specVersion: v1
+kind: COMPONENT
+metadata:
+ version: 1
+spec:
+ workload:
+ kind: SERVICE
+ source:
+ type: IMAGE
+ ref: nginx:1.29.4-alpine
+ contract:
+ inputs:
+ databaseUrl:
+ schema:
+ type: STRING
+ suppliedBy: CONNECTION
+ ui:
+ label: Database URL
diff --git a/conformance/component/v1/structural/019-connection-input-with-ui/diagnostics.json b/conformance/component/v1/structural/019-connection-input-with-ui/diagnostics.json
new file mode 100644
index 0000000..0d9cbe0
--- /dev/null
+++ b/conformance/component/v1/structural/019-connection-input-with-ui/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_INVALID_TYPE",
+ "path": "/spec/contract/inputs/databaseUrl/ui"
+ }
+]
diff --git a/conformance/component/v1/structural/019-connection-input-with-ui/metadata.json b/conformance/component/v1/structural/019-connection-input-with-ui/metadata.json
new file mode 100644
index 0000000..96a27b0
--- /dev/null
+++ b/conformance/component/v1/structural/019-connection-input-with-ui/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-019-connection-input-with-ui",
+ "phase": "structural",
+ "expected": "fail",
+ "clause": "specifications/component/v1/spec.md#inputs",
+ "summary": "Presentation metadata on a CONNECTION input is rejected."
+}
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
new file mode 100644
index 0000000..1c3bb57
--- /dev/null
+++ b/conformance/component/v1/structural/020-user-input-without-ui/case.yaml
@@ -0,0 +1,17 @@
+# suppliedBy defaults to USER, so omitting it does not opt out of the rule.
+# A form cannot render a field it has no label for.
+specVersion: v1
+kind: COMPONENT
+metadata:
+ version: 1
+spec:
+ workload:
+ kind: SERVICE
+ source:
+ type: IMAGE
+ ref: nginx:1.29.4-alpine
+ contract:
+ inputs:
+ siteTitle:
+ schema:
+ type: STRING
diff --git a/conformance/component/v1/structural/020-user-input-without-ui/diagnostics.json b/conformance/component/v1/structural/020-user-input-without-ui/diagnostics.json
new file mode 100644
index 0000000..020cec2
--- /dev/null
+++ b/conformance/component/v1/structural/020-user-input-without-ui/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_MISSING_FIELD",
+ "path": "/spec/contract/inputs/siteTitle"
+ }
+]
diff --git a/conformance/component/v1/structural/020-user-input-without-ui/metadata.json b/conformance/component/v1/structural/020-user-input-without-ui/metadata.json
new file mode 100644
index 0000000..dc64ad5
--- /dev/null
+++ b/conformance/component/v1/structural/020-user-input-without-ui/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-020-user-input-without-ui",
+ "phase": "structural",
+ "expected": "fail",
+ "clause": "specifications/component/v1/spec.md#inputs",
+ "summary": "A USER input omitting ui is rejected, including when suppliedBy is defaulted."
+}
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
new file mode 100644
index 0000000..a40599b
--- /dev/null
+++ b/conformance/component/v1/structural/021-derived-output-with-value/case.yaml
@@ -0,0 +1,19 @@
+# A DERIVED output is resolved from the running workload. A static value here
+# contradicts that, and nothing says which of the two a consumer would read.
+specVersion: v1
+kind: COMPONENT
+metadata:
+ version: 1
+spec:
+ workload:
+ kind: SERVICE
+ source:
+ type: IMAGE
+ ref: postgres:17.10-alpine
+ contract:
+ outputs:
+ connectionString:
+ schema:
+ type: STRING
+ valueFrom: DERIVED
+ value: postgres://localhost:5432/app
diff --git a/conformance/component/v1/structural/021-derived-output-with-value/diagnostics.json b/conformance/component/v1/structural/021-derived-output-with-value/diagnostics.json
new file mode 100644
index 0000000..3c14b7a
--- /dev/null
+++ b/conformance/component/v1/structural/021-derived-output-with-value/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_INVALID_TYPE",
+ "path": "/spec/contract/outputs/connectionString/value"
+ }
+]
diff --git a/conformance/component/v1/structural/021-derived-output-with-value/metadata.json b/conformance/component/v1/structural/021-derived-output-with-value/metadata.json
new file mode 100644
index 0000000..6655110
--- /dev/null
+++ b/conformance/component/v1/structural/021-derived-output-with-value/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-021-derived-output-with-value",
+ "phase": "structural",
+ "expected": "fail",
+ "clause": "specifications/component/v1/spec.md#outputs",
+ "summary": "A DERIVED output carrying a static value is rejected."
+}
diff --git a/conformance/component/v1/structural/022-contract-both-output-forms/case.yaml b/conformance/component/v1/structural/022-contract-both-output-forms/case.yaml
new file mode 100644
index 0000000..04a6f35
--- /dev/null
+++ b/conformance/component/v1/structural/022-contract-both-output-forms/case.yaml
@@ -0,0 +1,41 @@
+# The whole contract surface in one document: a generated USER input marked
+# sensitive, an output the platform resolves, and an output this document
+# states outright.
+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:
+ postgresPassword:
+ schema:
+ type: STRING
+ isSensitive: true
+ suppliedBy: USER
+ generator:
+ encoding: BASE64URL
+ byteLength: 32
+ ui:
+ label: Database password
+ outputs:
+ connectionString:
+ schema:
+ type: STRING
+ format: CONNECTION_STRING
+ valueFrom: DERIVED
+ databaseName:
+ schema:
+ type: STRING
+ valueFrom: DECLARED
+ value: app
diff --git a/conformance/component/v1/structural/022-contract-both-output-forms/metadata.json b/conformance/component/v1/structural/022-contract-both-output-forms/metadata.json
new file mode 100644
index 0000000..1c45d1b
--- /dev/null
+++ b/conformance/component/v1/structural/022-contract-both-output-forms/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-022-contract-both-output-forms",
+ "phase": "structural",
+ "expected": "pass",
+ "clause": "specifications/component/v1/spec.md#outputs",
+ "summary": "A generated input, a DERIVED output, and a DECLARED output together validate."
+}
diff --git a/conformance/listing/v1/cases.json b/conformance/listing/v1/cases.json
index 000c8db..b61df42 100644
--- a/conformance/listing/v1/cases.json
+++ b/conformance/listing/v1/cases.json
@@ -36,6 +36,31 @@
"id": "structural-006-featured-not-in-contract",
"phase": "structural",
"path": "structural/006-featured-not-in-contract"
+ },
+ {
+ "id": "structural-007-icon-escaping-the-item",
+ "phase": "structural",
+ "path": "structural/007-icon-escaping-the-item"
+ },
+ {
+ "id": "structural-008-media-outside-the-media-directory",
+ "phase": "structural",
+ "path": "structural/008-media-outside-the-media-directory"
+ },
+ {
+ "id": "structural-009-unsupported-media-format",
+ "phase": "structural",
+ "path": "structural/009-unsupported-media-format"
+ },
+ {
+ "id": "structural-010-media-paths",
+ "phase": "structural",
+ "path": "structural/010-media-paths"
+ },
+ {
+ "id": "semantic-001-duplicate-screenshot-basename",
+ "phase": "semantic",
+ "path": "semantic/001-duplicate-screenshot-basename"
}
]
}
diff --git a/conformance/listing/v1/semantic/001-duplicate-screenshot-basename/case.yaml b/conformance/listing/v1/semantic/001-duplicate-screenshot-basename/case.yaml
new file mode 100644
index 0000000..e60cd6c
--- /dev/null
+++ b/conformance/listing/v1/semantic/001-duplicate-screenshot-basename/case.yaml
@@ -0,0 +1,17 @@
+# Two distinct files, two distinct paths, one basename. Published assets are
+# addressed by basename, so these are one file — and without the rule the
+# second screenshot silently replaces the first.
+specVersion: v1
+kind: LISTING
+metadata:
+ slug: postgres
+ version: 1
+spec:
+ listingKind: COMPONENT
+ displayName: PostgreSQL
+ summary: The open-source relational database
+ category: INFRASTRUCTURE
+ lifecycleStage: STABLE
+ screenshots:
+ - file: media/desktop/overview.png
+ - file: media/mobile/overview.png
diff --git a/conformance/listing/v1/semantic/001-duplicate-screenshot-basename/diagnostics.json b/conformance/listing/v1/semantic/001-duplicate-screenshot-basename/diagnostics.json
new file mode 100644
index 0000000..d850818
--- /dev/null
+++ b/conformance/listing/v1/semantic/001-duplicate-screenshot-basename/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_DUPLICATE_MEDIA_BASENAME",
+ "path": "/spec/screenshots/1/file"
+ }
+]
diff --git a/conformance/listing/v1/semantic/001-duplicate-screenshot-basename/metadata.json b/conformance/listing/v1/semantic/001-duplicate-screenshot-basename/metadata.json
new file mode 100644
index 0000000..e4b40fc
--- /dev/null
+++ b/conformance/listing/v1/semantic/001-duplicate-screenshot-basename/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "semantic-001-duplicate-screenshot-basename",
+ "phase": "semantic",
+ "expected": "fail",
+ "clause": "specifications/listing/v1/spec.md#media",
+ "summary": "Two screenshots sharing a basename under different directories are rejected."
+}
diff --git a/conformance/listing/v1/structural/007-icon-escaping-the-item/case.yaml b/conformance/listing/v1/structural/007-icon-escaping-the-item/case.yaml
new file mode 100644
index 0000000..720376c
--- /dev/null
+++ b/conformance/listing/v1/structural/007-icon-escaping-the-item/case.yaml
@@ -0,0 +1,15 @@
+# Traversal is now unspellable rather than merely resolved-and-rejected. A
+# segment must begin with a letter or digit, so `..` cannot appear at all, and
+# the escape is caught by the grammar instead of by the filesystem.
+specVersion: v1
+kind: LISTING
+metadata:
+ slug: postgres
+ version: 1
+spec:
+ listingKind: COMPONENT
+ displayName: PostgreSQL
+ summary: The open-source relational database
+ category: INFRASTRUCTURE
+ lifecycleStage: STABLE
+ icon: ../../etc/passwd.png
diff --git a/conformance/listing/v1/structural/007-icon-escaping-the-item/diagnostics.json b/conformance/listing/v1/structural/007-icon-escaping-the-item/diagnostics.json
new file mode 100644
index 0000000..4c554d2
--- /dev/null
+++ b/conformance/listing/v1/structural/007-icon-escaping-the-item/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_INVALID_VALUE",
+ "path": "/spec/icon"
+ }
+]
diff --git a/conformance/listing/v1/structural/007-icon-escaping-the-item/metadata.json b/conformance/listing/v1/structural/007-icon-escaping-the-item/metadata.json
new file mode 100644
index 0000000..23ce0e3
--- /dev/null
+++ b/conformance/listing/v1/structural/007-icon-escaping-the-item/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-007-icon-escaping-the-item",
+ "phase": "structural",
+ "expected": "fail",
+ "clause": "specifications/listing/v1/spec.md#media",
+ "summary": "A media path spelling a traversal is rejected before containment is considered."
+}
diff --git a/conformance/listing/v1/structural/008-media-outside-the-media-directory/case.yaml b/conformance/listing/v1/structural/008-media-outside-the-media-directory/case.yaml
new file mode 100644
index 0000000..77fa96a
--- /dev/null
+++ b/conformance/listing/v1/structural/008-media-outside-the-media-directory/case.yaml
@@ -0,0 +1,16 @@
+# `assets/` is a perfectly ordinary directory name and an entirely legal
+# relative path. It is rejected because one fixed directory is what lets a
+# publisher copy an item's media without reading its listing first.
+specVersion: v1
+kind: LISTING
+metadata:
+ slug: postgres
+ version: 1
+spec:
+ listingKind: COMPONENT
+ displayName: PostgreSQL
+ summary: The open-source relational database
+ category: INFRASTRUCTURE
+ lifecycleStage: STABLE
+ screenshots:
+ - file: assets/overview.png
diff --git a/conformance/listing/v1/structural/008-media-outside-the-media-directory/diagnostics.json b/conformance/listing/v1/structural/008-media-outside-the-media-directory/diagnostics.json
new file mode 100644
index 0000000..8b8c8cc
--- /dev/null
+++ b/conformance/listing/v1/structural/008-media-outside-the-media-directory/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_INVALID_VALUE",
+ "path": "/spec/screenshots/0/file"
+ }
+]
diff --git a/conformance/listing/v1/structural/008-media-outside-the-media-directory/metadata.json b/conformance/listing/v1/structural/008-media-outside-the-media-directory/metadata.json
new file mode 100644
index 0000000..fff308f
--- /dev/null
+++ b/conformance/listing/v1/structural/008-media-outside-the-media-directory/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-008-media-outside-the-media-directory",
+ "phase": "structural",
+ "expected": "fail",
+ "clause": "specifications/listing/v1/spec.md#media",
+ "summary": "A media path whose first segment is not media/ is rejected."
+}
diff --git a/conformance/listing/v1/structural/009-unsupported-media-format/case.yaml b/conformance/listing/v1/structural/009-unsupported-media-format/case.yaml
new file mode 100644
index 0000000..a281d33
--- /dev/null
+++ b/conformance/listing/v1/structural/009-unsupported-media-format/case.yaml
@@ -0,0 +1,15 @@
+# The permitted set is png, jpg, jpeg and webp. A gif is not in it, and the
+# storefront would have no way to render a format it was never told about.
+specVersion: v1
+kind: LISTING
+metadata:
+ slug: postgres
+ version: 1
+spec:
+ listingKind: COMPONENT
+ displayName: PostgreSQL
+ summary: The open-source relational database
+ category: INFRASTRUCTURE
+ lifecycleStage: STABLE
+ screenshots:
+ - file: media/overview.gif
diff --git a/conformance/listing/v1/structural/009-unsupported-media-format/diagnostics.json b/conformance/listing/v1/structural/009-unsupported-media-format/diagnostics.json
new file mode 100644
index 0000000..8b8c8cc
--- /dev/null
+++ b/conformance/listing/v1/structural/009-unsupported-media-format/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_INVALID_VALUE",
+ "path": "/spec/screenshots/0/file"
+ }
+]
diff --git a/conformance/listing/v1/structural/009-unsupported-media-format/metadata.json b/conformance/listing/v1/structural/009-unsupported-media-format/metadata.json
new file mode 100644
index 0000000..18aa564
--- /dev/null
+++ b/conformance/listing/v1/structural/009-unsupported-media-format/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-009-unsupported-media-format",
+ "phase": "structural",
+ "expected": "fail",
+ "clause": "specifications/listing/v1/spec.md#media",
+ "summary": "A media path outside the permitted image formats is rejected."
+}
diff --git a/conformance/listing/v1/structural/010-media-paths/case.yaml b/conformance/listing/v1/structural/010-media-paths/case.yaml
new file mode 100644
index 0000000..cb91725
--- /dev/null
+++ b/conformance/listing/v1/structural/010-media-paths/case.yaml
@@ -0,0 +1,19 @@
+# Both things the grammar has to keep accepting: a nested directory under
+# media/, and an extension in upper case. A JSON Schema pattern carries no
+# case-insensitive flag, so the alternation spells the cases out.
+specVersion: v1
+kind: LISTING
+metadata:
+ slug: postgres
+ version: 1
+spec:
+ listingKind: COMPONENT
+ displayName: PostgreSQL
+ summary: The open-source relational database
+ category: INFRASTRUCTURE
+ lifecycleStage: STABLE
+ icon: media/icon.png
+ screenshots:
+ - file: media/shots/desktop.JPEG
+ caption: The connection panel
+ - file: media/shots/mobile.webp
diff --git a/conformance/listing/v1/structural/010-media-paths/metadata.json b/conformance/listing/v1/structural/010-media-paths/metadata.json
new file mode 100644
index 0000000..5c24d50
--- /dev/null
+++ b/conformance/listing/v1/structural/010-media-paths/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-010-media-paths",
+ "phase": "structural",
+ "expected": "pass",
+ "clause": "specifications/listing/v1/spec.md#media",
+ "summary": "Media paths nest below media/ and match their extension in any case."
+}
diff --git a/specifications/blueprint/v1/schemas/dist/blueprint.schema.json b/specifications/blueprint/v1/schemas/dist/blueprint.schema.json
index 8d6d4f2..24cae1a 100644
--- a/specifications/blueprint/v1/schemas/dist/blueprint.schema.json
+++ b/specifications/blueprint/v1/schemas/dist/blueprint.schema.json
@@ -431,10 +431,13 @@
"additionalProperties": false,
"properties": {
"components": {
- "description": "Component references composing the graph, keyed by graph-local node name.",
+ "description": "Component references composing the graph, keyed by graph-local node name. The name is what a connection's fromRole refers to and what the parameter merge orders the graph by; it means nothing outside this document.",
"additionalProperties": {
"$ref": "#/$defs/BlueprintNode"
},
+ "propertyNames": {
+ "pattern": "^[a-z][a-z0-9-]{0,61}[a-z0-9]$"
+ },
"type": "object"
},
"parameters": {
diff --git a/specifications/blueprint/v1/schemas/src/blueprint.schema.json b/specifications/blueprint/v1/schemas/src/blueprint.schema.json
index 6890181..7d62391 100644
--- a/specifications/blueprint/v1/schemas/src/blueprint.schema.json
+++ b/specifications/blueprint/v1/schemas/src/blueprint.schema.json
@@ -434,7 +434,10 @@
"additionalProperties": {
"$ref": "#/$defs/BlueprintNode"
},
- "description": "Component references composing the graph, keyed by graph-local node name.",
+ "description": "Component references composing the graph, keyed by graph-local node name. The name is what a connection's fromRole refers to and what the parameter merge orders the graph by; it means nothing outside this document.",
+ "propertyNames": {
+ "pattern": "^[a-z][a-z0-9-]{0,61}[a-z0-9]$"
+ },
"type": "object"
},
"parameters": {
diff --git a/specifications/blueprint/v1/spec.md b/specifications/blueprint/v1/spec.md
index 448161e..61b82eb 100644
--- a/specifications/blueprint/v1/spec.md
+++ b/specifications/blueprint/v1/spec.md
@@ -55,9 +55,53 @@ declarative apply — describe a row in a control plane, not a document. They
MUST NOT appear on a blueprint document, and a validator MUST reject them with
`ERR_UNKNOWN_FIELD` like any other unknown property.
-> **TODO** — `metadata.slug` MUST equal the containing item directory name;
-> `metadata.version` MUST agree with the sibling listing document. State the
-> diagnostic codes for each violation and which phase detects them.
+Three rules bind the item together. All three are `semantic`, and all three
+are measured against the item root defined below.
+
+| Rule | Diagnostic |
+|---|---|
+| `metadata.slug` MUST equal the item directory name. | `ERR_SLUG_MISMATCH` |
+| `metadata.version` MUST equal the sibling listing's `metadata.version`. | `ERR_VERSION_MISMATCH` |
+| Every component document in the item MUST be referenced by some node. | `ERR_UNREFERENCED_COMPONENT` |
+
+**An unreferenced component document is an error, not dead weight.** A
+component nothing references is not deployed, not checked against any node,
+and not visible to a reader working out what the item contains. Permitted, it
+accumulates: last release's `postgres.yaml` sitting beside the one actually in
+use, with nothing in the directory saying which is live. The diagnostic
+anchors at `/spec/components` — the mapping that should have named the file —
+because a JSON Pointer addresses this document, and the file it is complaining
+about is not in it.
+
+### 3.1 The item directory
+
+A blueprint does not travel alone. It and its sibling listing belong to a
+**catalog item**: one directory holding one deployable thing.
+
+```
+/
+ blueprint.yaml this document
+ listing.yaml the sibling storefront entry
+ components/ the component documents the graph references
+ media/ icon and screenshots
+```
+
+The directory containing `blueprint.yaml` is the **item root**. It is what
+§3's three rules are measured against, what
+[§4.1](#component-reference) means by containment, and what
+[listing §5](../../listing/v1/spec.md#media) resolves a media path inside.
+
+Only two names in that tree are fixed: `blueprint.yaml` and `listing.yaml`.
+Component documents MAY sit anywhere under the root — `components/` is a
+convention, and [§4.1](#component-reference) accepts a flat sibling equally.
+`media/` is fixed too, but by the listing family rather than by this one.
+
+**A document with no directory has no item root.** Every rule in this section
+needs one, so an implementation handed a document rather than a directory MUST
+NOT report any of them. It has not been given the means to check, and a
+diagnostic it cannot substantiate is worse than a silence.
+[§4.1](#component-reference) draws the same line for the component reference,
+for the same reason.
## 4. Component graph
@@ -65,10 +109,21 @@ MUST NOT appear on a blueprint document, and a validator MUST reject them with
reference. The node name is the identifier used by connections; it is local to
this blueprint and carries no meaning outside it.
-> **TODO** — Node name grammar and uniqueness.
+A node name MUST match `^[a-z][a-z0-9-]{0,61}[a-z0-9]$`, the same grammar
+`metadata.slug` uses. Uniqueness needs no rule of its own: `spec.components`
+is a mapping, so a repeated node name is `ERR_DUPLICATE_KEY` in the `parser`
+phase, before the graph is looked at.
+
+The name is graph-local, which is to say it means nothing outside this
+document. Two blueprints MAY each declare a node called `db` and neither is
+the other's. Its one job is to be what [§4.2](#connections) `fromRole` names,
+and what [§5.2](#merge) orders the graph by.
> **TODO** — `size` MUST name a Compute Profile in `family.tier.size` form.
> State whether an unknown profile is a `semantic` or a `capability` failure.
+> The profile vocabulary itself is not defined in this repository and a reader
+> outside the platform cannot resolve a slug like `general.standard.small`;
+> naming where it is published is part of closing this.
### 4.1 Component reference
@@ -157,23 +212,131 @@ connections:
fromOutput: connectionString
```
-> **TODO** — Normative rules: `fromRole` MUST name a node in the same
-> blueprint; `fromOutput` MUST name a declared output of that node's component;
-> the output's type MUST be compatible with the consuming input's schema.
-
-> **TODO** — **Cycle detection.** The connection graph MUST be acyclic. This is
-> the canonical example of a rule JSON Schema cannot express; it belongs to the
-> `semantic` phase with code `ERR_DEPENDENCY_CYCLE`. Specify how the cycle is
-> reported (the participating node names, in a deterministic order).
+The producer end MUST resolve.
+
+- `fromRole` MUST name a node in this blueprint. A connection cannot reach
+ outside the graph it is written in. `ERR_UNKNOWN_ROLE`.
+- `fromOutput` MUST name an output declared by the component that node
+ deploys. `ERR_UNKNOWN_OUTPUT`.
+
+Both are `semantic`. The first needs only this document; the second needs the
+referenced component document, which a repo-local reference makes readable
+without a network.
+
+The consumer end needs no rule. The map key names the input being filled and
+the enclosing node names the consumer, so one input cannot take two wires —
+the mapping already makes that structural.
+
+**The 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`.
+
+> **TODO** — Type compatibility between the output and the input it feeds.
+> `schema.semanticType` is the tag a composition layer matches on, but the
+> matching rule is not stated, and neither is what happens when a `STRING`
+> output feeds an `INTEGER` input. See
+> [component §6.2](../../component/v1/spec.md#outputs).
## 5. Parameters
-An empty `parameters` mapping is not the same as an absent one.
-
-> **TODO** — Specify derivation: when `parameters` is empty, the effective
-> parameter set is derived from the merged `USER`-supplied inputs of the
-> referenced components. Define the merge rule for two components declaring the
-> same input name with different schemas.
+`spec.parameters` is the install form: what a deploying user is asked for once,
+for the whole composition, rather than once per node.
+
+**Absent and empty mean the same thing.** Both say "derive the form from the
+graph". An earlier draft of this section asserted a difference between them;
+nothing in the document distinguishes a missing key from an empty mapping, and
+a distinction no author can express is one that survives in a specification
+and in no implementation.
+
+A non-empty mapping is an authored override, used in place of derivation
+rather than merged with it.
+
+### 5.1 Derivation
+
+When `parameters` is empty, the effective parameter set is derived from the
+`USER`-supplied inputs of the components the graph references, merged by
+[§5.2](#merge).
+
+A `CONNECTION` input is never derived — it is satisfied by a wire, not by a
+person. An input carrying a `generator` **is** derived, even though the user
+never types a value for it: a client rendering the install form still has to
+know it exists, and "three secrets will be generated for you" is a thing worth
+being able to say.
+
+A derived parameter takes the declaring input's `schema`, `ui` and
+`isRequired` unchanged. [Component §6.1](../../component/v1/spec.md#inputs)
+requires a `USER` input to carry `ui`, so every derived parameter arrives with
+the label a form needs; there is no such thing as a derived parameter that
+cannot be rendered.
+
+### 5.2 Merge
+
+Two components MAY declare an input under the same key. The merge is
+**first-wins in lexicographic node-name order**:
+
+1. Sort the entries of `spec.components` by node name.
+2. Walk them in that order, taking each `USER` input key not already taken.
+
+Node name because it is the only total order the document itself supplies. A
+mapping has no sequence, and a rule that depended on file order, on parse
+order, or on an identifier internal to a control plane would not be
+reproducible by someone reading the document.
+
+The comparison is unambiguous across implementations: [§4](#components)
+confines a node name to lowercase ASCII letters, digits and hyphens, so byte
+order and lexicographic order coincide and no collation or locale can change
+the result.
+
+**A conflicting redeclaration is an error.** Where a later node declares a key
+already taken and its declaration differs, the blueprint is rejected in the
+`semantic` phase with `ERR_CONFLICTING_INPUT_SCHEMA`. An identical
+redeclaration is absorbed in silence — two components that agree on what
+`adminPassword` is are not in conflict, and making them say so twice in
+different words would be the only way to trip this.
+
+Two declarations are identical when their `schema` blocks are equal once
+defaults are applied. `ui` and `isRequired` are not compared: they describe how
+a value is asked for, not what it is, and the first node's presentation winning
+is a presentation decision rather than a contract one.
+
+**Why this is not silent first-wins.** Taking the first schema and discarding a
+different second one settles the ambiguity without telling anyone there was
+one. The second component then receives 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 and with nothing pointing back at them.
+
+An author who wants one shared value across two components says so by writing
+`spec.parameters` outright, which is what an authored override is for.
+
+> **TODO** — How an authored parameter binds to the component inputs it
+> satisfies. Derivation makes that correspondence by key; an override is used
+> verbatim, which does not say what happens to a parameter key matching no
+> input, or to a `USER` input no parameter covers.
## 6. Validation layers
@@ -200,8 +363,8 @@ family adds:
| `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. |
-
-> **TODO** — Confirm this list is complete once §4 and §5 are written.
+| `ERR_UNREFERENCED_COMPONENT` | `semantic` | A component document in the item is referenced by no node. |
+| `ERR_CONFLICTING_INPUT_SCHEMA` | `semantic` | Two nodes declare the same input key with different schemas. |
## 8. Conformance
diff --git a/specifications/component/v1/schemas/dist/component.schema.json b/specifications/component/v1/schemas/dist/component.schema.json
index 61d399b..a42462d 100644
--- a/specifications/component/v1/schemas/dist/component.schema.json
+++ b/specifications/component/v1/schemas/dist/component.schema.json
@@ -221,16 +221,8 @@
"additionalProperties": false,
"properties": {
"build": {
- "description": "How the repository is built into an image, or null when defaults apply.",
- "anyOf": [
- {
- "$ref": "#/$defs/ComponentBuild"
- },
- {
- "type": "null"
- }
- ],
- "default": null
+ "description": "How the repository is built into an image. Required — a git source has no image until something builds one, and leaving the strategy implicit makes the resulting image depend on the builder rather than on this document.",
+ "$ref": "#/$defs/ComponentBuild"
},
"ref": {
"description": "Branch or commit to build from, or null when unpinned.",
@@ -256,7 +248,8 @@
},
"required": [
"type",
- "repositoryUrl"
+ "repositoryUrl",
+ "build"
],
"type": "object"
},
@@ -307,7 +300,10 @@
"additionalProperties": false,
"properties": {
"ref": {
- "description": "OCI image reference the workload runs from.",
+ "description": "OCI image reference the workload runs from. Must carry a tag or an @sha256 digest — a bare name is an implicit :latest and shifts under the registry.",
+ "$comment": "Lookahead-free so it compiles under RE2 as well as ECMA-262. The tag colon is the one after the final slash, which is what keeps a registry port (localhost:5000/nginx) from reading as a tag. Only the grammar is here: the floating-tag blocklist stays a semantic rule so it can grow without a major version. See spec.md §5.1.",
+ "maxLength": 512,
+ "pattern": "^(?:[A-Za-z0-9][A-Za-z0-9.-]*(?::[0-9]+)?/)?(?:[a-z0-9][a-z0-9._-]*/)*[a-z0-9][a-z0-9._-]*(?::[A-Za-z0-9_][A-Za-z0-9._-]{0,127}|(?::[A-Za-z0-9_][A-Za-z0-9._-]{0,127})?@sha256:[0-9a-f]{64})$",
"type": "string"
},
"type": {
@@ -324,6 +320,70 @@
},
"ComponentInput": {
"additionalProperties": false,
+ "allOf": [
+ {
+ "$comment": "spec.md §6.1. suppliedBy defaults to USER, and a default is invisible to a validator, so the USER branch is the `else` — an absent suppliedBy takes it.",
+ "else": {
+ "properties": {
+ "ui": {
+ "$ref": "#/$defs/ComponentInputUi"
+ }
+ },
+ "required": [
+ "ui"
+ ]
+ },
+ "if": {
+ "properties": {
+ "suppliedBy": {
+ "const": "CONNECTION"
+ }
+ },
+ "required": [
+ "suppliedBy"
+ ]
+ },
+ "then": {
+ "properties": {
+ "ui": {
+ "type": "null"
+ }
+ }
+ }
+ },
+ {
+ "$comment": "spec.md §6.1: a generated value is secret material. isSensitive is required explicitly because its default is false — the wrong answer here — while suppliedBy is only constrained when written, because its default is already USER.",
+ "if": {
+ "properties": {
+ "generator": {
+ "not": {
+ "type": "null"
+ }
+ }
+ },
+ "required": [
+ "generator"
+ ]
+ },
+ "then": {
+ "properties": {
+ "schema": {
+ "properties": {
+ "isSensitive": {
+ "const": true
+ }
+ },
+ "required": [
+ "isSensitive"
+ ]
+ },
+ "suppliedBy": {
+ "const": "USER"
+ }
+ }
+ }
+ }
+ ],
"properties": {
"description": {
"description": "Human-readable description of the input, or null when none is set.",
@@ -484,7 +544,29 @@
"type": "object"
},
"ComponentOutput": {
+ "$comment": "spec.md §6.2. valueFrom is required and has two members, so the else branch is exactly DECLARED.",
"additionalProperties": false,
+ "else": {
+ "properties": {
+ "value": {
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "required": [
+ "value"
+ ]
+ },
+ "if": {
+ "properties": {
+ "valueFrom": {
+ "const": "DERIVED"
+ }
+ },
+ "required": [
+ "valueFrom"
+ ]
+ },
"properties": {
"description": {
"description": "Human-readable description of the output, or null when none is set.",
@@ -527,6 +609,13 @@
"schema",
"valueFrom"
],
+ "then": {
+ "properties": {
+ "value": {
+ "type": "null"
+ }
+ }
+ },
"type": "object",
"x-additionalPropertiesName": "outputName"
},
@@ -757,6 +846,8 @@
},
"mountPath": {
"description": "Absolute path where the volume is mounted in the workload.",
+ "maxLength": 512,
+ "pattern": "^/",
"type": "string"
},
"sizeGib": {
@@ -773,9 +864,135 @@
},
"ComponentWorkload": {
"additionalProperties": false,
+ "allOf": [
+ {
+ "$comment": "Kind × field validity, spec.md §5. Every branch below is a bare conditional carrying no `type`, so lint.ts checkClosedObjects correctly skips it — the object is closed once, by the schema that declares its properties.",
+ "if": {
+ "properties": {
+ "kind": {
+ "enum": [
+ "WORKER",
+ "JOB",
+ "CRON"
+ ]
+ }
+ },
+ "required": [
+ "kind"
+ ]
+ },
+ "then": {
+ "properties": {
+ "endpoints": {
+ "maxProperties": 0
+ }
+ }
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "kind": {
+ "enum": [
+ "JOB",
+ "CRON"
+ ]
+ }
+ },
+ "required": [
+ "kind"
+ ]
+ },
+ "then": {
+ "properties": {
+ "command": {
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "required": [
+ "command"
+ ]
+ }
+ },
+ {
+ "else": {
+ "properties": {
+ "schedule": {
+ "type": "null"
+ }
+ }
+ },
+ "if": {
+ "properties": {
+ "kind": {
+ "const": "CRON"
+ }
+ },
+ "required": [
+ "kind"
+ ]
+ },
+ "then": {
+ "properties": {
+ "schedule": {
+ "$ref": "#/$defs/ComponentSchedule"
+ }
+ },
+ "required": [
+ "schedule"
+ ]
+ }
+ },
+ {
+ "$comment": "spec.md §5.4: readiness is required for a SERVICE exposing at least one PUBLIC endpoint. Endpoints are a mapping, and no keyword asserts `some member matches` over one, so the condition is spelled as the negation of `every member fails to match`.",
+ "if": {
+ "properties": {
+ "endpoints": {
+ "not": {
+ "additionalProperties": {
+ "properties": {
+ "visibility": {
+ "not": {
+ "const": "PUBLIC"
+ }
+ }
+ }
+ }
+ }
+ },
+ "kind": {
+ "const": "SERVICE"
+ }
+ },
+ "required": [
+ "endpoints",
+ "kind"
+ ]
+ },
+ "then": {
+ "properties": {
+ "health": {
+ "$ref": "#/$defs/ComponentHealthProbes",
+ "properties": {
+ "readiness": {
+ "$ref": "#/$defs/ComponentProbe"
+ }
+ },
+ "required": [
+ "readiness"
+ ]
+ }
+ },
+ "required": [
+ "health"
+ ]
+ }
+ }
+ ],
"properties": {
"command": {
- "description": "Command run for JOB and CRON workloads, or null otherwise.",
+ "description": "Command the workload runs. Required for JOB and CRON, which have nothing to run without it. On a SERVICE or WORKER it overrides the image's default command.",
"anyOf": [
{
"type": "string"
@@ -787,7 +1004,7 @@
"default": null
},
"endpoints": {
- "description": "Endpoints the workload exposes, keyed by endpoint name.",
+ "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.",
"additionalProperties": {
"$ref": "#/$defs/ComponentEndpoint"
},
@@ -823,7 +1040,7 @@
"type": "string"
},
"schedule": {
- "description": "Cron schedule for CRON workloads, or null otherwise.",
+ "description": "Cron schedule a CRON workload runs on. Required for CRON and rejected on every other kind, where a schedule would mean nothing.",
"anyOf": [
{
"$ref": "#/$defs/ComponentSchedule"
diff --git a/specifications/component/v1/schemas/src/component.schema.json b/specifications/component/v1/schemas/src/component.schema.json
index bd51742..e14b4cc 100644
--- a/specifications/component/v1/schemas/src/component.schema.json
+++ b/specifications/component/v1/schemas/src/component.schema.json
@@ -221,16 +221,8 @@
"additionalProperties": false,
"properties": {
"build": {
- "anyOf": [
- {
- "$ref": "#/$defs/ComponentBuild"
- },
- {
- "type": "null"
- }
- ],
- "default": null,
- "description": "How the repository is built into an image, or null when defaults apply."
+ "$ref": "#/$defs/ComponentBuild",
+ "description": "How the repository is built into an image. Required — a git source has no image until something builds one, and leaving the strategy implicit makes the resulting image depend on the builder rather than on this document."
},
"ref": {
"anyOf": [
@@ -256,7 +248,8 @@
},
"required": [
"type",
- "repositoryUrl"
+ "repositoryUrl",
+ "build"
],
"type": "object"
},
@@ -307,7 +300,10 @@
"additionalProperties": false,
"properties": {
"ref": {
- "description": "OCI image reference the workload runs from.",
+ "$comment": "Lookahead-free so it compiles under RE2 as well as ECMA-262. The tag colon is the one after the final slash, which is what keeps a registry port (localhost:5000/nginx) from reading as a tag. Only the grammar is here: the floating-tag blocklist stays a semantic rule so it can grow without a major version. See spec.md §5.1.",
+ "description": "OCI image reference the workload runs from. Must carry a tag or an @sha256 digest — a bare name is an implicit :latest and shifts under the registry.",
+ "maxLength": 512,
+ "pattern": "^(?:[A-Za-z0-9][A-Za-z0-9.-]*(?::[0-9]+)?/)?(?:[a-z0-9][a-z0-9._-]*/)*[a-z0-9][a-z0-9._-]*(?::[A-Za-z0-9_][A-Za-z0-9._-]{0,127}|(?::[A-Za-z0-9_][A-Za-z0-9._-]{0,127})?@sha256:[0-9a-f]{64})$",
"type": "string"
},
"type": {
@@ -324,6 +320,70 @@
},
"ComponentInput": {
"additionalProperties": false,
+ "allOf": [
+ {
+ "$comment": "spec.md §6.1. suppliedBy defaults to USER, and a default is invisible to a validator, so the USER branch is the `else` — an absent suppliedBy takes it.",
+ "else": {
+ "properties": {
+ "ui": {
+ "$ref": "#/$defs/ComponentInputUi"
+ }
+ },
+ "required": [
+ "ui"
+ ]
+ },
+ "if": {
+ "properties": {
+ "suppliedBy": {
+ "const": "CONNECTION"
+ }
+ },
+ "required": [
+ "suppliedBy"
+ ]
+ },
+ "then": {
+ "properties": {
+ "ui": {
+ "type": "null"
+ }
+ }
+ }
+ },
+ {
+ "$comment": "spec.md §6.1: a generated value is secret material. isSensitive is required explicitly because its default is false — the wrong answer here — while suppliedBy is only constrained when written, because its default is already USER.",
+ "if": {
+ "properties": {
+ "generator": {
+ "not": {
+ "type": "null"
+ }
+ }
+ },
+ "required": [
+ "generator"
+ ]
+ },
+ "then": {
+ "properties": {
+ "schema": {
+ "properties": {
+ "isSensitive": {
+ "const": true
+ }
+ },
+ "required": [
+ "isSensitive"
+ ]
+ },
+ "suppliedBy": {
+ "const": "USER"
+ }
+ }
+ }
+ }
+ ],
"properties": {
"description": {
"anyOf": [
@@ -485,6 +545,35 @@
},
"ComponentOutput": {
"additionalProperties": false,
+ "$comment": "spec.md §6.2. valueFrom is required and has two members, so the else branch is exactly DECLARED.",
+ "else": {
+ "properties": {
+ "value": {
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "required": [
+ "value"
+ ]
+ },
+ "if": {
+ "properties": {
+ "valueFrom": {
+ "const": "DERIVED"
+ }
+ },
+ "required": [
+ "valueFrom"
+ ]
+ },
+ "then": {
+ "properties": {
+ "value": {
+ "type": "null"
+ }
+ }
+ },
"properties": {
"description": {
"anyOf": [
@@ -757,6 +846,8 @@
},
"mountPath": {
"description": "Absolute path where the volume is mounted in the workload.",
+ "maxLength": 512,
+ "pattern": "^/",
"type": "string"
},
"sizeGib": {
@@ -773,6 +864,132 @@
},
"ComponentWorkload": {
"additionalProperties": false,
+ "allOf": [
+ {
+ "$comment": "Kind × field validity, spec.md §5. Every branch below is a bare conditional carrying no `type`, so lint.ts checkClosedObjects correctly skips it — the object is closed once, by the schema that declares its properties.",
+ "if": {
+ "properties": {
+ "kind": {
+ "enum": [
+ "WORKER",
+ "JOB",
+ "CRON"
+ ]
+ }
+ },
+ "required": [
+ "kind"
+ ]
+ },
+ "then": {
+ "properties": {
+ "endpoints": {
+ "maxProperties": 0
+ }
+ }
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "kind": {
+ "enum": [
+ "JOB",
+ "CRON"
+ ]
+ }
+ },
+ "required": [
+ "kind"
+ ]
+ },
+ "then": {
+ "properties": {
+ "command": {
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "required": [
+ "command"
+ ]
+ }
+ },
+ {
+ "else": {
+ "properties": {
+ "schedule": {
+ "type": "null"
+ }
+ }
+ },
+ "if": {
+ "properties": {
+ "kind": {
+ "const": "CRON"
+ }
+ },
+ "required": [
+ "kind"
+ ]
+ },
+ "then": {
+ "properties": {
+ "schedule": {
+ "$ref": "#/$defs/ComponentSchedule"
+ }
+ },
+ "required": [
+ "schedule"
+ ]
+ }
+ },
+ {
+ "$comment": "spec.md §5.4: readiness is required for a SERVICE exposing at least one PUBLIC endpoint. Endpoints are a mapping, and no keyword asserts `some member matches` over one, so the condition is spelled as the negation of `every member fails to match`.",
+ "if": {
+ "properties": {
+ "endpoints": {
+ "not": {
+ "additionalProperties": {
+ "properties": {
+ "visibility": {
+ "not": {
+ "const": "PUBLIC"
+ }
+ }
+ }
+ }
+ }
+ },
+ "kind": {
+ "const": "SERVICE"
+ }
+ },
+ "required": [
+ "endpoints",
+ "kind"
+ ]
+ },
+ "then": {
+ "properties": {
+ "health": {
+ "$ref": "#/$defs/ComponentHealthProbes",
+ "properties": {
+ "readiness": {
+ "$ref": "#/$defs/ComponentProbe"
+ }
+ },
+ "required": [
+ "readiness"
+ ]
+ }
+ },
+ "required": [
+ "health"
+ ]
+ }
+ }
+ ],
"properties": {
"command": {
"anyOf": [
@@ -784,13 +1001,13 @@
}
],
"default": null,
- "description": "Command run for JOB and CRON workloads, or null otherwise."
+ "description": "Command the workload runs. Required for JOB and CRON, which have nothing to run without it. On a SERVICE or WORKER it overrides the image's default command."
},
"endpoints": {
"additionalProperties": {
"$ref": "#/$defs/ComponentEndpoint"
},
- "description": "Endpoints the workload exposes, keyed by endpoint name.",
+ "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.",
"type": "object"
},
"envVars": {
@@ -832,7 +1049,7 @@
}
],
"default": null,
- "description": "Cron schedule for CRON workloads, or null otherwise."
+ "description": "Cron schedule a CRON workload runs on. Required for CRON and rejected on every other kind, where a schedule would mean nothing."
},
"source": {
"description": "Discriminated image or git source the workload is built or run from.",
diff --git a/specifications/component/v1/spec.md b/specifications/component/v1/spec.md
index d4a9cff..5fb02b5 100644
--- a/specifications/component/v1/spec.md
+++ b/specifications/component/v1/spec.md
@@ -81,17 +81,83 @@ validated against `v1.0.0` MUST validate against every later `v1.x.y`.
## 5. Workload
-> **TODO** — Normative rules for each workload kind (`SERVICE`, `WORKER`,
-> `JOB`, `CRON`), including which fields are meaningful for each. In particular:
-> whether `endpoints` on a `WORKER`, or `schedule` on a `SERVICE`, is an error
-> or ignored. Current schema permits both; that MUST be resolved before v1 is
-> declared stable.
+`spec.workload` says how the component runs. Its `kind` is the runtime shape,
+and the shape decides which of the remaining fields carry meaning.
+
+| Field | `SERVICE` | `WORKER` | `JOB` | `CRON` |
+|---|---|---|---|---|
+| `endpoints` | permitted | forbidden | forbidden | forbidden |
+| `command` | permitted | permitted | REQUIRED | REQUIRED |
+| `schedule` | forbidden | forbidden | forbidden | REQUIRED |
+| `health.readiness` | see [§5.4](#health) | permitted | permitted | permitted |
+
+`source`, `envVars`, and `volumes` are permitted on every kind. Every rule in
+the table is decided in the `structural` phase.
+
+**Forbidden means rejected, not ignored.** A forbidden field MAY be omitted, or
+written in its own empty form — `endpoints: {}` for a mapping, `schedule: null`
+for a nullable block. Anything else is an error, and the two empty forms are
+not interchangeable: `endpoints` is a mapping and takes no null,
+`schedule` is a nullable block and takes no empty mapping.
+[§2](#envelope) has already settled why — a misspelled field is an error rather
+than a silently ignored one, "including when the misspelled field is optional,
+where ignoring it would silently substitute the default". A `schedule` on a
+`SERVICE` fails for the same reason: an author who writes one believes their
+service is scheduled, and accepting it silently is how that belief survives to
+production.
+
+**`command` is a different case from `schedule`.** A `JOB` and a `CRON` have
+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.
+
+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
+the `parser` phase, before any rule in this section is considered.
### 5.1 Source
-> **TODO** — Image reference pinning rules. Floating tags (`latest`, `main`,
-> `edge`) MUST be rejected; state the exact grammar and the diagnostic code.
-> Define digest-pinned reference handling and Git ref resolution semantics.
+`source` is discriminated on `type`.
+
+**`IMAGE`.** `ref` is REQUIRED and names a prebuilt OCI image. There is no
+`build` on this branch — a prebuilt image is not built again, and the field is
+absent rather than ignored so it cannot be misread as an override.
+
+**`GIT`.** `repositoryUrl` and `build` are both REQUIRED. `ref` is OPTIONAL and
+pins a branch or a commit; omitting it takes the repository's default branch. A
+`BRANCH` ref resolves at build time, so two builds of one unchanged document
+can produce different images. A `COMMIT` ref is reproducible. Neither is
+rejected, but a component that must build reproducibly SHOULD pin a commit.
+
+**Image references MUST be pinned.** An unpinned reference mutates under
+whoever curates the registry, shifting a deployment with no change to any
+document in the item. Two layers enforce it, and the split between them is
+deliberate.
+
+A reference MUST carry a tag or a digest. A bare name — an implicit `:latest` —
+is rejected in the `structural` phase with `ERR_INVALID_VALUE`. That is a
+grammar, so the schema carries it.
+
+A reference MUST NOT carry a floating tag. The floating set is `latest`,
+`main`, `main-stable`, `master`, `stable`, `edge`, `nightly`, `dev`, and
+`rolling`, compared case-insensitively; a reference whose tag is one of them is
+rejected in the `semantic` phase with `ERR_UNPINNED_IMAGE`.
+
+**Why the floating set is not a `pattern`.** It is a curated list and it will
+grow. Growing a `pattern` makes a previously valid document invalid, which is a
+major version. Held as a `semantic` rule instead, the list can be extended in a
+minor release — the grammar above is fixed and belongs in the schema, the
+blocklist is not and does not.
+
+A digest pin — `@sha256:` followed by 64 lowercase hexadecimal digits —
+satisfies both rules whatever tag accompanies it, because the digest is what
+resolves. The floating-tag rule applies only to a reference carrying no digest.
### 5.2 Endpoints
@@ -105,20 +171,112 @@ validated against `v1.0.0` MUST validate against every later `v1.x.y`.
### 5.4 Health probes
-> **TODO** — Semantics of `startup`, `readiness`, and `liveness`; default
-> values; and the meaning of a probe naming an endpoint that does not exist.
+Three staged probes. Each is OPTIONAL unless the rule below applies, and each
+polls an HTTP path.
+
+| Probe | Gate | Consequence of failure |
+|---|---|---|
+| `startup` | Initialisation | The workload counts as not yet started. |
+| `readiness` | Traffic | The replica leaves routing; it is not restarted. |
+| `liveness` | Aliveness | The container is restarted. |
+
+`path` is the only REQUIRED property of a probe. The rest default:
+`initialDelaySeconds: 10`, `periodSeconds: 10`, `timeoutSeconds: 5`,
+`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.
+
+**`readiness` is REQUIRED for a `SERVICE` exposing at least one `PUBLIC`
+endpoint**, and OPTIONAL everywhere else, including on a `SERVICE` whose
+endpoints are all `PRIVATE`. The rule is narrow on purpose. Without a readiness
+gate a public URL routes to a replica that is running but not yet serving, and
+the first request a user makes is the one that fails. A private consumer inside
+the mesh retries; a browser does not.
### 5.5 Volumes
-> **TODO** — Mount path constraints (absolute, no traversal, no overlap between
-> two volumes), size bounds, and access-mode semantics.
+`volumes` is a mapping from volume name to a declaration. `sizeGib` and
+`mountPath` are REQUIRED. `accessMode` (default `READ_WRITE_ONCE`) and
+`isReadOnly` (default `false`) are OPTIONAL.
+
+`mountPath` MUST be absolute. A relative path is rejected in the `structural`
+phase with `ERR_INVALID_VALUE`.
+
+`accessMode` decides how the materialised volume is shared across replicas.
+`READ_WRITE_ONCE` is an attached block volume and mounts on a single replica;
+`READ_WRITE_MANY` is a shared network volume and mounts on every replica.
+
+**What v1 does not constrain.** Two volumes on one workload MAY declare
+overlapping mount paths, and `sizeGib` has no lower or upper bound. Neither
+silence is a considered permission — both are gaps, in this specification and
+in the implementations reading it, and they are recorded here rather than
+described aspirationally so that a reader can tell which silences are
+decisions. Closing either one rejects documents that validate today and is
+therefore a breaking change.
## 6. Configuration contract
-> **TODO** — The `inputs`/`outputs` contract is the load-bearing part of
-> composition and needs the most precise prose. Cover: `suppliedBy` semantics,
-> generator determinism, what makes an output referenceable by a blueprint
-> connection, and type compatibility between an output and the input it feeds.
+`spec.contract` is what makes a component composable. `inputs` are values the
+component needs; `outputs` are values it publishes for another component to
+consume. A [Blueprint](../../blueprint/v1/spec.md) connection joins one node's
+output to another node's input, and this section defines both ends of that
+join.
+
+`contract` is OPTIONAL. A component that neither consumes nor publishes
+configuration omits it.
+
+### 6.1 Inputs
+
+`schema` is the only REQUIRED property of an input. `suppliedBy` decides who
+satisfies it and defaults to `USER`.
+
+| `suppliedBy` | Satisfied by | `ui` |
+|---|---|---|
+| `USER` | The deploying user, through the install form. | REQUIRED |
+| `CONNECTION` | A blueprint connection, from an upstream output. | MUST be null |
+
+A `USER` input needs a label before a form can render it. A `CONNECTION` input
+never reaches the install form, so presentation metadata on one is a statement
+about a form it will never appear on. Both rules are `structural`.
+
+**A generated input is secret material.** An input carrying `generator` — one
+whose value the platform mints at deploy time — MUST declare `suppliedBy: USER`
+and `schema.isSensitive: true`. Both MUST be written explicitly rather than
+left to a default: `isSensitive` defaults to `false`, and a generated value not
+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.
+
+### 6.2 Outputs
+
+`schema` and `valueFrom` are REQUIRED.
+
+| `valueFrom` | `value` | Where the value comes from |
+|---|---|---|
+| `DECLARED` | REQUIRED, non-empty | This document. |
+| `DERIVED` | MUST be null | The platform, from the running workload. |
+
+**An output depends on its own node and nothing else.** A `DECLARED` output's
+value is written in this document. A `DERIVED` output's value comes from the
+producing workload's own addressing — its private address, its public URL. An
+output MUST NOT depend on a value the component received over an inbound
+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.
+
+> **TODO** — Type compatibility between an output and the input it feeds.
+> `schema.semanticType` is the tag a composition layer matches on, but the
+> matching rule is not yet stated. It belongs with
+> [blueprint §4.2](../../blueprint/v1/spec.md#connections), which resolves the
+> connection.
## 7. Validation layers
@@ -151,9 +309,14 @@ different text and that is expected.
| `ERR_MISSING_FIELD` | `structural` | A required property is absent. |
| `ERR_INVALID_TYPE` | `structural` | A value has the wrong type. |
| `ERR_INVALID_VALUE` | `structural` | A value violates a pattern, enum, or bound. |
-
-> **TODO** — Extend with the semantic-phase codes once section 6 is written.
-> The registry above is the complete set an implementation must handle today.
+| `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. |
+
+The rows above the `semantic` pair 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.
## 9. Conformance
diff --git a/specifications/listing/v1/schemas/dist/listing.schema.json b/specifications/listing/v1/schemas/dist/listing.schema.json
index 89e1e37..cceefae 100644
--- a/specifications/listing/v1/schemas/dist/listing.schema.json
+++ b/specifications/listing/v1/schemas/dist/listing.schema.json
@@ -72,9 +72,11 @@
"default": null
},
"file": {
- "description": "Relative path of the screenshot asset under the item's media/ directory.",
+ "description": "Path of the screenshot asset under the item's media/ directory, relative to the item root. Must be a .png, .jpg, .jpeg or .webp below media/.",
+ "$comment": "Lookahead-free so it compiles under RE2 as well as ECMA-262. A segment must begin with a letter or digit, which is how `.` and `..` are excluded without a negative lookahead; the extension alternation is spelled in character classes because a JSON Schema pattern carries no case-insensitive flag. See spec.md §5.",
"maxLength": 512,
"minLength": 1,
+ "pattern": "^media/(?:[A-Za-z0-9][A-Za-z0-9._-]*/)*[A-Za-z0-9][A-Za-z0-9._-]*\\.(?:[Pp][Nn][Gg]|[Jj][Pp][Gg]|[Jj][Pp][Ee][Gg]|[Ww][Ee][Bb][Pp])$",
"type": "string"
}
},
@@ -137,9 +139,12 @@
"default": null
},
"icon": {
- "description": "Relative path of the listing's icon under the item's media/ directory (e.g. media/icon.png), or null for no icon.",
+ "description": "Path of the listing's icon under the item's media/ directory (e.g. media/icon.png), relative to the item root, or null for no icon.",
"anyOf": [
{
+ "$comment": "The same grammar ListingScreenshot.file carries; see the note there.",
+ "maxLength": 512,
+ "pattern": "^media/(?:[A-Za-z0-9][A-Za-z0-9._-]*/)*[A-Za-z0-9][A-Za-z0-9._-]*\\.(?:[Pp][Nn][Gg]|[Jj][Pp][Gg]|[Jj][Pp][Ee][Gg]|[Ww][Ee][Bb][Pp])$",
"type": "string"
},
{
diff --git a/specifications/listing/v1/schemas/src/listing.schema.json b/specifications/listing/v1/schemas/src/listing.schema.json
index 217ead6..7a0861f 100644
--- a/specifications/listing/v1/schemas/src/listing.schema.json
+++ b/specifications/listing/v1/schemas/src/listing.schema.json
@@ -72,9 +72,11 @@
"description": "Accessible caption shown beneath the screenshot, or null when none."
},
"file": {
- "description": "Relative path of the screenshot asset under the item's media/ directory.",
+ "$comment": "Lookahead-free so it compiles under RE2 as well as ECMA-262. A segment must begin with a letter or digit, which is how `.` and `..` are excluded without a negative lookahead; the extension alternation is spelled in character classes because a JSON Schema pattern carries no case-insensitive flag. See spec.md §5.",
+ "description": "Path of the screenshot asset under the item's media/ directory, relative to the item root. Must be a .png, .jpg, .jpeg or .webp below media/.",
"maxLength": 512,
"minLength": 1,
+ "pattern": "^media/(?:[A-Za-z0-9][A-Za-z0-9._-]*/)*[A-Za-z0-9][A-Za-z0-9._-]*\\.(?:[Pp][Nn][Gg]|[Jj][Pp][Gg]|[Jj][Pp][Ee][Gg]|[Ww][Ee][Bb][Pp])$",
"type": "string"
}
},
@@ -139,6 +141,9 @@
"icon": {
"anyOf": [
{
+ "$comment": "The same grammar ListingScreenshot.file carries; see the note there.",
+ "maxLength": 512,
+ "pattern": "^media/(?:[A-Za-z0-9][A-Za-z0-9._-]*/)*[A-Za-z0-9][A-Za-z0-9._-]*\\.(?:[Pp][Nn][Gg]|[Jj][Pp][Gg]|[Jj][Pp][Ee][Gg]|[Ww][Ee][Bb][Pp])$",
"type": "string"
},
{
@@ -146,7 +151,7 @@
}
],
"default": null,
- "description": "Relative path of the listing's icon under the item's media/ directory (e.g. media/icon.png), or null for no icon."
+ "description": "Path of the listing's icon under the item's media/ directory (e.g. media/icon.png), relative to the item root, or null for no icon."
},
"license": {
"anyOf": [
diff --git a/specifications/listing/v1/spec.md b/specifications/listing/v1/spec.md
index 3c39c9a..1480a7f 100644
--- a/specifications/listing/v1/spec.md
+++ b/specifications/listing/v1/spec.md
@@ -39,20 +39,53 @@ format; `listingKind` identifies what the listing points at (`BLUEPRINT` or
## 3. Identity
-> **TODO** — `metadata.slug` MUST equal the item directory name and MUST agree
-> with the sibling blueprint document. `metadata.version` MUST agree likewise.
-> Both are `semantic`-phase rules.
+`metadata` carries `slug` and `version` — the same shape the sibling blueprint
+carries, and the two MUST agree.
+
+| Rule | Diagnostic |
+|---|---|
+| `metadata.slug` MUST equal the item directory name. | `ERR_SLUG_MISMATCH` |
+| `metadata.version` MUST equal the sibling blueprint's `metadata.version`. | `ERR_VERSION_MISMATCH` |
+
+Both are `semantic`, and both are measured against the item root defined in
+[blueprint §3.1](../../blueprint/v1/spec.md#item-directory). A listing handed
+over with no directory has no item root, and an implementation in that
+position MUST NOT report either rule.
+
+**The two documents are one item's two halves.** A listing whose version has
+moved ahead of its blueprint describes something other than what would be
+installed — this release's storefront copy over last release's graph. The rule
+is what keeps "read about this" and "install this" the same thing.
+
+The version rule needs a blueprint to compare against. An item whose listing
+is `listingKind: COMPONENT` need not contain one; where there is no sibling
+blueprint the rule has nothing to compare and does not apply.
+
+> **TODO** — What `metadata.version` agrees with in a `COMPONENT` item, which
+> has no blueprint. The component documents each carry their own
+> `metadata.version` and an item may hold more than one, so this is not the
+> same rule with a different sibling.
## 4. Presentation
-> **TODO** — Define the rendering contract for `summary` and `description`:
-> `summary` is plain text of bounded length; `description` is Markdown, and the
-> permitted subset MUST be stated (a storefront rendering untrusted Markdown is
-> an injection surface).
+`summary` is plain text, at most 280 characters, and MUST NOT be rendered as
+Markdown. It is a one-line tagline; rendering it as Markdown turns an
+underscore in a product name into emphasis and an asterisk into a bullet.
+
+`description` is Markdown, at most 20 000 characters.
+
+> **TODO** — The permitted Markdown subset for `description`. This one is a
+> security question rather than an undocumented behaviour: neither this
+> specification nor any implementation constrains the subset today, and a
+> storefront rendering untrusted Markdown from a third-party listing is an
+> injection surface. Raw embedded HTML, `javascript:` URLs, and remote image
+> references are the three that need deciding.
> **TODO** — `category` and `lifecycleStage` are controlled vocabularies. State
> the governance rule for adding a term — this is the field most likely to need
-> extension, and unmanaged growth makes the storefront incoherent.
+> extension, and unmanaged growth makes the storefront incoherent. Note that
+> adding a term is a minor release and removing one is major, since narrowing
+> an enum rejects a document that validated before.
Featured-row placement is **not** part of this contract. A listing document
MUST NOT declare `spec.featured`; promotion is a storefront-operator action,
@@ -63,15 +96,52 @@ listing.
## 5. Media
-`icon` and `screenshots[].file` are paths relative to the listing document.
-
-> **TODO** — Normative constraints: paths MUST be relative, MUST NOT escape the
-> item directory (`..` rejected), MUST resolve to a file that exists, and MUST
-> be one of a stated set of image formats. Dimension and size bounds SHOULD be
-> specified so the storefront can render without layout shift.
-
-Path containment is a `semantic`-phase rule; JSON Schema can constrain the
-string shape but cannot confirm the target exists inside the item directory.
+`icon` and `screenshots[].file` are media paths, resolved inside the item root
+defined in [blueprint §3.1](../../blueprint/v1/spec.md#item-directory).
+
+A media path MUST satisfy all of the following, and is rejected in the
+`structural` phase with `ERR_INVALID_VALUE` when it does not:
+
+- it MUST be relative — a leading `/` is not accepted;
+- its first segment MUST be exactly `media`;
+- every segment after that MUST begin with a letter or a digit, which is how
+ `.` and `..` are excluded as segments without a negative lookahead;
+- it MUST end in `.png`, `.jpg`, `.jpeg`, or `.webp`, in any case.
+
+**`media/` is a stronger rule than "relative to the listing document".** That
+was this section's earlier wording, and it is not what anything enforces. One
+fixed directory means a reader can find every asset an item ships without
+first reading its listing, and a publisher can copy that directory without
+walking the document to work out what to take.
+
+Three rules need the filesystem and are therefore `semantic`:
+
+| Rule | Diagnostic |
+|---|---|
+| The path MUST resolve to a file that exists. | `ERR_MEDIA_NOT_FOUND` |
+| The resolved target MUST lie inside the item root. | `ERR_PATH_ESCAPE` |
+| Two screenshots MUST NOT share a basename. | `ERR_DUPLICATE_MEDIA_BASENAME` |
+
+**`ERR_PATH_ESCAPE` outlives the grammar.** The pattern above makes `..`
+unspellable, so no path can escape by traversal any more. One can still escape
+by symlink — `media/icon.png` pointing outside the item is a legal spelling
+resolving to an illegal target. Containment is a property of the resolved
+location rather than of the string, the same distinction
+[blueprint §4.1](../../blueprint/v1/spec.md#component-reference) draws for a
+component reference.
+
+**Basenames must differ across the whole item**, not merely within a
+directory: `media/desktop/overview.png` and `media/mobile/overview.png`
+collide. Published assets are addressed by basename, so two files called
+`overview.png` are one file. The constraint is a real one on an author and is
+stated here rather than left to be found out when the second screenshot
+silently replaces the first.
+
+**What v1 does not constrain.** Neither dimensions nor file size are bounded.
+A storefront cannot reserve space for an image whose aspect ratio it does not
+know, so this is a gap rather than a permission — but bounding either one
+rejects listings that validate today, which makes closing it a breaking
+change.
## 6. Validation layers
@@ -87,7 +157,8 @@ family adds:
| `ERR_SLUG_MISMATCH` | `semantic` | `metadata.slug` disagrees with the item directory name. |
| `ERR_VERSION_MISMATCH` | `semantic` | `metadata.version` disagrees with the sibling blueprint document. |
| `ERR_MEDIA_NOT_FOUND` | `semantic` | A referenced media file does not exist. |
-| `ERR_PATH_ESCAPE` | `semantic` | A media path escapes the item directory. |
+| `ERR_PATH_ESCAPE` | `semantic` | A media path resolves outside the item directory. |
+| `ERR_DUPLICATE_MEDIA_BASENAME` | `semantic` | Two screenshots share a basename. |
## 8. Conformance
diff --git a/tools/src/conformance.ts b/tools/src/conformance.ts
index e30b61d..fd0c1f6 100644
--- a/tools/src/conformance.ts
+++ b/tools/src/conformance.ts
@@ -5,6 +5,12 @@
* NOT the normative runner — there deliberately is none. Downstream
* implementations write their own adapter over the same data, which is what
* makes cross-language parity provable rather than asserted.
+ *
+ * Two kinds of check live here. Executing a case needs a phase this repository
+ * implements; validating that a case is *well-formed* — that it cites a clause
+ * that exists and declares codes the prose actually defines — does not. The
+ * second kind runs for every case, including the ones the first kind skips,
+ * because a `semantic` fixture would otherwise be checked by nothing at all.
*/
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
@@ -14,6 +20,7 @@ import {
type Family,
isObject,
type Json,
+ REPO_ROOT,
readJson,
relativeToRepo,
} from './spec.ts'
@@ -33,8 +40,68 @@ interface CaseMetadata {
readonly summary?: string
}
+interface DeclaredDiagnostic {
+ readonly code: string
+ readonly path: string
+}
+
+const PHASES: readonly Phase[] = ['parser', 'structural', 'semantic', 'capability']
+
const IMPLEMENTED_PHASES = new Set(['parser', 'structural'])
+/**
+ * The family whose diagnostics table the other families declare themselves
+ * deltas on: blueprint §7 and listing §7 both open "The codes in component §8
+ * apply. This family adds:". The registry a fixture may draw on is therefore
+ * its own family's table unioned with this one's.
+ */
+const BASE_FAMILY = 'component'
+
+/** A row of a `| Code | Phase | Meaning |` table in a spec.md. */
+const DIAGNOSTIC_ROW = /^\|\s*`(ERR_[A-Z0-9_]+)`\s*\|\s*`([a-z]+)`\s*\|/
+/** A stable heading anchor, `## 2. Document envelope`. */
+const SPEC_ANCHOR = /<\/a>/g
+
+interface SpecIndex {
+ /** Diagnostic code to the phase the prose assigns it. */
+ readonly codes: ReadonlyMap
+ readonly anchors: ReadonlySet
+}
+
+const EMPTY_INDEX: SpecIndex = { codes: new Map(), anchors: new Set() }
+
+const specIndexCache = new Map()
+
+/** Index one spec.md, or `undefined` when there is no such file. */
+function specIndex(path: string): SpecIndex | undefined {
+ const cached = specIndexCache.get(path)
+ if (cached !== undefined || specIndexCache.has(path)) return cached
+
+ let index: SpecIndex | undefined
+ if (existsSync(path)) {
+ const source = readFileSync(path, 'utf8')
+ const codes = new Map()
+ for (const line of source.split('\n')) {
+ const row = DIAGNOSTIC_ROW.exec(line)
+ if (row?.[1] !== undefined && row[2] !== undefined) codes.set(row[1], row[2] as Phase)
+ }
+ const anchors = new Set()
+ for (const match of source.matchAll(SPEC_ANCHOR)) {
+ if (match[1] !== undefined) anchors.add(match[1])
+ }
+ index = { codes, anchors }
+ }
+ specIndexCache.set(path, index)
+ return index
+}
+
+/** Every diagnostic code a fixture in this family may legitimately declare. */
+function registryFor(family: Family): ReadonlyMap {
+ const own = specIndex(family.specPath) ?? EMPTY_INDEX
+ const base = specIndex(family.specPath.replace(`/${family.name}/`, `/${BASE_FAMILY}/`))
+ return new Map([...(base ?? EMPTY_INDEX).codes, ...own.codes])
+}
+
function loadIndex(family: Family, failures: Failures): CaseIndexEntry[] {
const indexPath = join(family.conformanceDir, 'cases.json')
if (!existsSync(indexPath)) return []
@@ -56,6 +123,92 @@ function loadIndex(family: Family, failures: Failures): CaseIndexEntry[] {
return entries
}
+/**
+ * Check the parts of a case that hold whether or not the phase runs here: the
+ * declared outcome, the clause it traces to, and the codes it names.
+ *
+ * Returns the declared diagnostics so the executing half does not re-read them,
+ * or `null` when the case is malformed.
+ */
+function checkCaseShape(
+ family: Family,
+ entry: CaseIndexEntry,
+ caseDir: string,
+ label: string,
+ metadata: CaseMetadata,
+ failures: Failures,
+): DeclaredDiagnostic[] | null {
+ let ok = true
+
+ if (!PHASES.includes(metadata.phase)) {
+ failures.add(`${label}: phase "${metadata.phase}" is not one of ${PHASES.join(', ')}`)
+ return null
+ }
+ if (entry.phase !== metadata.phase) {
+ failures.add(
+ `${label}: cases.json says phase "${entry.phase}" but metadata.json says "${metadata.phase}"`,
+ )
+ ok = false
+ }
+ if (!metadata.id.startsWith(`${metadata.phase}-`)) {
+ failures.add(`${label}: id must follow -- and lead with the phase`)
+ ok = false
+ }
+
+ // Every case should trace to prose — a fixture that cites nothing is an
+ // assertion about an implementation, not about the specification.
+ if (metadata.clause !== undefined) {
+ const [clausePath, fragment] = metadata.clause.split('#')
+ const cited = clausePath === undefined ? undefined : specIndex(join(REPO_ROOT, clausePath))
+ if (cited === undefined) {
+ failures.add(`${label}: clause cites ${clausePath}, which does not exist`)
+ ok = false
+ } else if (fragment === undefined || !cited.anchors.has(fragment)) {
+ failures.add(`${label}: clause anchor #${fragment ?? ''} is not declared in ${clausePath}`)
+ ok = false
+ }
+ }
+
+ if (metadata.expected === 'pass') return ok ? [] : null
+
+ const diagnosticsPath = join(caseDir, 'diagnostics.json')
+ if (!existsSync(diagnosticsPath)) {
+ failures.add(`${label}: a failing case must declare diagnostics.json`)
+ return null
+ }
+ const declared = readJson(diagnosticsPath)
+ if (!Array.isArray(declared) || declared.length === 0) {
+ failures.add(`${label}: diagnostics.json must be a non-empty array`)
+ return null
+ }
+
+ const registry = registryFor(family)
+ const diagnostics: DeclaredDiagnostic[] = []
+ for (const item of declared as Json[]) {
+ if (!isObject(item) || typeof item.code !== 'string' || typeof item.path !== 'string') {
+ failures.add(`${label}: every diagnostic needs string "code" and "path"`)
+ return null
+ }
+ const declaredPhase = registry.get(item.code)
+ if (declaredPhase === undefined) {
+ failures.add(
+ `${label}: ${item.code} is declared by no diagnostics table reachable from ` +
+ `${relativeToRepo(family.specPath)}`,
+ )
+ ok = false
+ } else if (declaredPhase !== metadata.phase) {
+ failures.add(
+ `${label}: ${item.code} is a ${declaredPhase}-phase code but the case declares ` +
+ `${metadata.phase}`,
+ )
+ ok = false
+ }
+ diagnostics.push({ code: item.code, path: item.path })
+ }
+
+ return ok ? diagnostics : null
+}
+
function runCase(
family: Family,
entry: CaseIndexEntry,
@@ -83,6 +236,9 @@ function runCase(
return 'failed'
}
+ const declared = checkCaseShape(family, entry, caseDir, label, metadata, failures)
+ if (declared === null) return 'failed'
+
if (!IMPLEMENTED_PHASES.has(metadata.phase)) {
console.log(` · ${label}: ${metadata.phase} phase not implemented here — skipped`)
return 'skipped'
@@ -115,23 +271,8 @@ function runCase(
}
// Diagnostic codes and the failing phase are normative; message text is not.
- const diagnosticsPath = join(caseDir, 'diagnostics.json')
- if (!existsSync(diagnosticsPath)) {
- failures.add(`${label}: a failing case must declare diagnostics.json`)
- return 'failed'
- }
- const declared = readJson(diagnosticsPath)
- if (!Array.isArray(declared) || declared.length === 0) {
- failures.add(`${label}: diagnostics.json must be a non-empty array`)
- return 'failed'
- }
-
const produced = new Set(result.diagnostics.map((d) => `${d.code}@${d.path}`))
- for (const item of declared as Json[]) {
- if (!isObject(item) || typeof item.code !== 'string' || typeof item.path !== 'string') {
- failures.add(`${label}: every diagnostic needs string "code" and "path"`)
- return 'failed'
- }
+ for (const item of declared) {
if (!produced.has(`${item.code}@${item.path}`)) {
failures.add(
`${label}: declared diagnostic ${item.code} at ${item.path || '/'} was not produced.\n` +
diff --git a/tools/src/spec.ts b/tools/src/spec.ts
index 5d29ae9..58e2379 100644
--- a/tools/src/spec.ts
+++ b/tools/src/spec.ts
@@ -29,6 +29,8 @@ export interface Family {
readonly srcDir: string
readonly distDir: string
readonly examplesDir: string
+ /** Absolute path to the family's normative prose. */
+ readonly specPath: string
/** Absolute path to the generated bundle. */
readonly bundlePath: string
/** Canonical publication URL of the bundle within its major-version alias. */
@@ -75,6 +77,7 @@ export function discoverFamilies(): Family[] {
srcDir: join(dir, 'schemas', 'src'),
distDir: join(dir, 'schemas', 'dist'),
examplesDir: join(dir, 'examples'),
+ specPath: join(dir, 'spec.md'),
bundlePath: join(dir, 'schemas', 'dist', `${name}.schema.json`),
bundleUrl: `${SCHEMA_ORIGIN}/${name}/${major}/${name}.schema.json`,
conformanceDir: join(CONFORMANCE_DIR, name, major),