DSL Agent Spec
Short-form Goldiblocks authoring DSL reference. This page is the canonical source for the bundled dsl_spec.md in each skill document folder.
This page is the canonical short-form DSL reference. The document_guide/dsl_spec.md file bundled inside each skill document folder is a copy of this page, with a version header prepended. When the spec changes, update this page first, then update any skill snapshots that exercise the changed feature.
For the full narrative reference with examples and edge cases, see DSL Syntax and DSL Expressions.
Overview
Read this file before editing DSL-authored document folders. This spec defines DSL semantics only — follow session_guide.md for interview, edit, render, status, and diagnostics workflow. The spec is organized around these contracts:
- Mental Model — documents assemble modules; modules contain blocks; every block is
contentordata. - Folder Contract — files the document folder must contain.
- Document File and Modules — frontmatter, namespaces, identity, status, and metadata rules.
- Blocks — the two kinds, shared envelope, and payload rules.
- Field Declarations — data-block field syntax, required markers, defaults, computed fields, collections, and collected values.
- Handles and Paths — reference syntax for fields, nested values, and collection items.
- Flow — conditional inclusion. Ordering is sequence position, not a
flowconcern. - Expression Language — computed-field and condition grammar.
- References (
@) — the reference syntax: values, citations, the terminator rule, and the authored/stored split. - Iteration — rendering semantics.
- Current Limitations — unsupported constructs the agent must not invent.
This spec is goldiblocks-authoring-v4, snapshotted 2026-07-28.
v4 syntax is settled but not yet implemented
v4 replaces {{ }} interpolation, the filter pipeline (fallback,
upper), and [[module#^block|alias]] links with one @ reference
syntax. The compiler, renderer, and every seeded module still speak v3 —
authored content in the corpus is unmigrated. Author new material against
v4 only once the compiler lands; until then this page is the target
contract, and the Legacy interpolation
section describes what actually runs. Migration is tracked in the
archived plan 2026-07-28-at-reference-dsl.
Mental Model
A document is assembled from ordered modules. A module is a container of blocks; every block has a kind — content or data.
Document -> Module[] -> Block[] # block.kind: content | data- The document file stores document-level metadata, defaults, output settings, and local workflow state.
- Modules are the sequencing and reuse units. Sequence position decides where a module sits;
module.flow.conditiondecides whether it is active. - Data blocks are the interview surface. Each data block is one interview step — a cohesive group of
fields:that are asked, shown, conditioned, or repeated together. The agent collects or edits fieldvalueentries, using each field'slabelas the human prompt and the block'sguide:for step framing. - Computed fields also live in data-block
fields:but keep their= expressionsource and never storevalue. - Content blocks are rendered output units. Their
content:may interpolate the moduledatanamespace, repeat or join collections, and useflow.condition, but they are not interview prompts. - The module
datanamespace is the fold of its data blocks' values. Field paths are unique across all data blocks of one module, so@data.fieldnever depends on which block owns the field.
Folder Contract
A document folder contains:
README.md— document entrypoint; lists files to read before acting.document_guide/session_guide.md— agent workflow (interview, edit, render, status, diagnostics).document_guide/dsl_spec.md— this spec, version-pinned.document_input/document.md— the whole document: frontmatter, authored defaults, and every module as a YAML sequence.document_output/— rendered Markdown outputs.
Document File
document_input/document.md frontmatter carries workflow state and lineage only:
---
reuse_type: working # library | working
status: in_progress # in_progress | complete
latest_render: document_output/simple_will.1.md # omit until first render
source_handle: simple_will # omit for original folders
---- The document handle is the folder name. It is not declared inside the file.
label,description,defaults,modes, andoutputlive in the body underdocument:.- Frontmatter carries no
created_at,updated_at, oredited_at. Document recency is derived from the maximum moduleedited_atin the body.
Modules
Modules are a YAML sequence in document_input/document.md. Sequence position is the order — nothing declares it; see Ordering Is Sequence Position. Each module entry has two namespaces: module: and blocks:. (The former standalone data: namespace is retired — field declarations live on data blocks, and the module's data schema is derived from them.)
Module frontmatter carries current workflow state and edit metadata:
---
status: not_started # not_started | in_progress | complete | skipped
edited_at: 2026-05-11T10:42:00+10:00 # omit until first persisted interview-state change
status_reason: # optional; use mainly for skipped
kind: declined # declined | deferred | flow_condition_false | agent_judgment | other
summary: User said they do not want backup executors.
---Missing or blank status means not_started.
Status values:
not_started— active under currentflow.condition, but no field has been collected yet.in_progress— at least one required field is collected; more required fields remain.complete— every active required field is collected.skipped— the module is not part of the active interview because itsflow.conditionevaluated false, the user declined it, the user deferred it out of the current run, or the agent made an applicability judgment.
skipped suppresses the module's blocks at render time. Use status_reason when the reason is not self-evident from collected values. edited_at advances on any persisted interview-state change, including value writes, status changes, and status_reason changes. The module status is derived from its data blocks' statuses; the frontmatter value mirrors that derivation.
Module identity and metadata
Write module: fields in this order:
module:
handle!: executors # required; unique within the document
label!: Executors # required display label
flow:
condition: expression # omit if unconditional
description: One-line metadata for indexes and listings.
access:
challenge: identity # optional; triggers an identity challenge ([app] tier)module.handle!must be unique within the document. On a duplicate, logdiagnostic.dsl_parse_unclear.module.descriptionis one-line metadata.- Modules have no
type. A data pool is simply a module whose blocks are all data blocks; the formerauthtype is expressed asaccess.challenge: identity(the local skill ignores it). - Interview framing lives on data blocks (
guide:), not on the module — see Blocks below. - YAML is unordered; the field order above is a convention for legibility.
Field Declarations
Field declarations live in a data block's fields: list (see Blocks). fields: is ordered — field order is interview order.
field_key|Type: # optional field, no default
field_key|Type!: # required
field_key|Type: default_value # authored default literal
field_key|Type: = expression # computed; never stores value
field_key|Collection[1..5]!: # required collection, 1–5 items
field_key|Collection[]: # unbounded collection
field_key|singleSelect[a, b, c]: # one selected option from an authored list
field_key|multiSelect[a, b, c]: # zero or more selected options from an authored listThe authored field handle is the part before the pipe. The compiler mints a
stable fld_ identity beneath it; references bind to that identity, so the
handle may later change without orphaning references. The ! required marker
is appended to the type, not the handle.
Supported field-level properties:
flow— condition and ordering for this field.label— optional human-readable field name and prompt; absent, derive it from the handle.question— legacy prompt wording accepted while old corpus entries fade; do not author it in new fields.item— inline item schema blueprint forCollectionfields.item_handle— handle pattern for auto-generated collection item handles.item_sum— cross-item sum rule on aCollectionfield; see Collection fields.default— authored default value.value— collected or pre-filled runtime value (working documents only).
label and guide
Split by job, not synonyms:
label(per field) — what the person is answering: concise human-readable text used by the form and by Goldie as the prompt. When absent, derive it from the field handle.guide(per block) — searchable quick overview a Builder uses to evaluate whether this is the right block. On a data block it additionally tells Goldie how to collect the group: purpose, tone, ordering, clarification strategy, and what to press on. On a content block it describes the content and its purpose for discovery and agent reasoning. It never renders as document content.
Do not nest field declarations or validation rules inside label or guide;
both are free text. Legacy question remains readable but is not new-authoring
syntax.
Collection fields
Use Collection with an inline item schema when the item shape is specific to the module:
executors|Collection[1..3]!:
label: Executors
item_handle: executor_{n}
item:
legal_name|PersonLegalName!:
street_address|StreetAddressAU!:itemis a blueprint, not collected data.item_handleis optional. When present, it must contain exactly one{n}placeholder.{n}is a handle-generation counter. It is not template interpolation.- The agent auto-generates stable handles using the
{singular}_{n}pattern (e.g.executor_1). Do not ask the user to supply handles. - Single-cardinality composite fields (e.g.
testator_address|StreetAddressAU!) carry no handle. The field key is the identifier. - A composite field inside a collection is nested under its field handle. For example,
person|PersonLegalNameAddressAU!storesperson.legal_nameand renders as@item.person.legal_nameinsidejoinFor. If the intended runtime shape is flat, declare the child fields directly initem:instead, as shown above.
item_sum declares that a numeric item field must total a fixed amount
across the collection's items — the shares rule stated once, as data, instead
of restated in prose per tier:
inheritors|Collection[1..20]!:
item:
legal_name|PersonLegalName!:
share|number!:
item_sum:
field: share
total: 100In plain terms: the inheritance shares must add up to 100%, and the system — not just the interviewing agent — refuses a list that doesn't.
fieldmust name a declared item field;totalis a number.- The server enforces it on every write door (single-field block writes and
whole-module accepts alike). A list passes when its sum rounds to
totalat whole-number precision using the expression language'sROUNDsemantics — so three authored thirds (33.33 × 3 = 99.99) pass and a real shortfall is refused. - The check defers while any item's summed field is still uncollected;
requiring the field to eventually arrive is
!'s job, notitem_sum's. - Prose in
label/guidemay explain the rule to the person, but the declaration is the enforced copy.
Collected runtime items store handles under value: as a YAML list:
value:
- handle: executor_1
legal_name: Jane Smith
street_address:
street: 12 Park Lane
suburb: Sydney
state: NSW
postcode: "2000"Collected values
- Blank unanswered fields omit
value. Do not writevalue: null. - Collected scalar fields:
value: literal. - Collected composite fields:
value:as nested YAML. Nohandleon single-cardinality composites. - Computed fields keep
= expressionand never storevalue.
Select fields
Use singleSelect[...] when a field must be one value from an authored list of choices. Use multiSelect[...] when a field may hold zero or more values from an authored list of choices.
The bracket form is the preferred local authoring syntax:
fallback|singleSelect[their_children, surviving_inheritors, other]:
label: Contingency Outcome
contact_methods|multiSelect[email, phone, sms]:
label: Contact Methods- Options are comma-separated and whitespace-trimmed.
- Option names are exact stored values. Prefer stable
snake_casenames. - Runtime
singleSelectvalues store the selected option as a string, for examplevalue: surviving_inheritors. - Runtime
multiSelectvalues store selected options as a YAML list of strings:
value:
- email
- sms- The agent must only persist exact options declared in the field type. If the user's answer is a synonym, normalise it to a declared option when unambiguous; otherwise ask a short clarification.
- Do not create new choices during interview unless the user is intentionally editing the document source. For session work, undeclared options are invalid.
Standard Types
These types are always available without declaration.
Base: text, markdown, number, count, checkbox, date, Collection
Semantic:
| Type | Base | Notes |
|---|---|---|
YesNo | checkbox | Authored literals: Yes / No |
PersonLegalName | text | Full legal name as it appears on a driver's licence or passport |
Jurisdiction | singleSelect | nsw vic qld act sa wa tas nt |
StateAU | singleSelect | act nsw nt qld sa tas vic wa |
Retired — DocumentGenerationMode. A preview / final field no longer
says where or when a block appears. Where is no longer asked at all — since
2026-08-25 there is one venue and no block declares a place (see
Permissions and Visibility → One venue).
When is no longer asked either: documentLifecycle was retired on
2026-09-01 and the unified schema has no column for it, leaving
two visibility filters
— whether and who. The matching @runtime.doc_generation_mode reference is
retired with it. A block still declaring the type reports an unresolved type reference on
insertion.
Composite:
| Type | Fields |
|---|---|
StreetAddressAU | street|text!, suburb|text!, state|StateAU!, postcode|text! |
CompanyLegalName | company_name|text!, company_acn|text! |
PersonLegalNameAddressAU | legal_name|PersonLegalName!, street_address|StreetAddressAU! |
Composite types may carry implied normalisation conventions such as jurisdiction, identifier format, and address shape. The session guide's Normalisation and Enrichment rule defers to those conventions.
For document-specific composite types reused across multiple modules in the same document, declare a types: namespace in data_pool.md. For shapes used in only one module, use an inline item: schema on the Collection field instead.
Handles and Paths
All handles and field keys are snake_case.
data.field_name # current module
module_handle.data.field_name # cross-module
data.person.name # nested composite
data.children[0].name # index
data.children[handle=child_1].name # stable handle (preferred)
data.beneficiaries[handle=beneficiary_1].share # collection item fieldInside repeatFor or joinFor, item fields use the explicit item scope:
@item.legal_name
@item.street_address.suburbEach item field binds to the stable fld_ identity in the collection item
blueprint while evaluation reads the current runtime item. Bare item-first
lookup is invalid.
The path is always module_handle.data.field_name. A field key may share its module handle without ambiguity (e.g. an executors module with an executors|Collection field gives the path executors.data.executors).
Flow
flow is scoped to the module, field, or block where it appears. There is no standalone top-level flow: namespace.
flow.after— retired. Do not author it. Order is sequence position, at both the module and block tier; see Ordering Is Sequence Position. The compiler derives stored order asorder.position— a gapped integer outsideflow. Legacy rows may still carryafter; nothing new writes it.flow.condition— boolean expression. Omit if unconditional. Do not use a leading=. This is the only way to express a branch: a condition names a value, never whether another block exists.
A false module condition skips the module from interview and render. A false field condition skips that interview field. A false block condition skips that block during render.
flow:
condition: '@data.has_minors'Placement is never a condition
flow.condition: uiMode = "fill" and its siblings are retired — do not
author them. uiMode was placement masquerading as a condition, and that
diagnosis is why it went: a condition tests data, never where.
Do not reintroduce a render-time placement reference in any spelling. There
is no uiMode, no surface, no display.surface, no display.surfaces and no
display.targets — neither in an expression nor in the envelope. A block
appears on the document when its conditions and the reader's capabilities allow
it, and a block that should not be in the instrument is not authored into it.
Why a replacement is an owner question rather than a schema convenience is
the completed lineage.
A block that used to swap rendering by mode is authored with the condition alone:
display:
start_level: 3 # how it looks — not where it goes
flow:
condition: '@data.has_minors' # whether — real data statehasValues in conditions
hasValues is a block-scoped render-time boolean, valid only in block flow.condition (lint error on modules and fields). It is the hide-if-empty gate, derived automatically — the compiler reads the block's own references, so the condition never goes stale when content is edited:
- On a content block: true when every value reference in
content:resolves to a non-blank value. Modifier references (:label,:number,:range,:handle) render no field value, so they are excluded from the test.repeatFor/joinForblocks require the iterated collection to resolve non-empty. - On a data block: true when every required field in the block has a collected value.
- A block with no references is vacuously true.
flow:
condition: hasValues # hide until all referenced data resolves
flow:
condition: NOT(hasValues) # placeholder while data is missingA recap that should appear only once its data is answered is a condition and nothing else — there is no second axis to combine it with:
flow:
condition: hasValueshasValues is read-only and never referenced from computed-field expressions.
Expression Language
Expressions appear in computed fields (= expression) and flow.condition values. Template content: supports @ references and the closed reference-modifier set only — not the general expression grammar.
Operators
| Category | Operators |
|---|---|
| Comparison | = != > >= < <= |
| Arithmetic | + - * / |
| String concat | & |
Logical composition uses functions, not && or ||.
Functions
Logical: AND(...) OR(...) NOT(arg) IF(condition, then, else) TRUE() FALSE()
Existence: BLANK() — use field = BLANK() for blank checks.
Text: CONCATENATE(...) UPPER(text) LOWER(text) TRIM(text) LEN(text)
Numeric: ROUND(value, precision) MAX(...) MIN(...)
Date: TODAY() DATEADD(date, n, units) DATETIME_DIFF(date1, date2, units) — units are "days", "months", "years".
Count: COUNT(value) — count items in a collection.
Computed fields are evaluated lazily. Field paths in the = expression use
@ and are stored as stable identity bindings with readable handle hints;
never store a computed result as value.
Blocks
Every block has a kind — content or data. Absent kind means content. Each kind has exactly one payload:
kind: content→content:(Markdown + interpolation)kind: data→fields:(ordered declarations with values) plus optional block-levelguide:
Everything else — required handle!, optional label:, flow, display,
access — is shared envelope. When label: is absent, the UI may
derive readable text from the handle; references never use the label.
blocks:
- handle!: executors_details # required; stable, scoped to module
label: Executor details # optional display label
kind: data
guide: |
Ask who administers the estate. Explain the executor role briefly if asked.
fields:
executor_name|PersonLegalName!:
label: Primary Executor
value: Jane Smith
has_executor|YesNo!:
label: Executor Chosen
value: Yes
- handle!: appointment_clause
label: Appointment of executor
kind: content
flow:
condition: '@data.has_executor'
display:
start_level: 3
content: |
I appoint @data.executor_name as my executor.Rules:
block.handle!is unique within its module.block.label:is optional editable display text. It is safe to rename or omit because references bind toblock.handle!; UI and agent projections may fall back to the handle.displaycarriesstart_level(1–4) and the fournumberingflags — both optional, and both about how a block looks. It carries nothing about where a block goes:surfacesretired 2026-08-25 with the second venue. There is nodisplay.type: a block holds mixed Markdown and element kind comes from parsing it at projection time. Numbering is a projection and is never written into content. See Numbering and Blocks → The Display Facet.- Field paths are unique across all data blocks of one module — a field key may live in only one data block. Enforced at lint and insertion time.
- One data block = one interview step. Split test: would these fields ever be asked, skipped, or repeated independently?
- Content blocks interpolate the module
datanamespace (@data.field), never a block handle. Moving a field between data blocks must not break any content. - Data blocks record interview status (
not_started/in_progress/complete/skipped, with optionalstatus_reason); the module's status derives from its data blocks.
References (@)
One syntax reaches a value, a field, or a block, with a :modifier selecting which reading is wanted. @ supersedes both {{ }} interpolation and [[module_handle#^block_handle|alias]] links.
Authored forms
@data.field the value, printed
@module_handle.data.field cross-module value
@data.person.name nested composite
@content_block the block's label, linked — the citation default
@content_block:number the block's computed clause number
@content_block:range the span of its numbers — "3.3.1 to 3.3.4"
@block:label the label, explicitly
@block:handle the handle itself — rare, explicit
@data.seller:upper uppercase scalar value
@data.email:lower lowercase scalar value
@guardians:label:upper resolution then casing — opening a sentence
@item.legal_name current iteration item- A bare
@on a data path prints the value. A reference that resolves to a blank value prints the document's placeholder (—) — one renderer convention, not a per-slot declaration. There is nofallbackfilter. - A bare
@on a content block yields its label, linked. A clause is cited far more often than any other reading, so the citation is the default. There is no:contentmodifier — clause prose is never interpolated mid-sentence; clause structure belongs todisplay. - The modifier separator is
:, never.—.is the path separator, so@person.legal_name:upper-style forms parse where a dotted modifier cannot. - Operative cross-references cite the number:
@ip_clause:numberis stable like the handle, readable like the label, and prints identically on screen and in the signed instrument. Referencing an unnumbered block with:number— or:range— is a typed compile failure; it never falls back to the label. :rangecites the span where:numbercites the first element: a block whose Markdown produces3.3.1through3.3.4reads3.3.1 to 3.3.4— full number path at both ends, never elided. A single-number block collapses to that number. The separator (to, or a dash) belongs to the document's numbering scheme; only numbers are emitted, so "clause" or "clauses" is the author's prose.- Modifiers occupy two slots, in fixed order. One resolution modifier (
:label,:number,:range,:handle) selects what to resolve to — mutually exclusive by meaning, two of them is aduplicate-modifierfailure. Optionally one casing modifier (:upper,:lower) follows, transforming the resolved text; two casings is alsoduplicate-modifier, and casing written before resolution (@guardians:upper:label) is malformed — casing applies after resolution. - Casing follows the resolution.
:labeland:handleresolve to text, so:label:uppercomposes;:numberand:rangerefuse casing (modifier-type-mismatch) because glyph case —(iv)vs(IV)— belongs to the scheme's level definition, and a citation must not render one level inconsistently. On a bare data path, casing still requires a scalar field. - No filter pipeline.
fallbackis retired. Modifiers take no arguments.
Terminator rule
A reference is @ followed by the longest run of [A-Za-z0-9_.], with up to two trailing :modifiers (resolution, then casing). It ends at the first character outside that set:
@data.seller. ends at `.` + space → sentence full stop
@data.share% ends at `%`
@data.seller, and ends at `,`
(@data.seller) ends at `)`
@data.seller's `'` terminates — possessives need no escape
@ip_clause:label then ends at the space after the modifierA trailing . is never part of the path — a path segment cannot be empty, so a . with no following identifier character is punctuation. A literal @ in prose is escaped \@ (authored form only; the corpus currently needs it nowhere).
Authored vs stored
The authored form is what a person types and reads back. On parse, the compiler resolves each reference to a stable block or field identity and stores a bracketed token carrying both the identifier and a readable handle hint — Slack's <#C024BE7LV|general> shape:
authored @ip_clause:label
stored <@blk_7f3a|ip_clause:label>- Brackets exist in the stored form only. It is machine-written and machine-read, so
>terminates unambiguously and no escape is needed there. - The identifier wins; the name is a hint. A resolving renderer ignores the stored name and prints the current handle or label; a plain-text reader sees the stale-but-readable name. A stale name is never a broken reference.
- There is one persisted representation. The authored spelling is projected from the stable identity and current handle rather than stored as a second copy that can drift.
- Authors never see a
blk_. About'scontent:shows the authored form; binding happens underneath. See Blocks Schema → Handles are generated, and editable.
@ in YAML paths
@ is a binding marker, not only an interpolation marker: in content: it also means print this; in YAML flow fields it binds without printing.
| Field | Holds | @ |
|---|---|---|
items: @data.gifts | a field path | yes |
flow.condition | an expression containing paths | paths only, never the whole value |
flow.after was a third row here. It is retired from authored source — order is
sequence position, so there is no handle to bind and no rename to survive.
condition: AND(@data.has_partner = "Yes", @data.count > 1) # paths markedEvaluation results and failures
A valid reference evaluates to one of three states:
value— resolved non-blank data or a reference facet;blank— a valid field with no collected value;unknown— a valid condition whose value cannot yet be determined.
Malformed syntax, unknown or ambiguous targets, invalid paths, invalid
modifier use (unknown words, two of one modifier kind, casing before
resolution, casing against :number / :range), missing item scope, and
:number or :range against an unnumbered block are typed failures. They
never silently render an empty string.
Iteration
repeatFor repeats a block once per collection item:
repeatFor:
items: @data.executorsjoinFor renders the block body once per item and joins results:
joinFor:
items: @data.executors
separator: "; "
lastSeparator: "; and "repeatFor and joinFor are mutually exclusive on a single block. lastSeparator defaults to separator when omitted.
For legal-list prose, prefer this pattern so the output reads A; B; and C:
joinFor:
items: @data.people
separator: ";\n"
lastSeparator: "; and\n"For fill-mode summaries or bullet-style lists, use separator: "\n" and omit lastSeparator.
When prose refers back to a previously-rendered list with a singular/plural pronoun, gate on the referenced list's count, not the current list's count.
Current Limitations (v2)
- No dynamic lookup operator for joining selected handles to source records across modules. Store render-ready values in the consuming module when cross-module composite data is needed.
- No list-to-scalar expression functions (
JOIN,LIST_JOIN, etc.). Use block-leveljoinForfor list prose.