feat: reconcile the specification/platform divergences, and expose the edge address - #41
Merged
Merged
Conversation
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 <merrelljustin@gmail.com>
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 <merrelljustin@gmail.com>
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 <merrelljustin@gmail.com>
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 <merrelljustin@gmail.com>
…hey were written §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 <merrelljustin@gmail.com>
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 <merrelljustin@gmail.com>
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<N> 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 <merrelljustin@gmail.com>
…e coverage gaps
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 <merrelljustin@gmail.com>
…UBLIC_PORT 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.<endpoint> 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 <merrelljustin@gmail.com>
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 <merrelljustin@gmail.com>
This was referenced Aug 17, 2026
7 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changes
Closes the last two open issues in this repository, and does it before the first
release rather than after. Closes #24. Closes #32.
#24 collects three places where PR #13 decided against implemented platform behaviour.
Each is a live divergence — a conformant implementation and the platform return
different verdicts on the same document. #32 records a capability the platform ships
that no document could reference.
Four decisions, one ADR, ten commits scoped so release-please reads them correctly.
ERR_DEPENDENCY_CYCLEwithdrawnERR_CONFLICTING_INPUT_SCHEMASERVICEendpoints (component §5)PUBLIC_ADDRESSandPUBLIC_PORTaddedAfter this, no divergence survives except 2, where ADR 0001 §1 already supplies
the answer and the platform has a defect to fix.
Why now
git tag -lis empty,.github/release-please/manifest.jsonreads0.0.0for allthree families, and #1/#2/#3 have sat open since 2026-08-08. Component §3's guarantee
is stated against a released version, and there is none — so decision 3 lands free
today and costs a
v2directory the moment a tag exists.That argument had been relied on twice (#31, #1) and existed only in PR descriptions.
ADR 0005 §1 writes it down, GOVERNANCE.md → Compatibility review now points at it,
and it names its own closing condition exactly.
The decisions
1. Cycles permitted (ADR 0005 §2)
§4.2 required acyclicity while conceding in the same clause that "acyclicity is not a
resolution hazard". The rule was held on three arguments; the load-bearing one does
not survive contact:
It obliges nothing. Component §6.2 already makes an output a function of its own node,
normatively and permanently — so resolving outputs before binding edges is what the
contract already describes, not a burden acyclicity would have lifted. Against
that stood mutual service discovery, a composition with a live consumer that the rule
made inexpressible.
What the loosening forecloses is stated in the clause rather than discovered later: no
later rule may assume a materialisation order without being a major version.
2. The merge conflict stands (ADR 0005 §3)
Unchanged, and §5.2's argument is unchanged with it — silent first-wins hands the
second component a value validated against the first's rules and fails at deploy time,
far from the two documents that disagreed.
The new pin caught a defect rather than merely describing one. §5.2 says two
declarations are identical "when their
schemablocks are equal once defaults areapplied".
tools/src/semantic.tscomparedJSON.stringify(schema)— key-ordersensitive, no defaults applied — so it rejected two declarations the prose calls the
same.
semantic-022variesui,isRequired, key order and written-out defaults;it was red on arrival and
canonicalValueSchemafixes it.3. A
SERVICEmust declare an endpoint (ADR 0005 §4) — BREAKINGThis 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. Here the specification was looser: it called a document valid that the
platform will not deploy. That is the one direction a contract must not be wrong in.
Structural, no new code — absent block is
ERR_MISSING_FIELD, empty block isERR_INVALID_VALUE, matching the two-spelling distinction §5 already makes in theforbidden direction.
4. The edge address is readable (ADR 0005 §5)
platformDefault.sourcegainsPUBLIC_ADDRESS(host:port) andPUBLIC_PORT(theallocated port alone). The four sources now pair exclusively with §5.2's two address
forms, with
ERR_ENDPOINT_NOT_L4as the mirror ofERR_ENDPOINT_NOT_HTTP— two codesso a diagnostic names the axis that failed.
Admitting an HTTP-family endpoint to
PUBLIC_ADDRESSwas refused deliberately, and§6.1 says why: it is published through the shared ingress rather than on an allocated
port, so the derivation would yield the ingress address — true, and not what an author
asking for an edge address wants. #32's
self.publicAddress.<ep>alternative isrejected in ADR 0005 §5; this contract has no expression language.
Three findings worth a reviewer's attention
1. #24 understates divergence 3's blast radius by an order of magnitude. It names
examples/minimal.yamlandstructural-001-minimal-valid. The sweep found 30documents declaring a
SERVICEwith noendpoints.2. Seventeen negative fixtures stopped pinning their own rule, and the runner could
not have told us.
conformance.tsmatches declared diagnostics as a subset, so anextra
ERR_MISSING_FIELDis tolerated — none of them was failing. But a negativefixture that fails for two reasons is not a pin:
structural-011-schedule-on-servicewould have stayed red with the schedule rule deleted. Each now carries a
PRIVATEendpoint and fails only for its own rule. Seven component documents inside listing item
trees were silently non-conformant for the same reason and are fixed too.
3.
semantic-007-probe-on-endpointless-workloadbecame unwritable and is re-basedonto a
WORKER. Its premise is a probe on a workload declaring no endpoint, which isnow impossible as a
SERVICE. The other three kinds declare none and may still carry aprobe, so the
ERR_AMBIGUOUS_ENDPOINTit pins survives intact — and that is exactlywhy §5.2 still has to say what happens when nothing is elected.
Compatibility
One narrowing only: a
SERVICEdeclaring no endpoints, or an empty mapping.Decisions 1 and 4 run the other way and are strictly loosening or additive.
No new
v<N>directory, for the reason in ADR 0005 §1 — v1 has never beenpublished. That argument expires when #1/#2/#3 merge, so this should land first.
Corpus impact — verified, not assumed
minimal.yamlandstructural-001-minimal-validgain aPRIVATEendpoint;§5.4 compels a readiness probe only on a
PUBLICHTTP-family one, so "minimal"stays minimal.
SERVICEwith no endpoints, all deliberate: the twoparsercases, which fail beforestructuralis entered, andstructural-033, which exists to be this.ERR_DEPENDENCY_CYCLEappears nowhere in the corpus, prose, or tooling.Three coverage gaps closed in passing
Found while scoping #32, and worth fixing whatever it decided: no fixture exercised
PUBLIC_HOSTNAME(onlyPUBLIC_URLappeared anywhere), none exercised aplatformDefaultomitting its endpoint and relying on §5.2's election, and noneexercised a
PUBLICUDP endpoint.Checklist
task checkpasses locallyschemas/dist/regenerated withtask bundleand committed (never edited by hand)clausespec.mdgit commit -s)Each behavioural change lands as a deliberately red
test(...)commit followed by thecommit that turns it green, with the failing output quoted in the message — the
fail-before evidence CLAUDE.md non-negotiable 3 requires.
task checkgreen: 3 modules linted, 3 bundles match a fresh compile, 9 examplesvalidate, 130 conformance cases pass, 0 skipped (122 → 130).
Follow-ups
ADR 0005 records two platform defects to file: the merge conflict check, and the merge
sort key (
(ordering, componentId)names two fields this contract does not have).