Goldie Blocks

Flow Schema

Authored flow contract — condition and adapt — shared by modules and blocks, with the retired after anchor

Phase scope: [skill] and [app]. flow is part of the authored DSL and the runtime contract. Modules and blocks both carry the same flow shape; this page is the canonical reference.

Purpose

flow answers two questions for any object that has it: whether, and how does this object hook into its host?

  • condition — whether (inclusion at render and interview time).
  • adapt — how (host-specific input/output bindings created during insertion or import).

The same shape applies to modules (hosted by a document) and blocks (hosted by a module).

Ordering is not one of flow's questions. It used to be — after was a relative anchor naming the sibling to render after. Order now lives outside flow entirely: authored order is sequence position, and stored order is order.position on the row. See Ordering.

`after` is retired

Authored source no longer writes after: order is position in the YAML sequence, and inclusion is condition. See Ordering Is Sequence Position.

The anchor conflated ordering with inclusion and failed once conditions entered — an anchor pointing at a conditioned-out sibling leaves the item nowhere to go. It also made cycles representable and required a topological sort on every read.

Stored form is now order.position, a gapped number on the row — see Ordering. Historical rows may still carry after, but no runtime path reads it and no current insertion path writes it.

Fields

Prop

Type

Ordering lives outside flow

Order is absolute; inclusion is conditional. The two are separate concerns and are separately expressed: a row's placement is a scalar on the row itself and is a static property of the document, independent of any runtime answer. Whether it appears is flow.condition. A block that only applies on one branch carries that branch's condition and sits in its natural position; if the branch does not fire it renders nothing and the rows after it close up. There is no anchor to lose.

FormWhere order lives
Authored (YAML)Position in the sequence. Nothing declares it.
Stored (Convex)order.position on the row — not inside flow.

Both forms are child-owned: a parent never stores an ordered list of its children. Reads sort children by their own order.position; the YAML sequence is the notation for that same scalar, not a separate mechanism. Moving a row is one write.

A detached row carries no position. The YAML sequence expresses placement only while the row sits in its list. A block or module exported on its own — for reuse, or handed to an agent — has nothing in its authored form recording where it sat, and the About facet edits one block in isolation so it shows no ordering field. This is a known gap, deferred while whole-document authoring is the only path in use. See the archived plan 2026-07-30-block-ordering-model → "Open — a detached child cannot state its own position". order.sibling_max below is the stored-side half of the same problem.

Stored form: order.position

order is a sibling of flow on blockVersions and moduleVersions, not a member of it:

Prop

Type

Positions are per-scope, and the two tiers never share a number line. Blocks sort against sibling blocks in one blockVersions.moduleVersionKey; modules sort against sibling modules in one moduleVersions.documentVersionKey. A block is never compared with a module, so both tiers use identical values harmlessly — modules 1000/2000/3000, and blocks 1000/2000/3000 within each module.

ObjectContaining scopeorder.position sorts against
ModuleThe document version (moduleVersions.documentVersionKey)Other modules in the same document version
BlockThe module version (blockVersions.moduleVersionKey)Other blocks in the same module version

Reserving one shared number line — modules in thousands with blocks in the gaps between them — was considered and rejected. It describes a line nothing traverses, and it reintroduces the coupling per-scope numbering exists to avoid: a module with more blocks than its allocation would overflow into the next module's range, and moving a module would strand its blocks' numbers outside it. Per-scope, a module's position is independent of its contents, so moving a module touches no block.

Gap is 1000. The gap decides only how many times the same pair of neighbours can be subdivided before values collide — each insert halves the remaining space, so 1000 allows nine. A drop between 1000 and 2000 writes 1500 and touches no neighbour; renumbering the tail on every insert is what makes a naive 1, 2, 3 index expensive and contended. Enough repeated drops into the same slot eventually exhausts the gap, and the answer is a renormalisation sweep over the scope, not a cleverer number.

Append is last + 1000, not a fixed slot. Appending is the common operation and must never subdivide; only insert-between does.

Ties break on the row's key ascending. Two rows may hold the same position — that stays representable rather than prevented, because a uniqueness constraint would turn every insert into a read-check-write while the tiebreak costs nothing. The key is already unique and already on the row, so the tiebreak is deterministic with no clock dependency.

A row with no position sorts after every positioned row, among themselves by key: "unpositioned" and "at position 0" are different facts, and an unmigrated or newly inserted row must be deterministic and last rather than silently ahead of authored content.

Retired after storage

The migration functions and anchor-order walks were removed after dev converged and the formal production dry run confirmed production held no version rows. Historical flow.after values are ignored. Ordering is structural metadata, not selection behavior.

It is not a derived selection facet; see Derived Selection Facets → Performs.

Condition

condition is an expression evaluated by the runtime. The grammar is the same as inline interpolation conditions and is owned by the DSL Expressions and DSL Syntax → References pages.

When condition evaluates falsy:

  • For modules, the module is skipped entirely. Its data is not collected during interview, and its blocks do not render.
  • For blocks, the block is skipped during render. Other blocks in the module continue to render.

condition may reference:

  • @data.<field> — the current module's data (for blocks, the parent module's data).
  • @<module_handle>.data.<field> — another module's data, including data_pool.
  • @runtime.<path> — runtime state. Not placement: since 2026-08-25 there is one venue and no block declares where it renders, so the retired uiMode reference is no longer valid in conditions (see Placement is never a condition). @runtime.doc_generation_mode is retired with the documentLifecycle axis it read (2026-09-01): a condition tests data, and there is no when filter left to test.

Bare paths and explicit comparisons

A condition can be a bare reference path or an explicit comparison expression. Both are valid:

# Bare path — the resolved value is coerced to a boolean.
# Suits genuinely boolean fields (`checkbox`); see the YesNo note below.
condition: '@data.wants_kids'

# Explicit comparison — evaluates the expression.
condition: '@data_pool.data.has_executor = true'

Truthiness rules for the bare-path form follow the DSL Expressions:

  • Truthy: true, non-empty strings, non-zero numbers, non-empty arrays, non-null objects.
  • Falsy: false, null, undefined, "", 0, [].

A bare path does not test whether a field exists — it tests the field's value. A field that has been answered with false or left empty is falsy. Use the explicit comparison form when "is the value exactly X" matters and X is not a boolean (e.g. condition: @data.status = "pending").

YesNo fields take the string form, never a boolean

YesNo carries the authored literals "Yes" and "No" — it is not a boolean, despite sharing checkbox as its base type. Always compare against the literal:

# Correct.
condition: '@data_pool.data.has_children = "Yes"'

Both boolean-flavoured forms are broken against a YesNo field, and neither fails loudly:

  • Bare path (condition: '@data.has_children') — every non-empty string is truthy, so "No" is truthy too. The condition passes for both answers.
  • = true (condition: '@data.has_children = true') — comparison is strict equality, and "Yes" === true is false. The condition never passes.

A checkbox field is boolean and takes the bare-path or = true form as described above. The distinction is load-bearing: a YesNo field holding a boolean passes validation, looks plausible in the YAML view, and silently stops every branch that reads it from firing. blockFold's validator rejects boolean writes to YesNo for this reason (2026-08-01).

Conditions on previously-collected data

Flow conditions can reference data on the same module, on data_pool, or on any other named module in the host document. This is how the interview chains questions: the runtime evaluates condition against data already collected, so a module or block is skipped — and its fields are never asked — when an earlier answer makes it irrelevant.

For example, a guardians module that only applies when the willmaker has minor children:

module:
  handle: guardians
  flow:
    condition: '@data_pool.data.has_minor_children'

If the user answered has_minor_children: false earlier in the interview, the guardians module is skipped: no guardian questions are asked, and no guardian blocks render. This is first-class behavior, not a side effect — flow.condition is the mechanism for adaptive interview flow.

A module's condition stays on the module

A module is also the reuse unit, so a guardians module carrying condition: '@data.has_minors' travels into documents that have no has_minors field. The condition stays where it is rather than moving to the host document's module-list entry as an overlay: a module keeps the ability to state its own applicability, which the overlay discards at first import.

Import remaps what it can through adapt. Whatever is left unresolved is visible Builder state, not a silent failure — a consumed path that resolves neither in the host namespace nor through an adapter is requires_setup in Selection Safety, computed per placement at read time rather than stored. The block still renders, carrying Builder-only chrome that reads as not yet seated rather than broken.

This is why adapt is not a one-time fix applied at import: it is a standing part of how references resolve in that placement.

Adapt

adapt is the insertion-time binding layer. It maps a path that exists in the host (input) to a path the inserted object consumes (output) when the two do not match natively.

adapt is authored or user-approved, not compiler-derived. A compiler can suggest likely adapters during insertion preview, but the final mapping is recorded explicitly because it changes meaning.

Shape

flow:
  adapt:
    - input: module_a.data.wants_children
      output: data.wants_kids

Adapter rules

  • input must resolve in the host module or target document tree.
  • output must match a consumed path derived from the inserted object.
  • input must be type-compatible with the value the output path is expected to provide.
  • runtime.* paths do not need adapters — they are always available.
  • Adapter outputs are virtual reads. They do not grant write authority and do not write data.

See Derived Selection Facets → Selection Safety for how adapt entries participate in insertion safety computation.

Worked example

A block that conditionally renders, with an adapter binding a host field to its consumed path. Its order is its position in the blocks: sequence, so nothing about placement appears in flow:

blocks:
  - handle!: guardian_condition
    label: Guardian condition
    flow:
      condition: '@data.wants_kids'
      adapt:
        - input: module_a.data.wants_children
          output: data.wants_kids
    content: |
      Guardianship provisions apply.

The same flow shape on a module:

module:
  handle: guardians
  label: Guardianship Provisions
  type: content
  flow:
    condition: '@data_pool.data.has_minor_children'

Cross-references

On this page