DSL Syntax
Authoring syntax for types, modules, blocks, field declarations, references, template content, conditions, and computed fields
This page is the syntax reference for the authored YAML shape. The DSL Agent Spec (
goldiblocks-authoring-v3) is the behavioral contract for agents conducting authoring and interviews; wherever this reference and the spec disagree, the spec wins. Storage mapping lives in YAML and JSON Mapping.
Naming Conventions
Authored identifiers such as module handles, block handles, field names, stable list item handles, and select choice names must be snake_case: lower-case letters, numbers, and underscores only. Do not use kebab-case or camelCase.
Type names use UpperCamelCase, such as PersonLegalName or StreetAddressAU.
Select field type keywords keep their exact spelling even though they are camelCase: singleSelect and multiSelect. This is authoring vocabulary only; Convex is the runtime persistence layer.
Standard Types
Standard types are always available without declaration. For the authoritative list see DSL Agent Spec.
Base: text, markdown, number, count, checkbox, date, Collection
Semantic: YesNo, PersonLegalName, Jurisdiction, StateAU
Composite: StreetAddressAU, CompanyLegalName, PersonLegalNameAddressAU
PersonLegalName is backed by text. Its standard agent guidance is: "Enter the full legal name as it appears on a driver's licence or passport."
StreetAddressAU composes street|text!, suburb|text!, state|StateAU!, postcode|text!.
CompanyLegalName composes company_name|text!, company_acn|text!. ACN is text to preserve leading zeros and standard formatting (e.g. 123 456 789).
For document-specific composite types reused across multiple modules, declare a types: namespace in data_pool.md. For shapes used in only one module, use an inline item: schema on the Collection field.
Module File Structure
Each module file has two top-level YAML namespaces:
module: # identity and metadata
blocks: # the module's blocks — every block has kind content or dataThere is no standalone data: namespace. Field declarations live on kind: data blocks; the module's data namespace is the fold of its data blocks' fields (see Blocks Schema → Derived module surfaces).
module: stores identity and metadata:
module:
handle!: guardians
label!: Guardianship Provisions
flow:
condition: '@data_pool.data.has_minors'Modules have no type. A data pool is a module whose blocks are all data blocks — a composition fact, not a category. Identity-challenge behavior (the former auth type) is module-level access metadata:
module:
handle!: auth
label!: Check Your Email
access:
challenge: identityblocks: stores the module's blocks. A kind: data block owns a group of fields; a kind: content block (the default when kind is absent) renders prose:
blocks:
- handle!: guardians_data
label: Guardian details
kind: data
guide: |
Collect guardian details if the testator has minor children.
fields:
guardian_name|PersonLegalName!:
label: Guardian
guardian_address|StreetAddressAU:
wants_backup_guardian|YesNo:
label: Backup Guardian Required
backup_guardian_name|PersonLegalName:
flow:
condition: '@data.wants_backup_guardian'
- handle!: guardian_appointment
label: Guardian appointment
kind: content
flow:
condition: '@data_pool.data.has_minor_children'
display:
start_level: 3
content: |
I APPOINT @data.guardian_name:upper of @data.guardian_address.street
as guardian of my minor children.One data block is one interview step: a cohesive group of fields asked, shown, conditioned, or repeated together. Split test: would these fields ever be asked, skipped, or repeated independently? If yes, they belong in separate data blocks. Field paths must be unique across all data blocks of one module — a field key may live in only one data block.
Scoped Flow
flow is scoped to the thing it governs. It is not a standalone top-level namespace.
flow.conditiongates whether a module, field, or block applies.flow.adaptbinds host paths on insertion.
Sequencing is not a flow concern. Order is declaration order — position in
the YAML sequence — at every tier: modules in the document, blocks in a module,
fields in a data block. The retired flow.after is covered at Ordering Is
Sequence Position.
Defaults:
- If
flowis omitted, the item is included, in declaration order. - If
flow.conditionis omitted, the item is included. - Do not author meaningless defaults like
condition: true.
Field Declaration Grammar
Field declarations live in a data block's fields: list. fields: is ordered — field order is interview order.
field_id|type: literal # authored default value
field_id|type: = expression # computed value, evaluated lazily
field_id|type: # no value; collected during interview
field_id|type!: ... # required
field_id|Collection[1..5]: ... # collection with min 1, max 5 items
field_id|Collection[]: ... # collection with no bounds
field_id|singleSelect[a, b, c]: # one selected option from an authored list
field_id|multiSelect[a, b, c]: # zero or more selected optionsThe pipe character separates the key from its type. No spaces around the pipe by convention; the parser may be tolerant.
The |type, !, bounds, select options, and = expression markers are authoring syntax only. The stable field key is the part before the pipe. App storage and compiled artifacts split the declaration into the field key plus structured metadata; see YAML and JSON Mapping.
The required marker is appended to the type, not the key:
executor_name|PersonLegalName!:Collection bounds appear after the type:
executors|Collection[1..5]!:The = prefix on the value side marks a computed expression:
multiple_executors|checkbox: = COUNT(executors) > 1Without =, an inline right-hand value is treated as an authored default literal of the declared type. Collected local runtime values are stored under the field's value property. The same = symbol is also the equality operator inside expressions; the parser distinguishes by position.
Expression marker rules:
| Context | Expression marker |
|---|---|
| Derived field value | Required: `key |
flow.condition | Implicit: condition: expression |
Block repeatFor.items / joinFor.items | Implicit: collection path |
| Template interpolation | Reference plus optional named filter pipeline |
Only derived field values use the assignment-side = marker. Conditions and checks are expression contexts and must not use a leading =.
Supported field-level properties for MVP:
labelflowquestion(legacy input only; do not author on new fields)itemitem_handledefaultvalue
label and guide
Split by job, not synonyms:
-
label(per field) — what the person is answering: the field's name and concise prompt as a human reads it in the Fields column, a few words at most (Your Name,ABN,Are you married). Optional; absent, readers humanize the handle (enter_start_email→ "enter start email"). A new field's handle is derived from its label at creation, after which the two are independent — a label edit never moves a handle, because@references bind to the handle and a silent re-point is the one failure the stable key exists to prevent. -
guide(per block) — searchable quick overview a Builder uses to evaluate whether this is the right block. On a data block it also tells Goldie how to collect the field 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.
Legacy question remains accepted while stored corpus entries fade, but new
fields use label plus block guide. Do not nest declarations or validation
rules inside either free-text field. Required-ness is signalled by ! on the
type.
item and item_handle apply only to Collection fields. item declares the schema for one runtime collection item. item_handle declares the stable handle pattern used when the agent creates collected items.
value stores a collected or pre-filled value in local document folders. It can appear on any local module because library and working documents share the same file shape. By convention, clean reusable library sources omit value; active working documents store interview answers as value in their own duplicated folder. Blank unanswered fields omit value; computed fields keep the = expression form and never store value.
Data Representation Layers
The DSL uses three related representations:
| Layer | Shape | Purpose |
|---|---|---|
| Schema / authoring declaration | flattened | Compact field catalog, validation, diffs, and compiler diagnostics. |
| Runtime instance storage | nested | Human-readable instance data that mirrors the real-world structure being collected. |
| Agent evaluation / render paths | path-based | Deterministic references into nested runtime data. |
Flattened field path rules:
- Flattened field paths are the canonical schema declaration format.
- Nested object fields are declared as dotted paths, such as
executors.name|text!. - Collection roots are declared once with their collection type and cardinality, such as
executors|Collection[1..5]:. - Child fields use the same dotted paths that templates and diagnostics use.
- Schema declarations stay flat even when runtime instance data is nested.
- The compiler must reject ambiguous or duplicate flattened paths — including duplicates across different data blocks of the same module.
Runtime instance storage is nested, co-located on the field entry inside the data block:
blocks:
- handle!: family_data
label: Family details
kind: data
fields:
has_children|YesNo:
value: true
children|Collection[]:
item_handle: child_{n}
item:
name|text!:
age|number:
value:
- handle: child_001
name: "Jane Smith"
age: 14Evaluation paths:
children[0].name
children[handle=child_001].age
children[].age
children.name # inside repeatFor/joinFor item scopeEvery list item has a required stable handle. Use stable-handle paths for edits, references, and agent updates that must survive reordering.
Paths never address a block. References and evaluation read the module data namespace — the fold of the module's data blocks' values — so @data.address stays valid no matter which data block currently owns address. See Blocks Schema → The Module Namespace Rule.
Collection Item Schema
Use Collection with an inline item schema. Build item shapes from standard types:
fields:
residuary_beneficiaries|Collection[1..5]!:
item_handle: residuary_beneficiary_{n}
item:
legal_name|PersonLegalName!:
share|number!:Rules:
Collection[1..5]!declares the collection cardinality and requiredness.itemdefines the schema for one future runtime item. It is a blueprint, not collected data.item_handleis optional. When present, it must contain exactly one{n}placeholder.{n}is a handle-generation counter placeholder. It is not template interpolation.- Collected runtime items store a stable
handlegenerated fromitem_handleor from the collection field key fallback. - Do not author
item_1,item_2, or runtime handles in the schema. Handles belong under collectedvalueitems.
Runtime storage for the example above is nested:
fields:
residuary_beneficiaries|Collection[1..5]!:
item_handle: residuary_beneficiary_{n}
item:
legal_name|PersonLegalName!:
share|number!:
value:
- handle: residuary_beneficiary_1
legal_name: "Jane Smith"
share: 50
- handle: residuary_beneficiary_2
legal_name: "Alex Chen"
share: 50Author-facing paths do not include the storage key value. Use stable-handle selectors:
data.residuary_beneficiaries[handle=residuary_beneficiary_1].legal_name
data.residuary_beneficiaries[handle=residuary_beneficiary_1].shareInside repeatFor or joinFor, the item scope exposes the current item's fields directly:
blocks:
- handle!: residuary_beneficiary_clause
label: Residuary beneficiary clause
repeatFor:
items: @data.residuary_beneficiaries
content: |
@item.legal_name receives @item.share%.Type System
Standard types are documented in Standard Types above. This section covers type system rules.
text is the canonical DSL type for plain strings. markdown is distinct because document prose and block content are authored as Markdown.
count is a computed numeric type for counting collection or object-like values:
children|Collection[]:
children_count|count: = COUNT(children)Currency is deferred. MVP money amounts that must preserve display formatting, such as 10.00, use text.
Closed option sets use the select bracket form — options are comma-separated, whitespace-trimmed, and stored exactly as written (prefer stable snake_case names):
fallback|singleSelect[their_children, surviving_inheritors, other]:
label: Contingency Outcome
contact_methods|multiSelect[email, phone, sms]:
label: Contact MethodssingleSelect[...]stores the selected option as a string, e.g.value: surviving_inheritors.multiSelect[...]stores selected options as a YAML list of strings.- Only declared options are valid values. The agent normalises unambiguous synonyms to a declared option and otherwise asks a short clarification; it never invents new options during a session.
- Do not use custom type names such as
enum,select, or Airtable'smultipleSelects.
Document-specific composite types reused across multiple modules are declared in data_pool.md under a types: namespace.
Aliases are tolerated: string resolves to text; bool and boolean
resolve to the low-level checkbox boolean type. New human yes/no fields use
the canonical semantic type YesNo instead.
Literal Values
Quotes are required for text values:
testator_name|text: "John Smith"
postcode|text: "3000"Numeric, Yes/No, and date literals are unquoted:
item_count|number: 750
is_company|YesNo: Yes
settlement_date|date: 2026-06-15Select literals use the declared option name unquoted:
gift_priority|singleSelect[primary, backup, contingent]!: primaryInside expressions, compare select values using string literals:
mood|text: = IF(colour = "blue", "happy")Date literal format is ISO 8601 only: YYYY-MM-DD.
Multi-line markdown uses YAML block scalar |:
recital|markdown: |
WHEREAS the Vendor is the registered proprietor of the land;
AND WHEREAS the Purchaser has agreed to purchase on these terms.A field declared without an authored default, computed expression, or collected value is blank. Blank checks use equality against BLANK(), for example field = BLANK().
References
One @ syntax binds values, collection items, and block citations. There is no {{ }} interpolation, wiki-link reference form, or
filter pipeline.
Local and cross-module values use namespaced paths:
@data.notice_period
@termination.data.notice_period
@data_pool.data.has_minor_childrenNested values retain dotted paths. Inside block iteration, item is explicit:
@data.testator.address
@data_pool.data.primary_guardian.name
@item.legal_name of @item.street_addressCollection selectors remain available outside iteration:
@data.children[0].name
@data.children[handle=child_001].nameDo not include the local storage key value in a reference.
Block references use the block handle in authored source:
@termination_clause
@termination_clause:label
@termination_clause:number
@termination_clause:range
@termination_clause:handleA bare block reference renders the current linked label. :number cites the
first number; :range cites the span of the block's numbers. Either against an
unnumbered block is a typed compile failure.
Scalar values support the closed casing modifiers:
@data.executor_name:upper
@data.email:lowerModifiers occupy two slots in a fixed order: one resolution modifier
(:label, :number, :range, :handle) selecting what to resolve to, then
optionally one casing modifier (:upper, :lower) transforming the resolved
text.
@guardians:label:upper
@guardians:handle:lowerCasing follows resolution because it transforms an already-resolved result, so
@guardians:upper:label is malformed. Two modifiers of the same kind are a
duplicate-modifier failure. Casing composes with :label and :handle, which
resolve to text, and is refused against :number and :range, whose glyph case
belongs to the numbering scheme. On a bare data path casing still requires a
scalar field. Modifiers accept no arguments.
In plain terms: you may ask for one reading of a block — its label, its number, its number range, or its handle — and then optionally ask for that reading in capitals or lower case, in that order. Asking for two readings at once, or for the capitals before you have said what to capitalise, is refused.
The full modifier rules, including every typed failure, are in the DSL Agent Spec.
Authored references bind to stable blk_ or fld_ identities before
persistence. Stored references use the bracketed form
<@stable_key|readable_handle_hint>. Stable identity wins; authoring views
project the current readable handle from it.
References also bind in YAML metadata:
flow:
condition: AND(@data.has_partner = "Yes", @data.count > 1)
joinFor:
items: @data.executorsOnly reference tokens inside a condition carry @; the whole expression does
not.
Built-In Runtime References
Runtime references resolve against runtime state rather than authored data:
| Reference | Meaning |
|---|---|
uiMode | Retired. It encoded placement as a condition, and placement itself retired on 2026-08-25 — one venue, and nothing declares it (One venue). The rule that replaced it is Placement is never a condition. |
hasValues | Block-scoped hide-if-empty gate, derived by the compiler from the block's own references. Valid only in block flow.condition. See DSL Agent Spec → hasValues. |
runtime.doc_generation_mode | Retired with the DocumentGenerationMode type, and with the documentLifecycle axis it read (2026-09-01). Neither when nor where is asked any more: there is one venue, and two visibility filters — whether and who. |
hasValues is a read-only render-time context value, not a data field. Do not assign to it and do not reference it from computed-field expressions — only from flow.condition. hasValues on a module or field flow.condition is a lint error.
There is no render-time placement or mode reference — not in a condition and not on the envelope. Conditions carry data state only, and since 2026-08-25 nothing carries placement: every folio item lands on the document (One venue).
Use them in block conditions and compose with AND / NOT:
blocks:
- handle!: executors_fill_summary
label: Executors (fill summary)
flow:
condition: hasValues
joinFor:
items: @data.executors
separator: ", "
lastSeparator: " and "
display:
start_level: 3
content: |
You appoint: @item.name.The runtime. namespace is reserved. Authors must not declare data fields whose paths begin with runtime..
Reference results and failures
A valid reference resolves to value, blank, or unknown. Broken syntax,
unknown or ambiguous targets, invalid paths, invalid modifiers, missing item
scope, and invalid numbering references are typed failures. They never render
as silent empty strings.
Template Syntax Inside Content Blocks
content: is a presentation layer. It supports @ references only. It does
not support inline conditionals, inline loops, arithmetic, collection
functions, or logical functions.
Allowed:
@data.field
@data.field:upper
@data.field:lower
@content_block:numberNot allowed:
@IF(...)
@COUNT(...)
@LIST_JOIN(...)
@SUM(...)
@JOIN(...)
@AND(...)
@OR(...)Logic belongs on block metadata and data-block field declarations.
Block Iteration
Use block metadata for iteration. Both iteration operators use the same internal shape: iterationOperator.items = collection. Iteration applies to content blocks only.
repeatFor repeats a block once per item:
blocks:
- handle!: beneficiary_gift
label: Beneficiary gift
repeatFor:
items: @data.beneficiaries
content: |
@item.name receives @item.gift_description.joinFor renders the block body once per item and joins the results:
blocks:
- handle!: executor_names
label: Executor names
joinFor:
items: @data.executors
separator: ", "
lastSeparator: " and "
content: |
@item.name of @item.addressseparator and lastSeparator are scoped under joinFor; they are not valid as floating block-level keys. lastSeparator defaults to separator when omitted.
For MVP, list-to-prose rendering belongs in block-level joinFor with sibling intro/outro blocks, not scalar computed fields. List-to-scalar expression functions are not part of the MVP computed-field surface.
Whitespace is trimmed by default around each rendered block body before joinFor separators are inserted.
Reference failures should cite the field path, template location, module file, block handle, and failing reference. Do not silently render empty.
Conditions
Conditions are co-located with what they gate under that item's flow object. There is no standalone top-level flow: namespace and no separate conditions: namespace.
Module-level conditions gate whether the entire module runs in the interview and whether its blocks render:
module:
handle!: guardians
flow:
condition: '@data_pool.data.has_minors'Field-level conditions gate whether the agent asks the user for the field. They live on the field entry inside its data block:
fields:
backup_guardian_name|PersonLegalName:
flow:
condition: '@data.wants_backup_guardian'Block-level conditions gate whether the block renders:
blocks:
- handle!: guardian_appointment
label: Guardian appointment
flow:
condition: '@data.has_minor_children'
content: |
...Condition value forms:
condition: some_field
condition: some_field = "value"
condition: AND(some_field, other_field > 100)flow.condition must evaluate to true or false. Do not use value-returning formulas as conditions unless their final result is boolean.
No = prefix is required in flow.condition values. condition: = expression is invalid.
The same condition at multiple levels is intentional, not redundant. A field-level condition gates the interview question; a block-level condition gates rendered prose. The two protect different lifecycle stages.
Lazy Evaluation Of Computed Fields
All computed values are evaluated lazily. The expression text is stored verbatim in the local module file. Each time a value is read, the expression is parsed and evaluated against current data.
fields:
width|number!:
height|number!:
area|number: = width * height
large_area|checkbox: = area > 100Local document data after interview:
fields:
width|number!:
value: 12
height|number!:
value: 9
area|number: = width * height
large_area|checkbox: = area > 100User-entered values are stored under value on the field entry in the owning data block. Computed values retain their expression text with the = prefix and never store value. No persisted derived results and no dependency graph maintenance are required for MVP.
During the interview, the agent asks the user only for fields that are required, not yet collected, and not computed. Computed fields are skipped because their values come from evaluation, not user input.
See DSL Expressions for MVP operators and functions.