Connections

Most of a document is a tree. A connection is the language's answer to the part that is not: an edge from one block to another, declared once as a schema and written as an arrow. web -> db :uses is a whole item — the thirteenth of the thirteen item forms — and a field declared @connections(DependsOn) reads every such arrow back as a list of records.

This chapter covers the declaration, the statement, the field that projects them, how an arrow's two operands resolve to blocks, how a statement finds its schema, and what wcl check says when any of that fails. It ends with the comparison that matters: connections are one of three ways a document points at another block, and the other two are @ref and &T.

Every file and every command below was run before it was written down. Type them and compare.

§ 1A graph in one file

Save this as services.wcl:

services.wclwcl
# services.wcl — the service graph.

symbol_set EdgeKind { uses  depends_on }

@block("service")
type Service {
  @inline(0) name: identifier
  port: u32
}

connection DependsOn: Service -> Service : EdgeKind

@document
type Cfg {
  @children("service")    services: list<Service>
  @connections(DependsOn) edges:    list<DependsOn>
}

service web   { port = 8080u32 }
service api   { port = 9000u32 }
service db    { port = 5432u32 }
service cache { port = 6379u32 }

web -> api
api -> db    :depends_on
api -> cache :uses

Four blocks, three arrows, and one field that turns the arrows into data:

text
$ wcl check services.wcl
OK

$ wcl get services.wcl edges
[DependsOn { destination: api, kind: :uses, source: web }, DependsOn { destination: db, kind: :depends_on, source: api }, DependsOn { destination: cache, kind: :uses, source: api }]

Read the shape off that output. Every arrow became a record with exactly three slots — source, destination, kind — and nothing else. web -> api carried no symbol, so it got :uses, the first member of EdgeKind. A host reads the same list as JSON:

text
$ wcl get services.wcl edges --json
[
  {
    "destination": "api",
    "kind": "uses",
    "source": "web"
  },
  {
    "destination": "db",
    "kind": "depends_on",
    "source": "api"
  },
  {
    "destination": "cache",
    "kind": "uses",
    "source": "api"
  }
]

Three items did that work, and the rest of this chapter is those three in turn: the connection declaration that describes the edge, the -> statements that write them, and the @connections field that reads them back.

§ 2Declaring a connection

A connection declaration names a relationship. It sits at file scope beside your types, and it has exactly four parts:

wcl
connection DependsOn : Service -> Service : EdgeKind
#          ────┬────   ───┬───    ───┬───   ───┬────
#              │          │          │         └─ kind vocabulary: a symbol_set
#              │          │          └─ destination endpoint type
#              │          └─ source endpoint type
#              └─ the schema's name

All four are required. There is no shorthand that drops the kind set — connection DependsOn: Service -> Service fails with expected ':' before connection kind symbol_set. If a relationship really has one flavour, declare a one-member set and let every statement default to it.

The name is a declaration name like any other: it may be dotted, and it lives in the file's namespace. See Namespaces and imports.

The kind position must resolve to a symbol_set, and both ways of getting that wrong are caught at the declaration rather than at the first arrow. An undeclared name reports unknown symbol_set 'Nope' in connection 'DependsOn'; a name that is declared, but as something else, reports kind 'EdgeKind' in connection 'DependsOn' must be a symbol_set.

The two endpoint positions are type references, not block kinds. Service here is the type carrying @block("service"), not the string "service". That distinction is the whole of how a statement finds its schema below, because a type reference can be polymorphic and a kind string cannot.

A declaration may carry decorators. Only one is meaningful today: @dynamic. Whichever way you space the first colon, wcl fmt prints the canonical form:

text
$ cat tight.wcl
connection DependsOn: Service -> Service : EdgeKind

$ wcl fmt tight.wcl
connection DependsOn : Service -> Service : EdgeKind

§ 3Connection statements

A statement is two identifiers, an arrow, and an optional symbol:

wcl
web -> api                # kind defaults to the first symbol_set member
api -> db    :depends_on  # explicit kind
api -> cache : uses       # the space is allowed; `wcl fmt` closes it up

That is the entire grammar. A statement has no label, no body and no fields of its own. The kind symbol is the only payload it carries, which is why the vocabulary is worth designing: wdoc's own edge kinds include yes and no precisely so that a decision branch in a flowchart can name its answer through the kind.

The two operands are bare identifiers. They are not paths — east.db -> web does not reach into east, it fails to parse with expected '.' or '::' in qualified block kind, found '->'. They are not expressions either: no call, no interpolation, no reference to a let. What an operand names is a block, and the next section is the rule for which one.

Statements sit wherever items sit: at the document's top level, inside a block, or in an imported file that gets spliced in. They are checked wherever they sit, whether or not any field reads them. A document with a connection declaration and a stray arrow that nothing projects still validates the arrow.

§ 4The @connections field

A statement becomes data only when a field asks for it. @connections(SchemaName) on a list<SchemaName> field is that request:

wcl
@document
type Cfg {
  @children("service")    services: list<Service>
  @connections(DependsOn) edges:    list<DependsOn>
}

The field is projected, not written. You never author edges = [...]; the value is synthesised from the sibling statements each time it is forced. Every record has the same three slots:

SlotHolds
sourceThe left operand's identifying value
destinationThe right operand's identifying value
kindThe statement's symbol, or the kind set's first member

"Sibling" is the load-bearing word. A @connections field gathers the statements written at its own level — the ones beside it in the same block, or the top-level ones for a field on the @document type. It does not sweep the whole tree. Declare the field on a @block type and every instance projects its own arrows:

regions.wclwcl
symbol_set EdgeKind { uses }

@block("service")
type Service { @inline(0) name: identifier }

connection DependsOn: Service -> Service : EdgeKind

@block("region")
type Region {
  @inline(0) name: identifier
  @children("service")    services: list<Service>
  @connections(DependsOn) edges:    list<DependsOn>
}

@document
type Cfg { @children("region") regions: list<Region> }

region east {
  service web
  service db
  web -> db
}

region west {
  service web
  service cache
  web -> cache
}
text
$ wcl check regions.wcl
OK

$ wcl get regions.wcl regions.east.edges
[DependsOn { destination: db, kind: :uses, source: web }]

$ wcl get regions.wcl regions.west.edges
[DependsOn { destination: cache, kind: :uses, source: web }]

Two blocks each declare a web, and neither region's edge list mentions the other's. That is scoping doing its job, and it is the subject of the next section.

One schema, many fields

Nothing stops two fields from naming the same connection schema, at different levels or in different types. The wdoc standard library does exactly that: diagram and container both declare @connections(Edge) edges: list<Edge>, so a nested container gathers its own child edges while the diagram gathers the top-level ones. The declaration says what an edge is; each field says whose edges it collects.

§ 5How an operand resolves

An operand is an identifier, and resolving it means finding the block that answers to that name. The rule is two steps and a tie-break.

The scope step is why the two web blocks in regions.wcl stayed apart: each statement found its own region's service before the document index was ever consulted. The document step is why an arrow at the top level may reach into a block:

deep.wclwcl
symbol_set EdgeKind { uses }

@block("service") type Service { @inline(0) name: identifier }
@block("region")  type Region {
  @inline(0) name: identifier
  @children("service") services: list<Service>
}

connection DependsOn: Service -> Service : EdgeKind

@document
type Cfg {
  @children("region")     regions:  list<Region>
  @children("service")    services: list<Service>
  @connections(DependsOn) edges:    list<DependsOn>
}

service gateway
region east { service db }

gateway -> db          # `db` lives inside `east`, and the arrow still finds it
text
$ wcl check deep.wcl
OK

$ wcl get deep.wcl edges
[DependsOn { destination: db, kind: :uses, source: gateway }]

The document index is first-match, and it is flat

Two blocks in different parts of the tree may answer to one name, and the document-wide fallback keeps only the first it walks into. gateway -> db reaches east.db because that is the db the walk met first — there is no path syntax to say which one you meant, and no error when there is more than one. Scope is the tool for that: write the arrow inside the block whose db you mean, and the innermost-out search settles it before the index is asked.

§ 6How a statement finds its schema

A statement names no schema. web -> db says nothing about DependsOn. The schema is found by dispatch: both operands are resolved to blocks, each block's type is read, and every connection declaration in the document is asked whether it accepts that pair of types. Exactly one must say yes.

"Accepts" is not type equality. An endpoint position admits three things:

Endpoint written asAccepts an operand whose block type isMatch is
ServiceService, or any type that extends itNominal
&NodeAny type carrying the interface Node's fieldsStructural
Node (a union)Any type one of Node's variants admitsEither, per variant

The interface form is the one that scales. Declare the endpoint as &Node and a single connection spans every kind of block that looks like a node — which is how one declaration covers a whole diagram vocabulary:

poly.wclwcl
# poly.wcl — one connection spanning every kind of node.

symbol_set RelKind { calls  reads }

interface Node { name: identifier }

@block("service") type Service { @inline(0) name: identifier  port: u32 }
@block("queue")   type Queue   { @inline(0) name: identifier }
@block("store")   type Store   { @inline(0) name: identifier  engine: utf8 }

connection Rel: &Node -> &Node : RelKind

@document
type Cfg {
  @children("service") services: list<Service>
  @children("queue")   queues:   list<Queue>
  @children("store")   stores:   list<Store>
  @connections(Rel)    rels:     list<Rel>
}

service web { port = 8080u32 }
queue jobs
store main { engine = "postgres" }

web  -> jobs
web  -> main :reads
jobs -> main :reads
text
$ wcl check poly.wcl
OK

$ wcl get poly.wcl rels
[Rel { destination: jobs, kind: :calls, source: web }, Rel { destination: main, kind: :reads, source: web }, Rel { destination: main, kind: :reads, source: jobs }]

Three block types, three arrows, one declaration. Add a fourth block type with a name: identifier field and it joins the graph with no edit to Rel. Add one without that field and every arrow touching it is refused — which is the point of writing the interface rather than a union of the three names you happen to have today.

Types covers interfaces, unions and extends in full, including the trap that extends on a @block type does not let an instance write the inherited fields. A subtype endpoint dispatches; its instances still have to redeclare what they write.

§ 7What check reports

Four things can go wrong with a statement, and wcl check names each at the span that caused it. The four transcripts below are services.wcl from the top of this chapter, broken four ways.

§ 7.1An operand that names no block

Change web -> api on line 24 to web -> worker:

text
$ wcl check services.wcl
wcl::eval::schema_violation

  × connection destination 'worker' does not name a block in scope
    ╭─[services.wcl:24:8]
 23 │
 24 │ web -> worker
    ·        ───┬──
    ·           ╰── schema violation
 25 │ api -> db    :depends_on
    ╰────

services.wcl: 1 schema violation

This is the dangling reference of the connection world, and the message tells the two ends apart: an unresolvable left operand reports connection source '…', a right one connection destination '…'. When both are broken, the source is reported first.

§ 7.2No schema accepts the pair

Both operands resolve, but no connection declaration admits their types. Declare a queue kind that no connection mentions, then point a service at it:

queues.wclwcl
# queues.wcl — a block kind no connection mentions.

symbol_set EdgeKind { uses }

@block("service") type Service { @inline(0) name: identifier }
@block("queue")   type Queue   { @inline(0) name: identifier }

connection DependsOn: Service -> Service : EdgeKind

@document
type Cfg {
  @children("service")    services: list<Service>
  @children("queue")      queues:   list<Queue>
  @connections(DependsOn) edges:    list<DependsOn>
}

service web
queue jobs

web -> jobs
text
$ wcl check queues.wcl
wcl::eval::schema_violation

  × no connection schema accepts 'Service -> Queue'
    ╭─[queues.wcl:20:1]
 19 │
 20 │ web -> jobs
    · ─────┬─────
    ·      ╰── schema violation
    ╰────

queues.wcl: 1 schema violation

The message names the two types, not the two identifiers, because types are what dispatch matched on. A document with no connection declaration at all reports the same error for its first arrow — an arrow is never schema-free.

§ 7.3More than one schema accepts the pair

Declare DependsOn and TalksTo with identical endpoints, and web -> db fits both:

ambiguous.wclwcl
# ambiguous.wcl — two connections over one pair of types.

symbol_set EdgeKind { uses }

@block("service") type Service { @inline(0) name: identifier }

connection DependsOn: Service -> Service : EdgeKind
connection TalksTo:   Service -> Service : EdgeKind

@document
type Cfg {
  @children("service")    services: list<Service>
  @connections(DependsOn) deps:     list<DependsOn>
  @connections(TalksTo)   calls:    list<TalksTo>
}

service web
service db

web -> db
text
$ wcl check ambiguous.wcl
wcl::eval::schema_violation

  × connection 'Service -> Service' matches multiple schemas: DependsOn,
  │ TalksTo
    ╭─[ambiguous.wcl:20:1]
 19 │
 20 │ web -> db
    · ────┬────
    ·     ╰── schema violation
    ╰────

ambiguous.wcl: 1 schema violation

Two connections over the same pair of types is a modelling decision rather than a slip, and the fix is a modelling one: separate the endpoint types, or fold the two relationships into one and let the kind symbol tell them apart.

An ambiguous arrow still projects — into both lists

check refuses the document above, but projection is a separate mechanism and it does not arbitrate. Ask for either field and the arrow is in it: wcl get ambiguous.wcl deps answers [DependsOn { destination: db, kind: :uses, source: web }] and wcl get ambiguous.wcl calls answers [TalksTo { destination: db, kind: :uses, source: web }] — one edge, counted twice. Fix the ambiguity; do not build on a document that only get will read.

§ 7.4A kind outside the vocabulary

Change api -> cache :uses on line 26 to :calls, which EdgeKind does not declare:

text
$ wcl check services.wcl
wcl::eval::schema_violation

  × connection kind ':calls' is not a member of 'EdgeKind'
    ╭─[services.wcl:26:14]
 25 │ api -> db    :depends_on
 26 │ api -> cache :calls
    ·              ───┬──
    ·                 ╰── schema violation
    ╰────

services.wcl: 1 schema violation

The symbol is checked against the schema the statement dispatched to, so this error appears only once dispatch has succeeded. A statement that fails dispatch never gets its kind checked at all.

§ 8Dynamic endpoints

Every rule above assumes an operand names a block that exists in the file. Sometimes it cannot: the thing on the other end is generated at consume time, by a repeater or a component the host expands. @dynamic on the declaration relaxes exactly that assumption.

dyn.wclwcl
symbol_set EdgeKind { uses }

@block("service")
type Service { @inline(0) name: identifier }

@dynamic
connection DependsOn: Service -> Service : EdgeKind

@document
type Cfg {
  @children("service")    services: list<Service>
  @connections(DependsOn) edges:    list<DependsOn>
}

service web
service db

web -> db
web -> shard_0          # no such block — and that is allowed here
text
$ wcl check dyn.wcl
OK

$ wcl get dyn.wcl edges
[DependsOn { destination: db, kind: :uses, source: web }, DependsOn { destination: shard_0, kind: :uses, source: web }]

Two changes, and only two. wcl check stops reporting the unresolved operand, and the projection emits the raw identifier — shard_0 — instead of dropping the statement. Everything else holds: the operand that does resolve is still type-checked against its role, and a statement whose resolved end fits no schema is still refused.

This is not a looser mode to switch on while you tidy up. It moves one check out of the language and into whatever consumes the document, and the consumer then has to do it. wdoc's own edge schema is the case it exists for:

wcl
symbol_set EdgeKind { default flow data yes no }
@dynamic
connection Edge: &SvgBlock -> &SvgBlock : EdgeKind

A diagram's a -> b may name a shape a repeater has not produced yet, so the build matches unresolved ids against the rendered shape ids and warns about the ones that match nothing. Leave @dynamic off for a connection whose endpoints are always literal — it is the only thing standing between a typo and a silently missing edge. Connections and routing covers the diagram side.

§ 9Referential integrity

A connection is not the only way one block points at another. The plainest way is an ordinary field holding an id — and @ref("kind") is how you say that the id must name something real.

refs.wclwcl
# refs.wcl — an id field that must name a real block.

@block("screen")
type Screen { @inline(0) id: identifier  title: utf8 }

@block("flow")
type Flow {
  @inline(0) id: identifier
  @ref("screen") entry: identifier         # one id
  @ref("screen") steps: list<identifier>   # a list, checked item by item
}

@document
type Cfg {
  @children("screen") screens: list<Screen>
  @children("flow")   flows:   list<Flow>
}

screen login   { title = "Sign in" }
screen home    { title = "Home" }
screen profile { title = "Your profile" }

flow onboarding {
  entry = login
  steps = [login, home, profile]
}

wcl check prints OK. Point steps at a screen that does not exist and it names the id, the kind, and nothing else:

text
$ wcl check refs.wcl
wcl::eval::schema_violation

  × field 'steps': @ref("screen") target 'dashboard' is not the id of any
  │ 'screen' block

refs.wcl: 1 schema violation

Break two of them and one violation covers both:

text
$ wcl check refs.wcl
wcl::eval::schema_violation

  × field 'steps': @ref("screen") targets 'dashboard', 'settings' name no
  │ 'screen' block

refs.wcl: 1 schema violation

Four rules follow from how that check is written, and every one of them is a difference from a connection operand:

The id itself may be written bare or quoted: entry = login and entry = "login" are both accepted, and both resolve against the same ids. Values and primitives covers why identifier and a string are interchangeable in that position.

§ 10Three ways to point at another block

@ref, &T and a connection statement all encode "this block relates to that one", and they are not interchangeable. What separates them is what each one hands back.

FormWritten asWhat is checkedChecked byWhat you get backReach for it when
@ref@ref("screen") entry: identifierA block of that kind exists with that id, anywhere in the documentwcl checkThe id, unchangedThe id itself is the data, and one block names one other
&Tfocus: &DrawableThe target's type satisfies T — structurally for an interface, nominally for a record typeWhatever reads itA lazy path you can read the target's fields throughYou need the target's contents, not its name
Connectionweb -> db :usesBoth ends name a block, exactly one schema accepts the pair of types, the kind is in the setwcl checkA projected list of {source, destination, kind} recordsThe document holds a graph, and something walks the edges

Put it as a question about where the relationship is written. @ref and &T go on the block that points, one field per relationship, and they read well when a block has a few named links: a flow has an entry screen. A connection goes beside the blocks, one line per edge, and it reads well when the edges outnumber the blocks and no single block owns them: a service graph, a diagram, a dependency set. Forty edges as forty @ref fields is a graph pretending to be a schema; forty arrows is a graph.

Mind the fourth column, because it is the difference you feel first. @ref and a connection are both schema-pass checks: wcl check refuses the document. &T is not. Its two readings run when the navigator is built, so wcl check prints OK on a &Drawable field pointing at a block with no x, and the failure appears as <error: type 'Note' does not implement interface 'Drawable': missing field 'x'> the moment wcl parse or a host reads through it. Types has the warning in full.

What the checks catch also differs in strength. &T is the strongest thing checked — the target's whole type, not its spelling — but it is checked latest. A connection is next, and it is checked by check: both ends must exist, and their types must fit a declared schema. @ref is the weakest: a kind and an id, matched as strings. Reach down that list only when the stronger form does not fit what you are modelling.

wdoc's connection chapter is about drawing, not declaring

Connections and routing covers how wdoc draws an edge — the route it takes, where it anchors on each shape, how it is labelled. Its subject is the rendering of the Edge schema above. This chapter's subject is the language mechanism that any host, wdoc included, builds on. The arrow you type is the same one.

§ 11Where to go next