Skip to content

feat: reconcile the specification/platform divergences, and expose the edge address - #41

Merged
justinmerrell merged 10 commits into
mainfrom
spec/reconcile-divergences-issues-24-32
Aug 17, 2026
Merged

feat: reconcile the specification/platform divergences, and expose the edge address#41
justinmerrell merged 10 commits into
mainfrom
spec/reconcile-divergences-issues-24-32

Conversation

@justinmerrell

Copy link
Copy Markdown
Contributor

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.

# Subject Decision Direction
1 Cycle detection (blueprint §4.2) Adopt the platform — cycles permitted, ERR_DEPENDENCY_CYCLE withdrawn loosening
2 Parameter merge (blueprint §5.2) Hold ERR_CONFLICTING_INPUT_SCHEMA unchanged
3 SERVICE endpoints (component §5) Adopt the platform — at least one required breaking
4 Edge address (#32) PUBLIC_ADDRESS and PUBLIC_PORT added additive

After 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 -l is empty, .github/release-please/manifest.json reads 0.0.0 for all
three 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 v2 directory 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 every implementation to be a two-pass resolver in perpetuity.

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 schema blocks are equal once defaults are
applied". tools/src/semantic.ts compared JSON.stringify(schema) — key-order
sensitive, no defaults applied — so it rejected two declarations the prose calls the
same. semantic-022 varies ui, isRequired, key order and written-out defaults;
it was red on arrival and canonicalValueSchema fixes it.

3. A SERVICE must declare an endpoint (ADR 0005 §4) — BREAKING

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. 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 is
ERR_INVALID_VALUE, matching the two-spelling distinction §5 already makes in the
forbidden direction.

4. The edge address is readable (ADR 0005 §5)

platformDefault.source gains PUBLIC_ADDRESS (host:port) and PUBLIC_PORT (the
allocated port alone). The four sources now pair exclusively with §5.2's two address
forms, with ERR_ENDPOINT_NOT_L4 as the mirror of ERR_ENDPOINT_NOT_HTTP — two codes
so a diagnostic names the axis that failed.

Admitting an HTTP-family endpoint to PUBLIC_ADDRESS was 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 is
rejected 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.yaml and structural-001-minimal-valid. The sweep found 30
documents declaring a SERVICE with no endpoints.

2. Seventeen negative fixtures stopped pinning their own rule, and the runner could
not have told us.
conformance.ts matches declared diagnostics as a subset, so an
extra ERR_MISSING_FIELD is tolerated — none of them was failing. But a negative
fixture that fails for two reasons is not a pin: structural-011-schedule-on-service
would have stayed red with the schedule rule deleted. Each now carries a PRIVATE
endpoint 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-workload became unwritable and is re-based
onto a WORKER.
Its premise is a probe on a workload declaring no endpoint, which is
now impossible as a SERVICE. The other three kinds declare none and may still carry a
probe, so the ERR_AMBIGUOUS_ENDPOINT it pins survives intact — and that is exactly
why §5.2 still has to say what happens when nothing is elected.

Compatibility

  • Breaking — a previously valid document now fails.

One narrowing only: a SERVICE declaring 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 been
published. That argument expires when #1/#2/#3 merge, so this should land first.

Corpus impact — verified, not assumed

  • minimal.yaml and structural-001-minimal-valid gain a PRIVATE endpoint;
    §5.4 compels a readiness probe only on a PUBLIC HTTP-family one, so "minimal"
    stays minimal.
  • Three documents still declare a SERVICE with no endpoints, all deliberate: the two
    parser cases, which fail before structural is entered, and
    structural-033, which exists to be this.
  • ERR_DEPENDENCY_CYCLE appears 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 (only PUBLIC_URL appeared anywhere), none exercised a
platformDefault omitting its endpoint and relying on §5.2's election, and none
exercised a PUBLIC UDP endpoint.

Checklist

  • task check passes locally
  • schemas/dist/ regenerated with task bundle and committed (never edited by hand)
  • Conformance fixtures added for every behavioural change, each citing a clause
  • Normative prose updated in the affected spec.md
  • Commit messages are Conventional and correctly scoped
  • Commits are DCO signed off (git commit -s)

Each behavioural change lands as a deliberately red test(...) commit followed by the
commit that turns it green, with the failing output quoted in the message — the
fail-before evidence CLAUDE.md non-negotiable 3 requires.

task check green: 3 modules linted, 3 bundles match a fresh compile, 9 examples
validate, 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).

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>
@justinmerrell
justinmerrell merged commit af2dec0 into main Aug 17, 2026
4 checks passed
@justinmerrell
justinmerrell deleted the spec/reconcile-divergences-issues-24-32 branch August 17, 2026 03:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant