Flow Schema
Authored flow contract — condition and adapt — shared by modules and blocks, with the retired after anchor
Phase scope:
[skill]and[app].flowis part of the authored DSL and the runtime contract. Modules and blocks both carry the sameflowshape; 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.
| Form | Where 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.
| Object | Containing scope | order.position sorts against |
|---|---|---|
| Module | The document version (moduleVersions.documentVersionKey) | Other modules in the same document version |
| Block | The 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, includingdata_pool.@runtime.<path>— runtime state. Not placement: since 2026-08-25 there is one venue and no block declares where it renders, so the retireduiModereference is no longer valid in conditions (see Placement is never a condition).@runtime.doc_generation_modeis retired with thedocumentLifecycleaxis 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" === trueisfalse. 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_kidsAdapter rules
inputmust resolve in the host module or target document tree.outputmust match a consumed path derived from the inserted object.inputmust be type-compatible with the value theoutputpath 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
- Folios Schema — where
flowis stored onmoduleVersions.module.flow, andorderalongside it. - Folios Schema — where
flowis stored on eachblockVersions.flow, andorderalongside it. - Ordering Is Sequence Position — the authored half of the same contract.
- Derived Selection Facets — how
consumesandflow.adaptcombine into selection safety. - DSL Expressions — grammar for
condition. - DSL Syntax → References — reference path grammar shared by
conditionandadapt.