YAML and JSON Mapping
Shared conversion rules between authored YAML source and parsed JSON/runtime schema storage
Phase scope: this page is shared by
[skill]and[app]. YAML is the editable authoring source format. Parsed JSON is the validation, tool, import, storage, test, and runtime contract.
The local skill and app Build mode share the same authored YAML shape. The local skill reads and writes YAML/Markdown files directly. App Build mode may expose the same raw YAML to a user or AI editor during template authoring.
Runtime code, tests, importer previews, and Convex storage consume the parsed JSON shape. They should not depend on YAML-specific syntax or parse handwritten YAML on hot paths.
Purpose
- Defines the boundary between human/AI-editable authoring files and machine-readable runtime records.
- Keeps document, module, block, and data-field conversion rules in one place.
- Explains how
snake_caseYAML source maps tocamelCaseJSON/TypeScript/Convex fields. - Shows the concrete YAML-to-JSON transformations used by import, validation, runtime, and export.
- Applies the same conversion pattern uniformly to documents, modules, and first-class blocks.
Boundary Model
YAML is source. JSON is contract.
authored YAML
-> parse and validate
-> parsed JSON contract
-> import / store / execute / test
-> export authored YAML when neededProp
Type
Naming Rules
Authoring-side keys use snake_case. Parsed JSON, TypeScript, and Convex fields use camelCase.
| YAML source | Parsed JSON / Convex |
|---|---|
reuse_type | reuseType |
derived_from_document_key | derivedFromDocumentKey |
derived_from_module_key | derivedFromModuleKey |
document_version_key | documentVersionKey |
repeat_for when exposed as a source key | repeatFor |
join_for when exposed as a source key | joinFor |
The mapping is mechanical and one-to-one. Do not create alternate names for the same concept between local-skill files and app Build mode.
Document Mapping
A document folder maps to one documents row plus its version chain when imported into Convex. Export performs the reverse conversion from the stored JSON snapshot back to the authored folder shape.
YAML keeps a nested document: namespace because nesting is how authors group related fields. Convex storage is flat — each authored sub-key becomes its own column on documentVersions. The mapping is mechanical: importers split the namespace; exporters re-nest it.
Prop
Type
Example document YAML
handle: simple_will
label: Simple Will
reuse_type: working
derived_from_document_key: doc_01HV8XQZK4J9R9W3M6T4M2RT01
document:
description: A straightforward will with executor and beneficiary provisions.
requires_payment: true
defaults:
block:
display:
numbering: decimal
modes:
supports: [fill, edit]
default: fill
output:
generation:
supported: [preview, final]
default: previewParsed document JSON
{
"documents": {
"key": "doc_01HV8XQZK4J9R9W3M6T4M2R8E1",
"handle": "simple_will",
"label": "Simple Will",
"reuseType": "working",
"derivedFromDocumentKey": "doc_01HV8XQZK4J9R9W3M6T4M2RT01",
"headVersionKey": "docv_01HV8XQZK4J9R9W3M6T4M2RH09"
},
"documentVersions": {
"key": "docv_01HV8XQZK4J9R9W3M6T4M2RH09",
"documentKey": "doc_01HV8XQZK4J9R9W3M6T4M2R8E1",
"versionNumber": 1,
"reuseTypeSnapshot": "working",
"creationMethod": "imported",
"description": "A straightforward will with executor and beneficiary provisions.",
"requiresPayment": true,
"defaults": {
"block": { "display": { "numbering": "decimal" } }
},
"modes": { "supports": ["fill", "edit"], "default": "fill" },
"output": { "generation": { "supported": ["preview", "final"], "default": "preview" } }
}
}See Folios Schema for canonical table fields.
Module Mapping
A module file maps to one modules row plus its version chain when imported into Convex. The authored module: namespace fans out into flat columns on moduleVersions. Each entry in the authored blocks: array maps to a blocks row plus a blockVersions row pinned to the module version (blockVersions.moduleVersionKey). Authors write nested YAML; importers split it; exporters re-nest it and flatten the pinned blocks back into the file's blocks: array.
There is no authored data: namespace: field declarations live on kind: data blocks, and the module's dataSchema / data surfaces are derived from them — see Modules Schema → Derived Data Surfaces.
Prop
Type
Example module YAML
One module, shown on its own for readability. In authored source it is an entry in the document file's modules: sequence, not its own file — there is no document_input/modules/ folder. Its index in that sequence is its order; see Local Skills → One File Per Document.
module:
handle: executors
label: Executors
reuse_type: working
flow:
condition: data_pool.data.has_executor = true
blocks:
- handle!: executor_details
label: Executor details
kind: data
guide: |
Ask who administers the estate. Explain the executor role briefly if asked.
fields:
primary_executor|Person!:
question: Who is the primary executor?
wants_backup|YesNo:
question: Would you like to appoint a backup executor?
backup_executor|Person:
question: Who is the backup executor?
- handle!: appointment_clause
label: Appointment of executor
kind: content
flow:
condition:
display:
type: paragraph
level: 1
numbering: decimal
content: |
I appoint {{ data.primary_executor.name }} of {{ data.primary_executor.address }} as my executor.Parsed module JSON
{
"modules": {
"key": "mod_01HV8XQZK4J9R9W3M6T4M2RMD1",
"handle": "executors",
"label": "Executors",
"reuseType": "working",
"documentKey": "doc_01HV8XQZK4J9R9W3M6T4M2R8E1",
"headVersionKey": "modv_01HV8XQZK4J9R9W3M6T4M2RMV4"
},
"moduleVersions": {
"key": "modv_01HV8XQZK4J9R9W3M6T4M2RMV4",
"moduleKey": "mod_01HV8XQZK4J9R9W3M6T4M2RMD1",
"documentVersionKey": "docv_01HV8XQZK4J9R9W3M6T4M2RH09",
"versionNumber": 1,
"reuseTypeSnapshot": "working",
"creationMethod": "imported",
"flow": {
"condition": "data_pool.data.has_executor = true"
},
"order": { "position": 2000 }
},
"blocks": [
{ "key": "blk_01HV8XQZK4J9R9W3M6T4M2RB01", "handle": "executor_details", "label": "Executor details", "reuseType": "working", "moduleKey": "mod_01HV8XQZK4J9R9W3M6T4M2RMD1" },
{ "key": "blk_01HV8XQZK4J9R9W3M6T4M2RB02", "handle": "appointment_clause", "label": "Appointment of executor", "reuseType": "working", "moduleKey": "mod_01HV8XQZK4J9R9W3M6T4M2RMD1" }
],
"blockVersions": [
{
"key": "blkv_01HV8XQZK4J9R9W3M6T4M2RBV1",
"blockKey": "blk_01HV8XQZK4J9R9W3M6T4M2RB01",
"moduleVersionKey": "modv_01HV8XQZK4J9R9W3M6T4M2RMV4",
"versionNumber": 1,
"reuseTypeSnapshot": "working",
"creationMethod": "imported",
"kind": "data",
"order": { "position": 1000 },
"guide": "Ask who administers the estate. Explain the executor role briefly if asked.",
"fields": [
{ "key": "primary_executor", "type": "Person", "required": true, "question": "Who is the primary executor?" },
{ "key": "wants_backup", "type": "YesNo", "required": false, "question": "Would you like to appoint a backup executor?" },
{ "key": "backup_executor", "type": "Person", "required": false, "question": "Who is the backup executor?" }
]
},
{
"key": "blkv_01HV8XQZK4J9R9W3M6T4M2RBV2",
"blockKey": "blk_01HV8XQZK4J9R9W3M6T4M2RB02",
"moduleVersionKey": "modv_01HV8XQZK4J9R9W3M6T4M2RMV4",
"versionNumber": 1,
"reuseTypeSnapshot": "working",
"creationMethod": "imported",
"kind": "content",
"flow": { "condition": null },
"order": { "position": 2000 },
"display": { "type": "paragraph", "level": 1, "numbering": "decimal" },
"content": "I appoint {{ data.primary_executor.name }} of {{ data.primary_executor.address }} as my executor."
}
]
}The module's dataSchema and data surfaces are derived from the data blocks' fields at read time (or maintained as a cache) — they are not authored or imported columns.
See Folios Schema for canonical table fields.
Field Declaration Mapping
Field declaration markers are authoring syntax only. Field declarations live in a data block's fields: list. The stable field key is the part before the pipe. The pipe type, required marker, collection bounds, defaults, questions, and computed expressions become structured JSON metadata on the blockVersions.fields[] entry. A field-level value stores collected or pre-filled data, co-located on the same entry. Clean reusable sources (library blocks) must omit value; working document folders usually contain it.
| Authoring YAML | Parsed JSON |
|---|---|
notice_period|Duration!: | "notice_period": { "type": "Duration", "required": true } |
children|Person[1..5]: | "children": { "type": "Person", "collection": true, "minItems": 1, "maxItems": 5 } |
notice_deadline|date: = completion_date - notice_period | "notice_deadline": { "type": "date", "computed": { "expression": "completion_date - notice_period" } } |
your_name|PersonLegalName!: with label: Your Name | "your_name": { "type": "PersonLegalName", "required": true, "label": "Your Name" } |
A field's label is display text and carries no reference weight: the left of
the pipe stays the handle every @ binds to. An unlabelled field omits the key
entirely rather than storing an empty string.
Example field YAML
Inside a data block:
blocks:
- handle!: notice_details
label: Notice details
kind: data
fields:
notice_period|Duration!:
question: What notice period applies?
value: "30 days"
children|Person[1..5]:
question: List the willmaker's children.
value:
- handle: child_001
name: Sam Smith
- handle: child_002
name: Riley Smith
notice_deadline|date: = completion_date - notice_periodParsed field entries JSON
On the block version:
{
"kind": "data",
"fields": [
{
"key": "notice_period",
"type": "Duration",
"required": true,
"question": "What notice period applies?",
"value": "30 days"
},
{
"key": "children",
"type": "Person",
"collection": true,
"minItems": 1,
"maxItems": 5,
"question": "List the willmaker's children.",
"value": [
{ "handle": "child_001", "name": "Sam Smith" },
{ "handle": "child_002", "name": "Riley Smith" }
]
},
{
"key": "notice_deadline",
"type": "date",
"computed": { "expression": "completion_date - notice_period" }
}
]
}The derived module surfaces fold these entries: declarations (everything except value) fold into dataSchema; values fold into the data namespace. Collected runtime values use the same stable field keys as their declarations. Computed values store expression text only and are evaluated lazily by runtime logic — they never store value.
Block Mapping
Blocks are authored under a module's blocks: namespace. Each entry maps to a blocks row (stable identity) plus a blockVersions row (immutable snapshot) pinned to the containing module version. Authored YAML remains source; parsed JSON remains the storage/runtime contract.
Prop
Type
Example block YAML
blocks:
- handle!: executor_appointment_list
label: Executor appointment list
join_for:
items: data.executors
separator: "; "
last_separator: "; and "
display:
type: clause
content: |
{{ name }} of {{ address }}Parsed block JSON
{
"blocks": {
"key": "blk_01HV8XQZK4J9R9W3M6T4M2RB03",
"handle": "executor_appointment_list",
"label": "Executor appointment list",
"reuseType": "working",
"moduleKey": "mod_01HV8XQZK4J9R9W3M6T4M2RMD1"
},
"blockVersions": {
"key": "blkv_01HV8XQZK4J9R9W3M6T4M2RBV3",
"blockKey": "blk_01HV8XQZK4J9R9W3M6T4M2RB03",
"moduleVersionKey": "modv_01HV8XQZK4J9R9W3M6T4M2RMV4",
"versionNumber": 1,
"reuseTypeSnapshot": "working",
"creationMethod": "imported",
"kind": "content",
"order": { "position": 3000 },
"joinFor": {
"items": "data.executors",
"separator": "; ",
"lastSeparator": "; and "
},
"display": { "type": "clause" },
"content": "{{ name }} of {{ address }}"
}
}See Folios Schema for canonical table fields.
Import and Export Contract
Importers must validate before write, produce JSON diagnostics/import operations, and write object/version rows only after the parsed JSON contract is valid enough for the target operation.
Exporters must read a specific immutable version snapshot and emit the authored folder/file shape from that snapshot. Do not export by dereferencing mutable heads of source library rows unless the requested export target explicitly is that current head.
For blocks, export shape stays nested even though the app stores blocks in first-class blocks / blockVersions tables. Skill-side export writes blocks back into the parent module file under blocks:; block rows are a storage/query optimization, not a one-file-per-block package format. See Blocks Schema → Export Shape Is Unchanged.
Ordering across the boundary
Order changes representation at the boundary, in both directions. Authored YAML carries it structurally as sequence position; storage carries it as a scalar.
- Import. Assign
order.positionas(index + 1) * 1000per scope — modules against their document version, blocks against their module version. Never writeflow.after. - Export. Sort the scope by
order.positionascending (ties on the row'skey), emit in that order, and droporderfrom the emitted YAML. Re-importing the exported file reproduces the same relative order, though not necessarily the same absolute numbers — a document whose modules sit at 1000/1500/2000 exports and re-imports as 1000/2000/3000. Relative order is what round-trips; absolute values are not part of the authored contract. flow.afteron stored rows. Historical rows may still carry it, but import/export ignores it. No boundary operation may reconstruct ordering from an anchor.
The same rule holds one tier down: a data block's fields: is an ordered array whose index is interview order (see Field Declaration Mapping). Order must never be transported as an unordered map at any tier.
Example import preview JSON
{
"status": "valid",
"source": {
"path": "documents/simple_will/modules/executors.md",
"format": "yaml"
},
"operations": [
{
"op": "upsert",
"table": "modules",
"key": "mod_01HV8XQZK4J9R9W3M6T4M2RMD1",
"handle": "executors"
},
{
"op": "insert",
"table": "moduleVersions",
"key": "modv_01HV8XQZK4J9R9W3M6T4M2RMV4",
"moduleKey": "mod_01HV8XQZK4J9R9W3M6T4M2RMD1"
},
{
"op": "upsert",
"table": "blocks",
"key": "blk_01HV8XQZK4J9R9W3M6T4M2RB01",
"handle": "executor_details"
},
{
"op": "insert",
"table": "blockVersions",
"key": "blkv_01HV8XQZK4J9R9W3M6T4M2RBV1",
"blockKey": "blk_01HV8XQZK4J9R9W3M6T4M2RB01",
"moduleVersionKey": "modv_01HV8XQZK4J9R9W3M6T4M2RMV4"
}
],
"diagnostics": []
}Round-trip behavior may normalize formatting, ordering, and implicit defaults, but it must preserve semantic content:
- stable handles and field keys
- lifecycle and lineage metadata
- data declarations and collected values
- block ordering, conditions, display metadata, and content
- expressions as expression text
- references and interpolation paths
Comments are authoring aids only. Exporters may emit comments, but importers must not depend on comments for persisted meaning.