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