sprig and prose docs classic docs
Reference› ADRs› ADR024 RelatesTargetsInPair

ADR024 RelatesTargetsInPair

Title: Relates targets must be in the pair

  • Status: Accepted
  • Decided: D2026-09-11
  • Revises: ADR007 RelatesEndpointDimensionPairs
  • Introduces: SP026 TargetNotInPairError
  • Affects: Parser, Relates
  • Pairings:
    • ADR007 RelatesEndpointDimensionPairs — the neither-endpoint fallback

Context

007 settled how a connection written inside a relates block binds. Its ruling is two sentences long and it is the right one:

The endpoint that is not to is the subject. to is the target.

That is what makes relates A and B and relates B and A mean the same thing. The block never names a subject, and it does not need to, because naming either endpoint as the target determines the other by elimination. The same record says so plainly: clause order does not pick the subject.

It then added a third case:

A to that names neither endpoint treats left as subject so the target dimension is still consulted.

That clause was reached for a resolution reason. If the target is a stranger, something still has to be the subject or the edge word cannot be looked up, and left was available. It reads as a decision about keeping the resolver total.

Its effect is that a stranger compiles. Found while modelling a television show:

relates Carl and ApartmentForRent {
  describe {
    It is strongly implied in the show that Carl lived in the apartment that
    was for rent as of season 2, episode 11.
  }

  relationships {
    appearsIn { S2E11 }
    isHeldBy { Mayor }
  }
}

Neither S2E11 nor Mayor is Carl or the apartment. It compiles with no diagnostics, and the manifest records this:

{ "edge": "isHeldBy",
  "relationship": "SchittsCreek.role.hasRole::isHeldBy",
  "to": "SchittsCreek.Mayor" }

There is no subject on it. For a well-formed relates none is needed — a reader derives it as the endpoint that is not to. For a stranger it is not derivable at all. 007 says the subject is Carl; nothing in the artifact says Carl, and no consumer of the manifest can find out. Read back as a sentence it is Carl-and-ApartmentForRent isHeldBy Mayor, which is about nothing.

Three problems, in increasing order of seriousness.

The clause contradicts the bullet two lines above it. Order does not pick the subject when the target is an endpoint, and does pick it when the target is a stranger. One record, two rules, and the difference is invisible at the call site.

The manifest cannot carry the result. The language permits writing a fact the artifact has no field for. Whatever the resolver decided is lost on the way out.

The better home already exists. A fact about one of the two belongs in that thing's own relationships block, where the subject is the node containing it and is therefore structurally present. Nothing is lost by refusing it here.

Decision

Every target inside a relates block must resolve to one of the two paired endpoints. A target that names neither is SP026 (TargetNotInPairError, kind target-not-in-pair).

The comparison is on resolved identity, not on spelling, so the village's produces { ClockworkVillage.Egg } inside relates Chicken and Egg continues to bind exactly as before.

007's ruling is otherwise untouched. The endpoint that is not the target is still the subject, clause order still does not pick it, endpoints may still be dimensions, and conflicting inverses are still SP023. Only the third case is removed, and with it the last branch of relatesSubjectEndpoint that returned left for want of anything better.

A relates edge now records its subject. Each entry in a relates relationships array carries a subject alongside edge, relationship and to. With strangers refused the subject is always derivable, so this is redundant on purpose: it means a consumer reads the direction rather than reimplementing a rule from an ADR.

Consequences

A minor for prose-parser, and one for the CLI, by 021. An error appears where none did, which is squarely the surprise test, and the manifest gains a field, which is output changing for unchanged input. The CLI prints the diagnostic and ships the manifest, so it takes its own entry.

Some universes stop compiling, and that is the point. Every one of them was recording an edge whose direction the manifest never captured. 021 anticipates exactly this shape of change and asks that the changeset body say so.

One legitimate want is refused along with the nonsense. The two lines in the example are not the same mistake. isHeldBy { Mayor } is a fact about Carl, misfiled, and it has somewhere better to go. appearsIn { S2E11 } is a fact about the pairing — this connection was true as of that episode — and it has nowhere to go at all. That is axis 2 of 018, which names it precisely: "a concept or an edge is true across part of an ordering."

So the accidental fallback was the only way to write a thing the language has an open proposal about. Refusing it is still right, because what it recorded was uninterpretable. But the refusal should not be read as deciding against the want, and SP026's description says as much.

A small piece of evidence for 018. The want surfaced unprompted, in a universe with no code in it, from somebody watching television and writing down what they saw. 018 already cites a Schitt's Creek modelling exercise as one of the two cases that reframed the temporal question; this is a third instance of the same pressure, arriving without anyone looking for it.

One degenerate case is not addressed. If both endpoints resolve to the same identity, relates A and A, then "the endpoint that is not the target" is undefined and this record does not say what happens. It is left alone because it has not come up, and because it is a question about whether that form should exist at all rather than about which subject it picks.

Alternatives considered

Keep the fallback and record left as the subject explicitly. The smallest change, and it fixes the unreadable-artifact problem without refusing anything. Rejected because it ratifies order-picks-the-subject, which 007 rules against in the case it actually thought about. It would leave the language with two directional rules and no way to tell at a glance which applies.

Let a relates edge mean a fact about the pairing, with the pairing as the subject. Genuinely tempting, because it is what appearsIn { S2E11 } wants, and the relates node has an identity of its own that could serve as a subject. Rejected as premature. It would make a pairing a thing that can be a subject, which is a real extension to what prose considers an entity, and 018 is the record where that argument belongs. Deciding it here, as a side effect of fixing a fallback, is how a language acquires a feature nobody designed.

Require the subject to be named in the block. Rejected. It is more syntax for something elimination already answers, and it would make the common two-endpoint case wordier to serve a case being removed.

Warn rather than error. Rejected. The output is uninterpretable, not merely questionable, and a warning would leave the manifest carrying edges with no recoverable direction.

Evidence

The fallback is one line. relatesSubjectEndpoint in validator.js:

if (toScopeId === left.scopeId) return right;
if (toScopeId === right.scopeId) return left;
return left;

The third return is the whole of the behaviour this record removes.

That it resolved something is what kept it quiet. isHeldBy is declared on dimension role; Carl is a character. Treating left as subject found nothing on character, fell through to the target's dimension, and bound against role — which is exactly what 007's clause intended. The mechanism worked as designed. What it produced was an edge with no author.