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),