DWSD.md

Flight Route DSL — Syntax Reference

The Flight Route DSL describes a Flight-Levels flight route: the abstract design layer of a delivery — the bands work moves through, the stages (an item type at a band) it passes, and the glyph-typed edges that connect them. A route is authored unbound: its path: is a pure design, and a separate bound-to: layer maps its bands onto concrete boards. A route carries header metadata (its title:, descriptive tags:, the primary item type it is for:), a bands: declaration, optional external triggers:, the abstract path:, and an optional bound-to: binding.

The title: + tags: half of that header is shared with the board and topology DSLs — same keys, same meaning, same shape in all three. See The document header.

The DSL is plain YAML. dwsd-flightroute is the VS Code language id for standalone .dwsd-flightroute files. In Markdown, the fence tag that the extension’s preview renders live is dwsd.flightroute (the dot form):

```dwsd.flightroute
title: Minimal Route
for: feature
bands:
- name: Delivery
fl: 1
path:
- epic @ Delivery -> story @ Delivery
```

In plain Markdown / GitHub the same example reads as a YAML document either way.

Live example · Field Issue Route — Voltfeld example →

title: Field Issue Route
for: "[[Field Issue]]"
for-domain: "[[Charger ART]]"
bands:
  - name: Field
  - name: Train
    fl: 2
  - name: Product Lines
    fl: 2
  - name: Teams
    fl: 1
triggers:
  - name: a charger fails on site, an installer calls, telemetry flags a pattern
    generates:
      - issue @ Field
path:
  # Field Service brings the week's issues to triage
  - issue @ Field --> issue @ Train
  # safety or fleet-down: the issue becomes a hotfix in the expedite lane
  - issue @ Train -> hotfix @ Train
  # everything else becomes a feature for the next PI
  - "issue @ Train -> [[Feature]] @ Train"
  # the product line takes the hotfix into its expedite lane
  - hotfix @ Train --> hotfix @ Product Lines
  # one of its teams fixes it immediately
  - hotfix @ Product Lines --> hotfix @ Teams
  # the verified fix flows back into the issue
  - hotfix @ Teams ..> issue @ Train
  # the hotfix reaches the fleet over the air
  - hotfix @ Train ..> ()
  # Field Service hears back what happened to what it reported
  - issue @ Train ..> issue @ Field
bound-to:
  - band: Train
    boards:
      - board: "[[Charger ART Board]]"
        stages:
          - issue: "lane:Field Issue/col:Triage"
          - hotfix: "lane:Field Issue/col:Fix-in-Progress"
          - Feature: "lane:Feature/group:Preparation/col:Program Backlog"
  - band: Product Lines
    boards:
      - board: "[[Wallbox Board]]"
        stages:
          - hotfix: "lane:Hotfix/col:In Progress"

Fence-tag caveat — the examples on the topic pages use dwsd-flightroute. Syntax highlighting accepts four spellings (wsd-flightroute, dwsd-flightroute, wsd.flightroute, dwsd.flightroute — the markdown-injection grammar), but the preview extractor currently accepts only dwsd.flightroute. So a ```dwsd-flightroute fence highlights but does not render. The topology DSL already resolved this asymmetry by accepting all four spellings in both places; board and route have not yet. Until they do, use the dot form for a fence you want to see rendered.

The parser is tolerant: it never throws on author input. A malformed bands:, triggers:, path:, or bound-to: entry produces a route.* diagnostic (mostly a warning) and the offending entry is skipped — the rest of the document still parses. A small set of violations are deliberately hard errors instead: the retired document-title key route:, the four retired compat keys (fl-triggers:, fl-path:, flow:, fl-bands: — see below), and the structural Flow ⊆ Path rule (route.flow-not-subset-path). The diagnostic codes are listed per construct on each topic page and summarized under Diagnostics (route.*) below.

When prose and code disagree, the code wins. Every key, value set, and diagnostic on these pages was authored from the parser (src/route/yaml-parser.ts), the AST types (src/route/types.ts), and the validator rules (src/route/rules/*). If a future change makes this page inaccurate, the shipped parser is the authority.

Note on field names that do not exist. The Flight Route DSL has no inline sugar beyond the edge glyphs — every construct is a spelled-out YAML key (bands:, triggers:, path:, bound-to:, for:). The only string sugar is the edge line itself (epic @ Strategy -> story @ Delivery) and the stage form itemType @ Band.

The following are not keys — do not write them:

  • bind: / realizes: — the band→board binding section is the single bound-to: key (workspace-consistent with the AI-agent bound-to: frontmatter). These spellings are rejected, not aliased.
  • fl-triggers: / fl-path: / flow: / fl-bands: — retired. The old compat form is gone. The current keys are triggers:, path:, bound-to: and bands: (in that order). A document that carries any of the four gets one route.legacy-syntax error that names every old key found and its replacement, and the canvas stays empty — a current key beside an old one is not drawn. Files in the old form must be rewritten by hand; there is no alias. See Legacy keys.
  • deliver edge — retired. Delivery is a dotted edge to the reserved outside-band sink: stage ..> ().
  • route: header — retired. The document title is title: (see The document header). A root-level route: is a hard route.retired-key error that names its own fix; the value is dropped, not read.

The tolerant parser silently ignores any non-key (no diagnostic, no field), so an example that writes one would parse “clean” while dropping the value — which is exactly why route: and the compat keys were given real errors instead of joining that list. Two repo gates hold the line on the examples below: pnpm check:dsl-corpus-sweep parses every tracked unit and live fence and requires 0 diagnostics, and pnpm check:retired-key-audit fails on any surviving retired spelling.

Topic pages

The reference is split across per-topic pages:

  • The document header — title: + tags:, the header shared by all three DSLs, the missing-value convention, and the retired keys.
  • The locator grammar — the cross-DSL locator that bound-to: stage refinements and route refs address boards and columns with.
  • Path & bands — the abstract path: model, bands: declaration (with the multi-FL span), stage identity (itemType @ Band, inferred from edge endpoints), and external triggers:.
  • Edges — the three canonical edges (generate / copy / feedback), the ()/⊙ outside-band delivery, and the band constraints.
  • Binding — the bound-to: layer that maps a route band onto a concrete board via the shared locator, and the Flow ⊆ Path rule.
  • Anchors — route refs via the shared locator, stage markers, and the drawer.
  • Examples — full worked dwsd-flightroute examples.
  • Examples gallery — the generated SVG gallery (Source fence + rendered SVG per example) for GitHub / handover reading.

Design notes — YAML, humans, and machines

The Flight Route DSL is YAML by design. Every example in this reference is also a valid YAML document — block style (bands: as an indented list) and flow style ({ name: Delivery, fl: 1 }) both parse — so any YAML-aware editor, linter, or formatter can read and write it. The package ships its own tolerant parser (src/route/yaml-parser.ts) modeled verbatim on the board and topology parsers.

The syntax optimizes for readability and round-trippability, not brevity. Field names are spelled out (bands, triggers, bound-to — not abbreviations), values are explicit, and the parser never throws: unknown or malformed input yields a route.* diagnostic and a skip rather than a hard crash. That verbosity is deliberate — the format is meant to be read and written by humans, but above all by machines. The closed, documented set of keys and the per-construct diagnostics give a coding agent everything it needs to author or repair a route without a UI; the live fence preview is then the way a person checks what the machine produced.

Route header fields

Every field below is implemented by the parser (src/route/yaml-parser.ts, normalizeRouteYaml) and produces the frozen RouteAst contract (src/route/types.ts). Each optional field is omitted when absent — never a placeholder '' / []. The Diagnostic column names the route.* code an author hits on misuse.

FieldValues / shapeAST fieldDefaultDiagnostic on misuse
titlestring (the route name)title?(none — omitted when absent)(none — a non-string or empty value is silently ignored)
tagsstring list — document scope only (a scalar string is accepted as a one-element list)tags?(none)tolerant — non-string entries dropped, empties trimmed
fora quoted wikilink ("[[Feature]]") or plain text (feature) — the primary item type the route describes; painted as the first header chip, before the for-domain chipsprimaryItemTypenullroute.invalid-for (warn) on an unquoted for: [[Feature]]; route.unresolved-item-type-ref (warn, needs a workspace) on a wikilink that resolves to nothing; route.item-type-ref-kind (warn, needs a workspace) on a wikilink that is not a flight-item-type — plain text is never checked
for-domainwikilink or list of wikilinks ("[[Delivery Ops]]") — the domain (a work system or a role) responsible for the routeforDomain?(none — omitted when absent)route.invalid-for-domain (warn) on a malformed shape; route.unresolved-domain-ref (warn, needs a workspace) on an unknown target
bandslist of band records { name, fl?: number | number[] }routeBands?(none)route.invalid-band (skip); route.invalid-band-fl (warn)
triggerslist of { name, generates } — external notes OUTSIDE bandsrouteTriggers?(none)route.invalid-trigger (entry skipped)
pathglyph-typed edge list (see Edges)pathEdges?(none)route.invalid-edge (skip); route.edge-band-constraint (info)
bound-toband→board bindings (see Binding)boundTo?(none)route.invalid-bound-to (skip); route.flow-not-subset-path (error)

title is the optional route name — the header text. It is the same key the board and topology DSLs use; when it is absent the field is omitted from the AST (never ''). The type-named route: key it replaced is retired and now errors — see The document header. tags are descriptive chips at document level only — deliberately not on bands or stages (“Color and information belong only where they carry meaning” — at a stage a tag would only repeat what the band position already says; see The document header). for names the primary item type the route describes (kept verbatim as primaryItemType): a quoted wikilink when it has a file, plain text when it has none. The preview paints it as the first chip of the header (before the for-domain chips) with the flight-item-type glyph — see Responsibility and references. for-domain names who is responsible for the route — see Responsibility and references. bands, triggers, path, and bound-to are documented in full on their topic pages.

Legacy keys — one error that names the fix

The pre-Phase-24 compat form used four top-level keys: fl-triggers:, fl-path:, flow: and fl-bands:. They are gone. A route whose YAML root carries any of them shows one route.legacy-syntax error, ranged on the line of the first old key, and an empty canvas — never a partial render of the current keys that sit beside it. A file with two or more old keys still gets one error, naming all of them in source order:

Old keyWrite instead
fl-triggers:triggers:
fl-path:path:
flow:bound-to:
fl-bands:bands:

The message reads Legacy Flight Route key is no longer supported — replace `fl-bands:` with `bands:` (see the route reference). for one key and Legacy Flight Route keys are no longer supported — replace `fl-triggers:` with `triggers:`, `fl-path:` with `path:` (see the route reference). for several. The values are never echoed. The old record shapes differ from the current ones (an fl-bands: entry { id, label, levels } becomes a bands: entry { name, fl }; the deliver edge becomes stage ..> ()), so the rename is not a find-and-replace: rewrite the section in the current form described on the topic pages. In a live preview, a key typed into a route that was drawing correctly keeps the last good picture next to the error until the key is removed.

Empty routes

A route without bands is not a blank page. When a document has a title:, tags:, for-domain: or for: (or notes) but no bands and no stages, the preview shows its header — the title, the for: chip, the for-domain chips and the tags — and below it one muted hint, No bands or stages yet — add `bands:` and `path:`. No bands are invented. A route that has only for: shows the header strip with the for: chip, like any other header content. A document with none of the route keys at all keeps the original hint, Type `title: name` to begin, on the small empty canvas.

Diagnostics (route.*)

The parser is tolerant and never throws; most malformed input yields a route.* warning and a skip, while a few violations are deliberate errors. The full diagnostic family — every code below is emitted by the shipped parser (src/route/yaml-parser.ts) or a validator rule (src/route/rules/*):

CodeOriginSeverityTrigger
route.syntax-erroryaml-parsererrorThe YAML itself does not parse (including any attempt at an executable !!-tag, which is rejected, never run). A path line or generates: entry holding an unquoted wikilink (- [[Epic]] @ Core -> …, a YAML flow sequence) lands here, and the message names the fix: quote the line
route.legacy-syntaxparser / yaml-parsererrorA legacy route "Name" header with a non-mapping root — migrate to title:; or a retired compat key at the YAML root (fl-triggers: / fl-path: / flow: / fl-bands:) — one error per file on the first such key, every key found named with its replacement, the canvas stays empty (Legacy keys)
route.retired-keyyaml-parsererrorThe retired root route: document-title key was authored — rename to title:
route.internal-error / parse.internal-erroryaml-parser / parsererrorBelt-and-suspenders catch for an unexpected throw
route.invalid-bandyaml-parsertolerant (warn)A malformed bands: entry (not an object, missing name, or an invalid fl) is skipped
route.invalid-triggeryaml-parsertolerant (warn)A malformed triggers: entry or generate target (not "itemType @ Band" or "[[ItemType]] @ Band") is skipped
route.invalid-edgeyaml-parsertolerant (warn) / errorA malformed path: edge is skipped; a wrong-glyph -> () delivery or a multi-hop line is an error naming the fix
route.invalid-bound-toyaml-parsertolerant (warn)A malformed bound-to: entry is skipped
route.invalid-foryaml-parsertolerant (warn)An unquoted for: [[Feature]], which YAML reads as a nested list; the value is dropped and the message names the quoting fix
route.invalid-for-domainyaml-parsertolerant (warn)A for-domain: value that is neither a quoted wikilink nor a list of them (an unquoted [[Name]], which YAML reads as a nested list, gets a message naming the quoting fix); the malformed entries are dropped
route.empty-router5info/warnAn empty route
route.flow-not-subset-pathr12errorThe Flow ⊆ Path rule is violated, or a bound-to: band is not declared in bands: (AST-internal — no lookup)
route.invalid-band-flr13warnA new-model bands: fl outside the canonical 1..3
route.edge-band-constraintr14infoThe SEED-004 / Klaus-#20 band constraint (a teaching nudge — cross-band generate or same-band copy)
route.unresolved-board-refxref/r4warnA bound-to: board not found in the workspace index
route.unresolved-domain-refxref/r5warnA for-domain target that is not found in the workspace index (needs a workspace; without one nothing is reported). The route still renders
route.unresolved-item-type-refxref/r6warnA stage item type or a for: written as a wikilink that is not found in the workspace (needs a workspace). Plain text is never looked up and never reported. The route still renders
route.item-type-ref-kindxref/r7warnA stage item type or a for: written as a wikilink that resolves, but to something that is not a flight-item-type (a work system, a board, a role, a note, …). The message names the kind found. The link keeps its look and its drawer row; text stages and a text for: are never checked, a wikilink that resolves to nothing is route.unresolved-item-type-ref instead (needs a workspace)
route.ambiguous-stage-namer15warnOne band holds two stage names that differ only by case ([[Feature]] @ Train beside feature @ Train), or one name written both as a wikilink and as text
route.stage-name-spellingr16warnA bound-to stage key or a note on: locator names a stage by a different case than the stage’s name (feature: for [[Feature]] @ Train). The old spelling still works, for one release

Security: the parser uses the default core YAML schema — no executable tags (no RCE via !!js/function / !!python/object; an unknown !!-tag becomes a route.syntax-error, never a side effect), ReDoS-safe edge-glyph regexes, and it never throws past the public boundary.

Responsibility and references

for-domain names who takes care when the route changes. Its value is a work system or a role — a responsibility area, not a place where work flows. The work systems a route flows through are derived, never authored: bound-to names a board, and the board’s visualization-of names its work system.

The bound route worked example carries one. A list of quoted wikilinks names more than one domain. A malformed value is a route.invalid-for-domain warning; a target the workspace does not know is a route.unresolved-domain-ref warning. Neither blocks the route.

The preview shows each domain as a chip next to the title. When the workspace knows the work system or role, the chip label shows in the link ink with a pointer and a heavier outline while you hover it, and a click opens the entity. When it does not, the chip is plain. When more domains are named than fit, the header shows as many as fit and a +N chip for the rest. A published picture renders without a workspace, so its chip is always the plain state.

Every header chip carries the glyph of its target’s type. A domain chip shows the work-system glyph, or the role glyph when its target is a role; a name the workspace does not know keeps the work-system glyph. The glyph is the same size as on the other chips and sits at the left of the label. A link written in another case ([[delivery ops]]) opens the real file (Delivery Ops): the chip keeps the text as written and the click uses the entity’s own name.

for: is the first chip of the header, in front of the domain chips, with the flight-item-type glyph — also when it is plain text, when it is a broken link and when it names something that is not a flight item type. A quoted wikilink (for: "[[Feature]]", with an alias "[[Feature|the feature]]" the chip shows the alias) is a link like a domain chip: link ink, a pointer and a heavier outline on hover when the workspace knows it, a click opens the file. A plain-text for: feature is a chip without a link and is never looked up, even when a flight-item-type of that name exists. A broken wikilink stays a plain neutral chip; the warning is route.unresolved-item-type-ref. A wikilink that resolves to something other than a flight-item-type stays a link and gets a route.item-type-ref-kind warning. for: and the domains share one run: when they do not all fit, the header shows as many as fit and one +N chip for the rest. The route’s drawer (the . doc-note bubble in the header opens it) lists the for: value as a Flight Item Type row, in the same three looks as a stage row (link, not-found, plain).

A stage’s item type is text or a wikilink — nothing is matched by name. Text (order @ Shops) describes the item locally to the route: it is never looked up, never linked and never flagged, even when a flight-item-type of that name exists. A quoted wikilink ("[[Feature]] @ Train") is a real connection to a flight-item-type entity: it resolves like any wikilink, and one that resolves to nothing is a broken link. An alias keeps a short label ([[Customer Order|order]] @ ERP shows order and links to Customer Order). Both forms mix freely, also on one line, in path lines, in the expanded { from, to, kind } form and in a trigger’s generates:. A line that holds a wikilink is quoted, as everywhere in YAML:

path:
- "[[Epic]] @ Train -> [[Feature]] @ Train"
- "[[Feature]] @ Train -> story @ Teams"

A resolved wikilink stage label shows in the link ink with a pointer, the stage box takes the hover tone, and a click opens the entity. Route links carry no underline, not even on hover. A broken wikilink stays a plain neutral label on the canvas; the warning is a route.unresolved-item-type-ref in the Problems panel and the not-found row in the stage drawer. A wikilink that resolves, but to something that is not a flight-item-type (a work system, a board, a role), stays a link with the same look and gets a route.item-type-ref-kind warning. A link written in another case ([[customer order]] @ ERP) opens the real file (Customer Order); the label stays as written. Static pictures (an exported SVG or PNG, the Markdown preview, the example gallery) show the link ink only, and a host without a workspace shows every wikilink stage as a plain label. Path endpoints and trigger generates: targets are both stages. Item types inside a route are part of the route itself and are never filtered. What the Filter menu does hide closes up: when one of the pills on a stage side (agreement, interaction, AI agent, …) is hidden, the others move into its place with no gap; bands, stages and arrows never move.

A stage is named by its token, or for a wikilink by its link target (not the alias). stage:Feature @ Train in a note locator, Feature: as a bound-to stage key. The match is exact within the route, and a locator never nests [[…]]. Switching a stage from text to a wikilink therefore changes its name (feature → Feature): update the locators and keys with it. The old lowercase spelling still pins and anchors the stage for one release, with a route.stage-name-spelling warning (and anchor.route-stage-spelling on the Markdown line of a cross-file locator in the editor). Two names in one band that differ only by case get a route.ambiguous-stage-name warning.

A click on the stage box opens the stage drawer. The box, and any of its markers, open the one drawer of that stage (one drawer per stage, across its before / in / after sides). The drawer lists the stage’s Flight Item Type first, then everything anchored to the stage (agreements, interactions, AI agents, change entries) and the stage’s notes. A stage with nothing anchored still opens, with the Flight Item Type row alone. The row follows the item type’s form:

  • a wikilink stage the workspace knows: the row is a link and opens the item type (the row shows the alias, when there is one);
  • a wikilink stage the workspace does not know: the not-found row every broken wikilink gets (the alert glyph and “Target not found in workspace”), with no link;
  • a text stage, and a wikilink stage in a picture laid out without a workspace (a published picture, a host with no workspace index): the plain name, no link and no warning. Text is a description local to the route, not a reference.

The stage label of a wikilink keeps its own action and opens the item type directly, without a drawer. Pills, the change pill, the bound-board pill and the note marker keep their own actions too. With the keyboard, Tab moves to a stage box (in stage order), Enter or Space opens the drawer and, pressed again, closes it; the focus ring follows the box border. A host can keep the older model, where only the markers open the drawer, with openOn: 'marker-zone' on renderRoute.

Like what you see? → Start — build your own org folder in ~2 min

index.md
# Flight Route DSL — Syntax Reference
The **Flight Route DSL** describes a Flight-Levels **flight route**: the abstract
design layer of a delivery — the **bands** work moves through, the **stages**
(an item type at a band) it passes, and the glyph-typed **edges** that connect
them. A route is authored *unbound*: its `path:` is a pure design, and a separate
`bound-to:` layer maps its bands onto concrete boards. A route carries header
metadata (its `title:`, descriptive `tags:`, the primary item type it is `for:`),
a `bands:` declaration, optional external `triggers:`, the abstract `path:`, and
an optional `bound-to:` binding.
The `title:` + `tags:` half of that header is **shared with the board and
topology DSLs** — same keys, same meaning, same shape in all three. See
[The document header](../document-header.md).
The DSL is plain **YAML**. `dwsd-flightroute` is the **VS Code language id** for
standalone `.dwsd-flightroute` files. In Markdown, the fence tag that the
extension's **preview** renders live is **`dwsd.flightroute`** (the dot form):
````markdown
```dwsd.flightroute
title: Minimal Route
for: feature
bands:
- name: Delivery
fl: 1
path:
- epic @ Delivery -> story @ Delivery
```
````
In plain Markdown / GitHub the same example reads as a YAML document either way.
> **Fence-tag caveat — the examples on the topic pages use `dwsd-flightroute`.**
> Syntax **highlighting** accepts four spellings (`wsd-flightroute`,
> `dwsd-flightroute`, `wsd.flightroute`, `dwsd.flightroute` — the
> markdown-injection grammar), but the **preview extractor** currently accepts
> only `dwsd.flightroute`. So a ` ```dwsd-flightroute ` fence highlights but does
> not render. The topology DSL already resolved this asymmetry by accepting all
> four spellings in both places; board and route have not yet. Until they do, use
> the **dot form** for a fence you want to see rendered.
The parser is **tolerant**: it never throws on author input. A malformed `bands:`,
`triggers:`, `path:`, or `bound-to:` entry produces a `route.*` diagnostic (mostly
a warning) and the offending entry is **skipped** — the rest of the document still
parses. A small set of violations are deliberately **hard errors** instead: the
retired document-title key `route:`, the four retired compat keys
(`fl-triggers:`, `fl-path:`, `flow:`, `fl-bands:` — see below), and the
structural Flow ⊆ Path rule (`route.flow-not-subset-path`). The diagnostic codes
are listed per construct on each topic page and summarized under
[Diagnostics (route.*)](#diagnostics-route) below.
> **When prose and code disagree, the code wins.** Every key, value set, and
> diagnostic on these pages was authored from the parser (`src/route/yaml-parser.ts`),
> the AST types (`src/route/types.ts`), and the validator rules
> (`src/route/rules/*`). If a future change makes this page inaccurate, the shipped
> parser is the authority.
> **Note on field names that do _not_ exist.** The Flight Route DSL has **no
> inline sugar beyond the edge glyphs** — every construct is a spelled-out YAML
> key (`bands:`, `triggers:`, `path:`, `bound-to:`, `for:`). The only string sugar
> is the edge line itself (`epic @ Strategy -> story @ Delivery`) and the stage
> form `itemType @ Band`.
>
> The following are **not** keys — do not write them:
>
> - **`bind:` / `realizes:`** — the band→board binding section is the single
> **`bound-to:`** key (workspace-consistent with the AI-agent `bound-to:`
> frontmatter). These spellings are **rejected**, not aliased.
> - **`fl-triggers:` / `fl-path:` / `flow:` / `fl-bands:`** — **retired.** The
> old compat form is gone. The current keys are `triggers:`, `path:`,
> `bound-to:` and `bands:` (in that order). A document that carries any of the
> four gets **one** `route.legacy-syntax` **error** that names every old key
> found and its replacement, and the canvas stays empty — a current key beside
> an old one is not drawn. Files in the old form must be rewritten by hand;
> there is no alias. See [Legacy keys](#legacy-keys--one-error-that-names-the-fix).
> - **`deliver` edge** — **retired.** Delivery is a dotted edge to the reserved
> outside-band sink: `stage ..> ()`.
> - **`route:` header** — **retired.** The document title is `title:` (see
> [The document header](../document-header.md)). A root-level `route:` is a hard
> `route.retired-key` **error** that names its own fix; the value is dropped, not
> read.
>
> The tolerant parser **silently ignores** any non-key (no diagnostic, no field),
> so an example that writes one would parse "clean" while dropping the value —
> which is exactly why `route:` and the compat keys were given real errors instead
> of joining that list. Two repo gates hold the line on the examples below: `pnpm
> check:dsl-corpus-sweep` parses every tracked unit and live fence and requires 0
> diagnostics, and `pnpm check:retired-key-audit` fails on any surviving retired
> spelling.
## Topic pages
The reference is split across per-topic pages:
- [The document header](../document-header.md) — `title:` + `tags:`, the header
shared by all three DSLs, the missing-value convention, and the retired keys.
- [The locator grammar](../locator-grammar.md) — the cross-DSL locator that
`bound-to:` stage refinements and route refs address boards and columns with.
- [Path & bands](./path-and-bands.md) — the abstract `path:` model, `bands:`
declaration (with the multi-FL span), stage identity (`itemType @ Band`,
inferred from edge endpoints), and external `triggers:`.
- [Edges](./edges.md) — the three canonical edges (`generate` / `copy` /
`feedback`), the `()`/⊙ outside-band delivery, and the band constraints.
- [Binding](./binding.md) — the `bound-to:` layer that maps a route band onto a
concrete board via the shared locator, and the Flow ⊆ Path rule.
- [Anchors](./anchors.md) — route refs via the shared locator, stage markers, and
the drawer.
- [Examples](./examples.md) — full worked `dwsd-flightroute` examples.
- [Examples gallery](./route-examples.md) — the generated SVG gallery (Source
fence + rendered SVG per example) for GitHub / handover reading.
## Design notes — YAML, humans, and machines
The Flight Route DSL is **YAML by design**. Every example in this reference is
also a valid YAML document — block style (`bands:` as an indented list) and flow
style (`{ name: Delivery, fl: 1 }`) both parse — so any YAML-aware editor, linter,
or formatter can read and write it. The package ships its own tolerant parser
(`src/route/yaml-parser.ts`) modeled verbatim on the board and topology parsers.
The syntax optimizes for **readability and round-trippability, not brevity**.
Field names are spelled out (`bands`, `triggers`, `bound-to` — not
abbreviations), values are explicit, and the parser never throws: unknown or
malformed input yields a `route.*` diagnostic and a skip rather than a hard crash.
That verbosity is deliberate — the format is meant to be read and written by
humans, but **above all by machines**. The closed, documented set of keys and the
per-construct diagnostics give a coding agent everything it needs to author or
repair a route without a UI; the live fence preview is then the way a person
checks what the machine produced.
## Route header fields
Every field below is implemented by the parser (`src/route/yaml-parser.ts`,
`normalizeRouteYaml`) and produces the frozen `RouteAst` contract
(`src/route/types.ts`). Each optional field is **omitted** when absent — never a
placeholder `''` / `[]`. The **Diagnostic** column names the `route.*` code an
author hits on misuse.
| Field | Values / shape | AST field | Default | Diagnostic on misuse |
|-------|----------------|-----------|---------|----------------------|
| `title` | string (the route name) | `title?` | (none — omitted when absent) | (none — a non-string or empty value is silently ignored) |
| `tags` | string list — **document scope only** (a scalar string is accepted as a one-element list) | `tags?` | (none) | tolerant — non-string entries dropped, empties trimmed |
| `for` | a quoted wikilink (`"[[Feature]]"`) or plain text (`feature`) — the primary item type the route describes; painted as the **first header chip**, before the `for-domain` chips | `primaryItemType` | `null` | `route.invalid-for` (warn) on an unquoted `for: [[Feature]]`; `route.unresolved-item-type-ref` (warn, needs a workspace) on a wikilink that resolves to nothing; `route.item-type-ref-kind` (warn, needs a workspace) on a wikilink that is not a flight-item-type — plain text is never checked |
| `for-domain` | wikilink or list of wikilinks (`"[[Delivery Ops]]"`) — the domain (a work system or a role) responsible for the route | `forDomain?` | (none — omitted when absent) | `route.invalid-for-domain` (warn) on a malformed shape; `route.unresolved-domain-ref` (warn, needs a workspace) on an unknown target |
| `bands` | list of band records `{ name, fl?: number \| number[] }` | `routeBands?` | (none) | `route.invalid-band` (skip); `route.invalid-band-fl` (warn) |
| `triggers` | list of `{ name, generates }` — external notes OUTSIDE bands | `routeTriggers?` | (none) | `route.invalid-trigger` (entry skipped) |
| `path` | glyph-typed edge list (see [Edges](./edges.md)) | `pathEdges?` | (none) | `route.invalid-edge` (skip); `route.edge-band-constraint` (info) |
| `bound-to` | band→board bindings (see [Binding](./binding.md)) | `boundTo?` | (none) | `route.invalid-bound-to` (skip); `route.flow-not-subset-path` (**error**) |
**`title`** is the optional route name — the header text. It is the **same key
the board and topology DSLs use**; when it is absent the field is **omitted** from
the AST (never `''`). The type-named `route:` key it replaced is retired and now
errors — see [The document header](../document-header.md). **`tags`** are
descriptive chips at **document level only** — deliberately not on bands or stages
(*"Color and information belong only where they carry meaning"* — at a stage a tag
would only repeat what the band position already says; see
[The document header](../document-header.md#route-s-tags-are-document-level-only)).
**`for`** names the primary item type the route describes (kept verbatim as
`primaryItemType`): a quoted wikilink when it has a file, plain text when it has
none. The preview paints it as the **first chip of the header** (before the
`for-domain` chips) with the flight-item-type glyph — see
[Responsibility and references](#responsibility-and-references).
**`for-domain`** names who is responsible for
the route — see [Responsibility and references](#responsibility-and-references).
**`bands`**, **`triggers`**, **`path`**, and
**`bound-to`** are documented in full on their topic pages.
## Legacy keys — one error that names the fix
The pre-Phase-24 compat form used four top-level keys: `fl-triggers:`, `fl-path:`,
`flow:` and `fl-bands:`. They are gone. A route whose YAML root carries any of them
shows **one** `route.legacy-syntax` error, ranged on the line of the first old key,
and an **empty canvas** — never a partial render of the current keys that sit beside
it. A file with two or more old keys still gets one error, naming all of them in
source order:
| Old key | Write instead |
|---------|---------------|
| `fl-triggers:` | `triggers:` |
| `fl-path:` | `path:` |
| `flow:` | `bound-to:` |
| `fl-bands:` | `bands:` |
The message reads ``Legacy Flight Route key is no longer supported — replace `fl-bands:` with `bands:` (see the route reference).``
for one key and ``Legacy Flight Route keys are no longer supported — replace `fl-triggers:` with `triggers:`, `fl-path:` with `path:` (see the route reference).``
for several. The values are never echoed. The old record shapes differ from the
current ones (an `fl-bands:` entry `{ id, label, levels }` becomes a `bands:` entry
`{ name, fl }`; the `deliver` edge becomes `stage ..> ()`), so the rename is not a
find-and-replace: rewrite the section in the current form described on the topic
pages. In a live preview, a key typed into a route that was drawing correctly keeps
the last good picture next to the error until the key is removed.
## Empty routes
A route without bands is not a blank page. When a document has a `title:`, `tags:`,
`for-domain:` or `for:` (or notes) but **no bands and no stages**, the preview shows
its **header** — the title, the `for:` chip, the `for-domain` chips and the tags —
and below it one muted hint, `` No bands or stages yet — add `bands:` and `path:` ``.
No bands are invented. A route that has only `for:` shows the header strip with the
`for:` chip, like any other header content. A document
with none of the route keys at all keeps the original hint,
`` Type `title: name` to begin ``, on the small empty canvas.
## Diagnostics (`route.*`)
The parser is tolerant and never throws; most malformed input yields a `route.*`
warning and a skip, while a few violations are deliberate errors. The full
diagnostic family — every code below is emitted by the shipped parser
(`src/route/yaml-parser.ts`) or a validator rule (`src/route/rules/*`):
| Code | Origin | Severity | Trigger |
|------|--------|----------|---------|
| `route.syntax-error` | yaml-parser | error | The YAML itself does not parse (including any attempt at an executable `!!`-tag, which is rejected, never run). A path line or `generates:` entry holding an **unquoted** wikilink (`- [[Epic]] @ Core -> …`, a YAML flow sequence) lands here, and the message names the fix: quote the line |
| `route.legacy-syntax` | parser / yaml-parser | error | A legacy `route "Name"` header with a non-mapping root — migrate to `title:`; **or** a retired compat key at the YAML root (`fl-triggers:` / `fl-path:` / `flow:` / `fl-bands:`) — one error per file on the first such key, every key found named with its replacement, the canvas stays empty ([Legacy keys](#legacy-keys--one-error-that-names-the-fix)) |
| `route.retired-key` | yaml-parser | error | The retired root `route:` document-title key was authored — rename to `title:` |
| `route.internal-error` / `parse.internal-error` | yaml-parser / parser | error | Belt-and-suspenders catch for an unexpected throw |
| `route.invalid-band` | yaml-parser | tolerant (warn) | A malformed `bands:` entry (not an object, missing `name`, or an invalid `fl`) is skipped |
| `route.invalid-trigger` | yaml-parser | tolerant (warn) | A malformed `triggers:` entry or generate target (not `"itemType @ Band"` or `"[[ItemType]] @ Band"`) is skipped |
| `route.invalid-edge` | yaml-parser | tolerant (warn) / error | A malformed `path:` edge is skipped; a wrong-glyph `-> ()` delivery or a multi-hop line is an **error** naming the fix |
| `route.invalid-bound-to` | yaml-parser | tolerant (warn) | A malformed `bound-to:` entry is skipped |
| `route.invalid-for` | yaml-parser | tolerant (warn) | An unquoted `for: [[Feature]]`, which YAML reads as a nested list; the value is dropped and the message names the quoting fix |
| `route.invalid-for-domain` | yaml-parser | tolerant (warn) | A `for-domain:` value that is neither a quoted wikilink nor a list of them (an unquoted `[[Name]]`, which YAML reads as a nested list, gets a message naming the quoting fix); the malformed entries are dropped |
| `route.empty-route` | r5 | info/warn | An empty route |
| `route.flow-not-subset-path` | r12 | **error** | The Flow ⊆ Path rule is violated, or a `bound-to:` band is not declared in `bands:` (AST-internal — no lookup) |
| `route.invalid-band-fl` | r13 | warn | A new-model `bands:` `fl` outside the canonical `1..3` |
| `route.edge-band-constraint` | r14 | info | The SEED-004 / Klaus-#20 band constraint (a teaching nudge — cross-band `generate` or same-band `copy`) |
| `route.unresolved-board-ref` | xref/r4 | warn | A `bound-to:` board not found in the workspace index |
| `route.unresolved-domain-ref` | xref/r5 | warn | A `for-domain` target that is not found in the workspace index (needs a workspace; without one nothing is reported). The route still renders |
| `route.unresolved-item-type-ref` | xref/r6 | warn | A stage item type or a `for:` written as a **wikilink** that is not found in the workspace (needs a workspace). Plain text is never looked up and never reported. The route still renders |
| `route.item-type-ref-kind` | xref/r7 | warn | A stage item type or a `for:` written as a **wikilink** that resolves, but to something that is not a flight-item-type (a work system, a board, a role, a note, ...). The message names the kind found. The link keeps its look and its drawer row; text stages and a text `for:` are never checked, a wikilink that resolves to nothing is `route.unresolved-item-type-ref` instead (needs a workspace) |
| `route.ambiguous-stage-name` | r15 | warn | One band holds two stage names that differ only by case (`[[Feature]] @ Train` beside `feature @ Train`), or one name written both as a wikilink and as text |
| `route.stage-name-spelling` | r16 | warn | A `bound-to` stage key or a note `on:` locator names a stage by a different case than the stage's name (`feature:` for `[[Feature]] @ Train`). The old spelling still works, for one release |
Security: the parser uses the default **core YAML schema** — **no executable
tags** (no RCE via `!!js/function` / `!!python/object`; an unknown `!!`-tag becomes
a `route.syntax-error`, never a side effect), ReDoS-safe edge-glyph regexes, and
it never throws past the public boundary.
## Responsibility and references
**`for-domain` names who takes care when the route changes.** Its value is a
work system or a role — a responsibility area, not a place where work flows. The
work systems a route flows through are derived, never authored: `bound-to` names
a board, and the board's `visualization-of` names its work system.
The [bound route worked example](./binding.md#worked-example--a-bound-route) carries
one. A list of quoted wikilinks names more than one domain. A malformed value is a
`route.invalid-for-domain` warning; a target the workspace does not know is a
`route.unresolved-domain-ref` warning. Neither blocks the route.
**The preview shows each domain as a chip next to the title.** When the
workspace knows the work system or role, the chip label shows in the link ink
with a pointer and a heavier outline while you hover it, and a click opens the
entity. When it does not, the chip is plain. When more domains
are named than fit, the header shows as many as fit and a `+N` chip for the rest.
A published picture renders without a workspace, so its chip is always the plain
state.
**Every header chip carries the glyph of its target's type.** A domain chip
shows the work-system glyph, or the role glyph when its target is a role; a name
the workspace does not know keeps the work-system glyph. The glyph is the same
size as on the other chips and sits at the left of the label. A link written in
another case (`[[delivery ops]]`) opens the real file (`Delivery Ops`): the chip
keeps the text as written and the click uses the entity's own name.
**`for:` is the first chip of the header**, in front of the domain chips, with
the flight-item-type glyph — also when it is plain text, when it is a broken link
and when it names something that is not a flight item type. A quoted wikilink
(`for: "[[Feature]]"`, with an alias `"[[Feature|the feature]]"` the chip shows
the alias) is a link like a domain chip: link ink, a pointer and a heavier outline on
hover when the workspace knows it, a click opens the file. A **plain-text** `for: feature`
is a chip without a link and is never looked up, even when a flight-item-type of
that name exists. A **broken** wikilink stays a plain neutral chip; the warning is
`route.unresolved-item-type-ref`. A wikilink that resolves to something other than
a flight-item-type stays a link and gets a `route.item-type-ref-kind` warning. `for:`
and the domains share one run: when they do not all fit, the header shows as many
as fit and one `+N` chip for the rest. The route's drawer (the `.` doc-note bubble in
the header opens it) lists the `for:` value as a **Flight Item Type** row, in the
same three looks as a stage row (link, not-found, plain).
**A stage's item type is text or a wikilink — nothing is matched by name.**
Text (`order @ Shops`) describes the item locally to the route: it is never
looked up, never linked and never flagged, even when a flight-item-type of that
name exists. A **quoted wikilink** (`"[[Feature]] @ Train"`) is a real
connection to a flight-item-type entity: it resolves like any wikilink, and one
that resolves to nothing is a broken link. An alias keeps a short label
(`[[Customer Order|order]] @ ERP` shows `order` and links to Customer Order).
Both forms mix freely, also on one line, in path lines, in the expanded
`{ from, to, kind }` form and in a trigger's `generates:`. A line that holds a
wikilink is **quoted**, as everywhere in YAML:
```yaml
path:
- "[[Epic]] @ Train -> [[Feature]] @ Train"
- "[[Feature]] @ Train -> story @ Teams"
```
A resolved wikilink stage label shows in the link ink with a pointer, the stage box
takes the hover tone, and a click opens the entity. Route links carry no underline,
not even on hover. A **broken** wikilink
stays a plain neutral label on the canvas; the warning is a
`route.unresolved-item-type-ref` in the Problems panel and the not-found row in
the stage drawer. A wikilink that resolves, but to something that is **not** a
flight-item-type (a work system, a board, a role), stays a link with the same look
and gets a `route.item-type-ref-kind` warning. A link written in another case
(`[[customer order]] @ ERP`) opens the real file (`Customer Order`); the label stays
as written. Static pictures (an exported SVG or PNG, the Markdown preview,
the example gallery) show the link ink only, and a host without a workspace shows every
wikilink stage as a plain label. Path endpoints and trigger `generates:` targets
are both stages. Item types inside a route are part of the route itself and are
never filtered. What the Filter menu does hide closes up: when one of the pills on a
stage side (agreement, interaction, AI agent, ...) is hidden, the others move into
its place with no gap; bands, stages and arrows never move.
**A stage is named by its token, or for a wikilink by its link target (not the
alias).** `stage:Feature @ Train` in a note locator, `Feature:` as a `bound-to`
stage key. The match is exact within the route, and a locator never nests
`[[…]]`. Switching a stage from text to a wikilink therefore changes its name
(`feature` → `Feature`): update the locators and keys with it. The old
lowercase spelling still pins and anchors the stage **for one release**, with a
`route.stage-name-spelling` warning (and `anchor.route-stage-spelling` on the
Markdown line of a cross-file locator in the editor). Two names in one band that
differ only by case get a `route.ambiguous-stage-name` warning.
**A click on the stage box opens the stage drawer.** The box, and any of its
markers, open the one drawer of that stage (one drawer per stage, across its
before / in / after sides). The drawer lists the stage's **Flight Item Type**
first, then everything anchored to the stage (agreements, interactions, AI
agents, change entries) and the stage's notes. A stage with nothing anchored
still opens, with the Flight Item Type row alone. The row follows the item type's
form:
- a **wikilink** stage the workspace knows: the row is a link and opens the item
type (the row shows the alias, when there is one);
- a **wikilink** stage the workspace does not know: the not-found row every broken
wikilink gets (the alert glyph and "Target not found in workspace"), with no link;
- a **text** stage, and a wikilink stage in a picture laid out without a workspace
(a published picture, a host with no workspace index): the plain name, no link and
no warning. Text is a description local to the route, not a reference.
The stage label of a wikilink keeps its own action and opens the item
type directly, without a drawer. Pills, the change pill, the bound-board pill and the
note marker keep their own actions too. With the keyboard, Tab moves to a stage
box (in stage order), Enter or Space opens the drawer and, pressed again, closes
it; the focus ring follows the box border. A host can keep the older model, where only
the markers open the drawer, with `openOn: 'marker-zone'` on `renderRoute`.