Cross-references
A Member Set the Document Writes
Member sets are closed
lists chosen by the plugin: fill-rule is evenodd | nonzero and will
be for as long as the plugin lives. That works for vocabularies the
plugin knows in advance, and it is useless for the ones it cannot.
A track that plays phrases has to name the phrases, and the plugin cannot know their names, because you are about to invent them. So the legal values have to be discovered from the document being validated, and that is what a cross-reference is: a symbol whose member set is built by reading your own file.
member set legal values come from the PLUGIN fill-rule -> evenodd | nonzero
cross-reference legal values come from the DOCUMENT phrase-name -> every (phrase :name …) in scopeWhich means a cross-reference always has two sides, and you write both:
(phrase :name p0) ; declaration: introduces the name p0(track :sequence [p0]) ; reference: uses the name p0Two Passes, So Order Does Not Matter
Think of the validator as doing two passes:
pass 1 walk the document, collect every target form's name registry: { p0, p1 }
pass 2 check every symbol in a cross-reference slot against it (track :sequence [p0 p1]) both present -> cleanBecause the registry is complete before any reference is checked, a reference may name a form that appears later in the file:
(track :sequence [p0])(phrase :name p0)That validates. Forward references are not a special feature that had to be added; they fall out of building the registry first.
The surface value is still a plain symbol. You do not write $p0,
ref(p0), "p0", or :p0, unless a plugin’s docs explicitly say some
other value kind is expected.
Worked Example
You will not see the plugin DSL while authoring. You will see an author-facing summary like this:
(phrase ...) :name symbol required ; declares a phrase name :notes vector optional
(track ...) :sequence vector<phrase-name> required
phrase-name: symbol, cross-reference to (phrase :name ...)Read it line by line:
(phrase ...) :name symboltells you how names are declared.(track ...) :sequence vector<phrase-name>tells you where names are referenced.phrase-name: ... cross-reference to (phrase :name ...)connects the reference kind to the declaration form.
This source validates:
(phrase :name p0 :notes [E4 G4 A4 G4])(phrase :name p1 :notes [B4 A4 G4 E4])
(track :sequence [p0 p1])Read it as a registry:
declared phrase names: p0, p1track references: p0, p1Both references resolve.
This source does not validate:
(phrase :name p0 :notes [E4 G4 A4 G4])
(track :sequence [p0 p99])Registry:
declared phrase names: p0track references: p0, p99p99 is missing, so the likely diagnostic is not_cross_ref. Repair
by making the reference match a declaration:
(phrase :name p0 :notes [E4 G4 A4 G4])
(track :sequence [p0])Or repair by adding the missing declaration:
(phrase :name p0 :notes [E4 G4 A4 G4])(phrase :name p99 :notes [B4 A4 G4 E4])
(track :sequence [p0 p99])Names Are Symbols
Most cross-reference declarations use a symbol-valued name key:
(phrase :name p0)These are different values:
(phrase :name "p0") ; string(phrase :name :p0) ; keyword, and also a keyword-pairing problemIf the plugin says :name symbol, write a bare symbol. A quoted string
does not declare the same name. A keyword does not become a normal value
after :name; the pairing rule from
Forms and keyword pairing still
applies.
The same rule applies at the reference site:
(track :sequence [p0]) ; symbol reference(track :sequence ["p0"]) ; string, wrong shape(track :sequence [:p0]) ; keyword element, wrong shapeA Cross-Reference As Half Of A scalar-or-ref
This is how most authors first meet a cross-reference in practice. A
slot that takes “a number, or a constant naming one” is the
scalar-or-ref shorthand,
and its reference half can be a cross-reference kind:
(define :name MAX_BONES :value 128)
(mesh :bones 128) ; the literal half(mesh :bones MAX_BONES) ; the reference half, a cross-referenceThe value of doing it this way is what happens on a typo. Left as the
default, the reference half is a plain symbol and MAX_BONE
validates clean, naming nothing. Backed by a cross-reference kind, it
fails, reported as not_cross_ref, naming the form the symbol had to
be declared by. The shorthand is a union underneath, but a symbol can
only have meant its reference half, so the diagnostic is that half’s
own rather than the union’s list of alternatives.
Duplicate Declarations
A name should identify one target in its scope. If two declarations use the same name, the validator cannot choose which one the reference means.
Broken:
(phrase :name p0 :notes [E4 G4 A4 G4])(phrase :name p0 :notes [B4 A4 G4 E4])
(track :sequence [p0])Registry attempt:
p0 -> first phrasep0 -> second phrase ; duplicateLikely diagnostic: duplicate_cross_ref_target. Repair by renaming one
declaration:
(phrase :name p0 :notes [E4 G4 A4 G4])(phrase :name p1 :notes [B4 A4 G4 E4])
(track :sequence [p0])Then choose which one the track should reference:
(track :sequence [p0 p1])Duplicate checking happens inside the active scope. Scope, further down, explains what that means for references.
Two Targets, One Name
You just learned that two declarations sharing a name is an error. Here is the case where it is not, and why that turns out to matter.
Duplicate checking is per target. Each target form gets its own
namespace, so a (render-pipeline :name same) and a
(compute-pipeline :name same) are each alone in theirs. Neither is a
duplicate. Both are fine.
Now suppose a slot accepts either one, as a union of two reference kinds:
render-pipeline-ref: symbol, cross-ref to render-pipelinecompute-pipeline-ref: symbol, cross-ref to compute-pipelinepipeline-ref: union render-pipeline-ref | compute-pipeline-ref
(dispatch ...) :pipeline pipeline-refBefore reading on, predict what happens here:
(render-pipeline :name same)(compute-pipeline :name same)
(dispatch :pipeline same)Ask yourself two questions. Does it validate? And which pipeline is
same?
The answers are “yes” and “the render one”, but only because
render-pipeline-ref is listed first. That is
Order Is Part of the Contract
doing real work: alternatives are tried in declaration order, the first
that accepts wins, and here both accept. Reorder the plugin’s
alternatives and the same document means something different.
So it validates, with a warning:
union_ambiguous (warning)Read it as: this reference has two readings, and nothing but
declaration order is choosing between them. Your document is not
broken. What is fragile is that a tool resolving same through its own
lookup (an emitter, a code generator, an editor) may pick the compute
pipeline, and neither it nor the validator would ever notice they
disagreed.
Two repairs, and the right one depends on what you meant:
; Repair A: the collision was an accident. Rename one.(render-pipeline :name blit)(compute-pipeline :name reduce)
(dispatch :pipeline blit); Repair B: both names are deliberate; the SLOT was overloaded.; Ask the plugin author for two keys, one reference kind each.(dispatch :render-pipeline same); Repair C: the names were never meant to coexist. Ask the plugin author; for ONE reference kind over BOTH forms:;; pipeline-ref: symbol, cross-ref to [render-pipeline compute-pipeline];; Then this document does not warn. It fails, at the declarations:
(render-pipeline :name same)(compute-pipeline :name same) ; duplicate_cross_ref_targetRepair A is right most of the time. Reach for B when the two names are genuinely the same concept in two pipelines and renaming would be a lie. Repair C is the one to ask for when they are never meant to be the same name, which the next heading unpicks.
One Namespace Or Two
Repairs B and C look similar and are opposites. Both change the schema; what they change is how many namespaces exist.
A union of two reference kinds is two namespaces. same in each is
two different names that happen to be spelled alike, so declaring both is
fine and every reference is the ambiguous thing.
A target group, meaning one cross-reference over a list of forms, is one
namespace. same declared twice is one name declared twice, so the
declaration is the wrong thing and no reference is ever ambiguous.
That is the whole distinction, and it is a question about your data, not
about SJON: are a render pipeline and a compute pipeline allowed to share
a name? If yes, the union is right and union_ambiguous is a fair warning
about a genuinely overloaded slot. If no, the group is right and you want
to hear about the collision once, where you made it.
You cannot pick between them as a document author, because :target is the
plugin’s to write. What you can do is read the diagnostic and know which
one you are inside: a warning on a reference means two namespaces, an
error on a declaration means one.
When It Stays Quiet
This warning is deliberately narrow, so do not expect it whenever a union overlaps:
- A union over plain values. “A byte count or a named constant” overlaps by design, and first-match is the point. Nothing there names two entities, so nothing warns.
- A member set that wins first. If the alternative that accepts is an enum rather than a reference, the slot denotes a member, not an entity, so there is no “which one” to answer.
- Two alternatives onto the same target. Both readings pick out the same declaration, so order decides nothing.
- A target group. One namespace has nothing to be ambiguous between; the collision it would warn about is an error at the declarations instead.
The one case it fires on is the one where order silently picks between two different things. Everything else is the union working as designed.
Scope
When a plugin declares a cross-reference, it also defines where the validator should look for names. The authoring question is:
Which declarations are visible from this reference?You will usually encounter two practical cases.
Tree Scope
Tree scope is the default authoring model: references resolve against names declared in the same parsed tree, usually one source document or file.
(phrase :name p0)(track :sequence [p0])This is the easiest case. If a reference fails, first look in the same document for a declaration with the exact same symbol spelling.
When a host validates several roots as a forest, tooling may build one index for all of them, but ordinary cross-reference checks are still scoped by the rules the plugin and host document. Do not assume a name in another file is visible just because both files are open. For split-file authoring, check the host’s plugin docs.
Lexical Scope
A plugin can make a cross-reference local to the nearest enclosing form. The docs might say:
phrase-name: cross-reference to (phrase :name ...), scope pieceRead that as: a (piece ...) form opens a local registry. References
inside a piece can only see phrase names declared inside that same
piece.
This validates:
(piece (phrase :name p0) (phrase :name p1) (track :sequence [p0 p1]))
(piece (phrase :name p0) (track :sequence [p0]))The two p0 declarations do not collide because they live in different
piece scopes.
This does not validate:
(phrase :name p0)(track :sequence [p0])If phrase-name is scoped to piece, the reference appears outside
any enclosing (piece ...). Likely diagnostic:
cross_ref_outside_scope. Repair by moving the declaration and
reference into the same scope:
(piece (phrase :name p0) (track :sequence [p0]))Another common scoped mistake is referencing a name from a sibling scope:
(piece (phrase :name p0))
(piece (track :sequence [p0]))The second piece has no local p0. Likely diagnostic:
not_cross_ref. Repair by declaring p0 in the same piece or moving
the track into the piece where p0 is declared.
Acyclic References
Some cross-references describe parent chains or dependency chains. In those cases, the plugin may say the reference must be acyclic.
Example contract:
(phrase ...) :name symbol required :parent phrase-name optional
phrase-name: cross-reference to (phrase :name ...), acyclicThis chain is fine:
(phrase :name p0 :parent p1)(phrase :name p1 :parent p2)(phrase :name p2)Read the edges:
p0 -> p1p1 -> p2p2 -> nothingThere is no loop.
This chain is not fine:
(phrase :name p0 :parent p1)(phrase :name p1 :parent p0)Edges:
p0 -> p1p1 -> p0The names form a cycle, so the likely diagnostic is
cyclic_cross_ref. Repair by breaking the loop:
(phrase :name p0 :parent p1)(phrase :name p1)Most cross-references are not acyclic. This rule only matters when the plugin docs explicitly say the kind or key participates in acyclic checking.
Names Inside A String
Everything above assumes the name is written in your document as a
symbol: (phrase :name p0) puts p0 where the validator can read it.
Plenty of names are not written that way. The uniforms of a shader are
inside the shader source. The columns of a table are inside the DDL.
The capture groups of a regex are inside the regex.
A plugin can still make those names referenceable, by declaring a provider: a named extractor that reads one string and reports the names in it. The author-facing summary says so:
(shader ...) :name symbol required :code string required ; the provider reads this
(bind ...) :uniform uniform-ref optional
uniforms: provider, names out of a shader sourceuniform-ref: symbol, cross-reference to (shader ...), provider uniforms, source key codeRead the last two lines together: the target form is still (shader ...), but the member set no longer comes from a name key on that form.
It comes from running uniforms over each shader’s :code string.
This validates:
(shader :name blur :code """uniform float u_time;uniform vec2 u_resolution;""")
(bind :uniform u_time)(bind :uniform u_resolution)Registry:
shader names: (unused; this route ignores :name)extracted names: u_time, u_resolutionbind references: u_time, u_resolutionNote what is not in that registry. blur is a shader name, not a
uniform name, and :name plays no part on this route. The legal values
are exactly what the provider found.
This does not validate:
(shader :name blur :code """uniform float u_time;""")
(bind :uniform u_tim)Likely diagnostic: not_cross_ref, the same code as any other missed
reference, because from the reference side nothing has changed. Repair
the same way, by matching a name that exists:
(bind :uniform u_time)Your side of the deal is unchanged: write a symbol, and it either is a member or it is not. What changed is where the member set came from.
The Provider Only Sees Your String
This is the rule worth memorising, because it is what keeps validation predictable:
A provider is handed the string in your document,and nothing else.Not the rest of your document. Not the schema. Not your filesystem, the
network, or the clock. So a provider can tell you that u_time is
declared inside the source you wrote; it can never tell you that a file
exists on disk or that a column exists in a live database. Those are
facts about the world, not about your document, and validating them
would mean two hosts could disagree about the same file.
The practical consequence: if a name is not in the string, no configuration will make the reference resolve. Add it to the source.
Two Diagnostics You Only See On This Route
(shader :name blur :code "not a shader {{{")(bind :uniform u_time)Likely diagnostic: cross_ref_extraction_failed, reported on the
:code string, not on the reference. The provider ran and rejected
its input, so nobody knows what the legal uniform names were. Repair by
fixing the source string the provider could not read.
Notice where the diagnostic did not appear. (bind :uniform u_time)
is silent, on purpose. Reporting not_cross_ref there would mean
claiming u_time is absent from a member set that was never computed.
On this route the references go unchecked when the extraction fails,
neither accepted nor rejected.
The other one is not about your document at all:
cross_ref_provider_unavailableIt means the host you are running could not execute the provider: the browser playground, for instance, cannot run plugin code at all. Same place (the source string), same silence at the references, but the repair is on the host side, not in your file. In an editor it usually arrives as a hint rather than an error, because nothing you wrote is wrong. If you see it in a build that is supposed to check these names, check that the plugin’s executable half is actually installed.
Go-To-Definition Lands On The Whole String
An extracted name has no span of its own. SJON never parsed that shader
source; it received a list of names. So “go to definition” on u_time
takes you to the :code string that produced it, not to the line inside
it. That is a limit of the route, not a bug in the editor.
Diagnostic Cheat Sheet
| Diagnostic | What it usually means | Repair |
|---|---|---|
not_cross_ref | The symbol is not in the visible registry. | Fix the spelling, add the declaration, or move the reference into the right scope. |
duplicate_cross_ref_target | Two declarations use the same name in one scope. | Rename or remove one declaration. |
union_ambiguous (warning) | A name exists in two of a union slot’s reference targets, so declaration order is picking which one. | Rename one declaration, or ask for the slot to be split into two single-kind keys. |
cross_ref_outside_scope | A scoped reference appears outside its required enclosing form. | Put the declaration and reference inside that scope form. |
cyclic_cross_ref | An acyclic reference chain loops back on itself. | Remove or change one edge in the cycle. |
wrong_underlying | The declaration or reference is not the expected value shape, often string vs symbol. | Match the plugin’s declared type. |
cross_ref_extraction_failed | A provider ran over a source string and rejected it, so that source contributed no names. | Fix the string the diagnostic points at; the references into it stay unchecked until it parses. |
cross_ref_provider_unavailable | The host could not run the provider at all. | Nothing in the document is wrong. Check the host, or accept that these names go unchecked here. |
You may also see schema setup diagnostics such as
unknown_cross_ref_target, ambiguous_cross_ref_target,
cross_ref_name_key_unknown, unknown_cross_ref_scope, or
ambiguous_cross_ref_scope. Those usually mean the plugin or manifest
is misconfigured, not that an ordinary document reference is misspelled.
Repair Workflow
When a cross-reference fails, do this mechanically:
- Find the reference slot in the diagnostic path.
- Read the slot’s value kind in the plugin docs.
- Find the target form and name key, such as
(phrase :name ...). - List the declarations visible from the reference’s scope.
- Check exact symbol spelling and case.
- If the name exists twice, rename one declaration.
- If the slot is a union of reference kinds, check every target for the name, not just the one you had in mind.
- If the kind is acyclic, draw the arrows and remove the loop.
- If the kind names a provider, the list in step 4 is what the provider extracted from the source string, so read the source instead of looking for declarations.
Exercises
Predict the diagnostic, then repair.
Unknown Reference
Assume only p0 and p1 are declared:
(phrase :name p0 :notes [E4 G4 A4 G4])(phrase :name p1 :notes [B4 A4 G4 E4])
(track :sequence [p0 p99])Likely diagnostic: not_cross_ref. Repair by spelling the reference
correctly:
(phrase :name p0 :notes [E4 G4 A4 G4])(phrase :name p1 :notes [B4 A4 G4 E4])
(track :sequence [p0 p1])Or by adding the missing target:
(phrase :name p0 :notes [E4 G4 A4 G4])(phrase :name p1 :notes [B4 A4 G4 E4])(phrase :name p99 :notes [C5 B4 A4 G4])
(track :sequence [p0 p99])Forward Reference
(track :sequence [p0])(phrase :name p0)This should validate. The reference appears first, but the registry is built from the whole document before references are checked.
String Instead Of Symbol
Assume :name expects symbol:
(phrase :name "p0")(track :sequence [p0])Likely diagnostics: the declaration has the wrong underlying shape, and
the reference may also fail because no symbol name p0 was registered.
Repair the declaration:
(phrase :name p0)(track :sequence [p0])Duplicate Target
(phrase :name p0 :notes [E4 G4 A4 G4])(phrase :name p0 :notes [B4 A4 G4 E4])
(track :sequence [p0])Likely diagnostic: duplicate_cross_ref_target. Repair by renaming one
declaration:
(phrase :name p0 :notes [E4 G4 A4 G4])(phrase :name p1 :notes [B4 A4 G4 E4])
(track :sequence [p0])Same Name, Two Targets
Slot :pipeline is typed pipeline-ref, a union of
render-pipeline-ref | compute-pipeline-ref in that order.
(render-pipeline :name same)(compute-pipeline :name same)
(dispatch :pipeline same)Two questions before you answer: does this validate, and which pipeline
does same mean?
It validates. same is the render pipeline, because that alternative
is listed first. Neither declaration is a duplicate, because duplicate checking
is per target, and these are two targets. Likely diagnostic:
union_ambiguous, at warning severity. Repair by renaming so the two
targets do not collide:
(render-pipeline :name blit)(compute-pipeline :name reduce)
(dispatch :pipeline blit)Overlapping Union, No Warning
Slot :size is typed byte-count | symbol.
(buffer :size 1024)(buffer :size default-size)Both lines match a union whose alternatives overlap on shape, so is this the ambiguity case?
No: clean, no diagnostic. 1024 matches byte-count and
default-size matches the symbol half, and neither value names two
different entities. union_ambiguous is about two references to two
different things colliding on one name, not about a union having
alternatives that could both accept.
Outside Lexical Scope
Assume phrase-name is scoped to piece:
(phrase :name p0)(track :sequence [p0])Likely diagnostic: cross_ref_outside_scope. Repair by adding the
scope form:
(piece (phrase :name p0) (track :sequence [p0]))Sibling Lexical Scope
Assume phrase-name is scoped to piece:
(piece (phrase :name p0))
(piece (track :sequence [p0]))Likely diagnostic: not_cross_ref. The second piece has no visible
p0. Repair by moving the track or declaring the phrase in the same
piece:
(piece (phrase :name p0) (track :sequence [p0]))Cycle
Assume the plugin opts the :parent key into acyclic detection:
(phrase :name p0 :parent p1)(phrase :name p1 :parent p0)Likely diagnostic: cyclic_cross_ref. Repair by breaking the loop:
(phrase :name p0 :parent p1)(phrase :name p1)Provider-Backed Reference
Assume uniform-ref reads its names from a (shader ...)’s :code
string through the uniforms provider:
(shader :name blur :code """uniform float u_time;""")
(bind :uniform u_resolution)Likely diagnostic: not_cross_ref. The source declares one uniform and
it is not that one. Repair by referencing a name the source contains:
(bind :uniform u_time)Or by adding it to the source, which is where this route’s declarations live:
(shader :name blur :code """uniform float u_time;uniform vec2 u_resolution;""")
(bind :uniform u_resolution)Unreadable Provider Source
(shader :name blur :code "not a shader {{{")
(bind :uniform u_time)Likely diagnostic: cross_ref_extraction_failed, on the :code string.
Predict the second diagnostic too. There isn’t one: (bind :uniform u_time) is not reported, because the member set was never computed.
Repair the source, and the reference becomes checkable again.
Mastery Check
-
Two forms of different kinds both declare
:name same. Is that aduplicate_cross_ref_target? -
A slot reports
duplicate_cross_ref_targetacross two different forms. What does that tell you about the schema? -
You get
union_ambiguouson a reference. What would a target group have done with the same document? -
A union slot accepts either of two reference kinds, and a name exists in both targets. What happens?
-
Why is
union_ambiguousa warning rather than an error? -
A slot is typed "a byte count or a named constant" and you write
1024. Does that warn as ambiguous? -
What are the two sides of a cross-reference?
-
Why can a reference appear before the form it names?
-
Why does a typo on a phrase reference produce
not_cross_refinstead ofnot_member? -
Why is
"p0"not the same declaration asp0? -
What is the first repair to try for
cross_ref_outside_scope? -
Where does a provider-backed kind get its legal names from?
-
A provider rejects a shader source, and
cross_ref_extraction_failedfires on that string. Why is the reference to a uniform not also reported?