Goldie Blocks
Authoring

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 content or data.
  • 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 flow concern.
  • 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.condition decides 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 field value entries, using each field's label as the human prompt and the block's guide: for step framing.
  • Computed fields also live in data-block fields: but keep their = expression source and never store value.
  • Content blocks are rendered output units. Their content: may interpolate the module data namespace, repeat or join collections, and use flow.condition, but they are not interview prompts.
  • The module data namespace is the fold of its data blocks' values. Field paths are unique across all data blocks of one module, so @data.field never 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, and output live in the body under document:.
  • Frontmatter carries no created_at, updated_at, or edited_at. Document recency is derived from the maximum module edited_at in 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 current flow.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 its flow.condition evaluated 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, log diagnostic.dsl_parse_unclear.
  • module.description is one-line metadata.
  • Modules have no type. A data pool is simply a module whose blocks are all data blocks; the former auth type is expressed as access.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 list

The 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 for Collection fields.
  • item_handle — handle pattern for auto-generated collection item handles.
  • item_sum — cross-item sum rule on a Collection field; 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!:
  • item is a blueprint, not collected data.
  • item_handle is 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! stores person.legal_name and renders as @item.person.legal_name inside joinFor. If the intended runtime shape is flat, declare the child fields directly in item: 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: 100

In plain terms: the inheritance shares must add up to 100%, and the system — not just the interviewing agent — refuses a list that doesn't.

  • field must name a declared item field; total is 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 total at whole-number precision using the expression language's ROUND semantics — 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, not item_sum's.
  • Prose in label/guide may 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 write value: null.
  • Collected scalar fields: value: literal.
  • Collected composite fields: value: as nested YAML. No handle on single-cardinality composites.
  • Computed fields keep = expression and never store value.

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_case names.
  • Runtime singleSelect values store the selected option as a string, for example value: surviving_inheritors.
  • Runtime multiSelect values 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:

TypeBaseNotes
YesNocheckboxAuthored literals: Yes / No
PersonLegalNametextFull legal name as it appears on a driver's licence or passport
JurisdictionsingleSelectnsw vic qld act sa wa tas nt
StateAUsingleSelectact 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:

TypeFields
StreetAddressAUstreet|text!, suburb|text!, state|StateAU!, postcode|text!
CompanyLegalNamecompany_name|text!, company_acn|text!
PersonLegalNameAddressAUlegal_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 field

Inside repeatFor or joinFor, item fields use the explicit item scope:

@item.legal_name
@item.street_address.suburb

Each 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 as order.position — a gapped integer outside flow. Legacy rows may still carry after; 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 state

hasValues 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 / joinFor blocks 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 missing

A 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: hasValues

hasValues 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

CategoryOperators
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-level guide:

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 to block.handle!; UI and agent projections may fall back to the handle.
  • display carries start_level (1–4) and the four numbering flags — both optional, and both about how a block looks. It carries nothing about where a block goes: surfaces retired 2026-08-25 with the second venue. There is no display.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 data namespace (@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 optional status_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 no fallback filter.
  • 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 :content modifier — clause prose is never interpolated mid-sentence; clause structure belongs to display.
  • 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:number is 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.
  • :range cites the span where :number cites the first element: a block whose Markdown produces 3.3.1 through 3.3.4 reads 3.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 a duplicate-modifier failure. Optionally one casing modifier (:upper, :lower) follows, transforming the resolved text; two casings is also duplicate-modifier, and casing written before resolution (@guardians:upper:label) is malformed — casing applies after resolution.
  • Casing follows the resolution. :label and :handle resolve to text, so :label:upper composes; :number and :range refuse 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. fallback is 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 modifier

A 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's content: 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.

FieldHolds@
items: @data.giftsa field pathyes
flow.conditionan expression containing pathspaths 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 marked

Evaluation 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.executors

joinFor 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-level joinFor for list prose.

On this page