Goldie Blocks
Authoring

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 data

There 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: identity

blocks: 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.condition gates whether a module, field, or block applies.
  • flow.adapt binds 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 flow is omitted, the item is included, in declaration order.
  • If flow.condition is 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 options

The 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) > 1

Without =, 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:

ContextExpression marker
Derived field valueRequired: `key
flow.conditionImplicit: condition: expression
Block repeatFor.items / joinFor.itemsImplicit: collection path
Template interpolationReference 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:

  • label
  • flow
  • question (legacy input only; do not author on new fields)
  • item
  • item_handle
  • default
  • value

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:

LayerShapePurpose
Schema / authoring declarationflattenedCompact field catalog, validation, diffs, and compiler diagnostics.
Runtime instance storagenestedHuman-readable instance data that mirrors the real-world structure being collected.
Agent evaluation / render pathspath-basedDeterministic 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: 14

Evaluation paths:

children[0].name
children[handle=child_001].age
children[].age
children.name      # inside repeatFor/joinFor item scope

Every 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.
  • item defines the schema for one future runtime item. It 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 placeholder. It is not template interpolation.
  • Collected runtime items store a stable handle generated from item_handle or from the collection field key fallback.
  • Do not author item_1, item_2, or runtime handles in the schema. Handles belong under collected value items.

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: 50

Author-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].share

Inside 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 Methods
  • singleSelect[...] 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's multipleSelects.

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-15

Select literals use the declared option name unquoted:

gift_priority|singleSelect[primary, backup, contingent]!: primary

Inside 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_children

Nested 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_address

Collection selectors remain available outside iteration:

@data.children[0].name
@data.children[handle=child_001].name

Do 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:handle

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

Modifiers 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:lower

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

Only 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:

ReferenceMeaning
uiModeRetired. 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.
hasValuesBlock-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_modeRetired 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:number

Not 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.address

separator 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 > 100

Local document data after interview:

fields:
  width|number!:
    value: 12
  height|number!:
    value: 9
  area|number: = width * height
  large_area|checkbox: = area > 100

User-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.

On this page