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