Goldie Blocks
Authoring

Numbering

Numbering as a projection over Markdown — the six-slot scheme, nesting rules, the four numbering flags, and the two-pass resolution

Numbering is a projection over Markdown. Markdown is the source format; numbers are derived when a document is projected to the canvas, Word, PDF or HTML, and are never stored in content.

Principles, in order of authority:

  1. Markdown is the source of truth.
  2. Numbers are never stored in the content.
  3. Numbering is derived during projection.
  4. The same Markdown projects under different schemes.
  5. Numbering behaviour is declarative and deterministic.

The model is not legal-specific. A document may be an agreement, a policy manual or a procedure; level names are set per document, and the machinery is the same.

Schema location: only a content block stores numbering, in its payload (Folios Schema → content_block). Documents, modules and data blocks store none. The scheme is not stored on the document either: a block's optional format names a scheme in the app-published catalogue, and absence means the app default.

One place numbering is stored

FolioStores
DocumentNothing. It has no payload
ModuleNothing. Its numbering control writes onto the content blocks inside it
Content blockpayload.numbering: { format?, level?, restart, headings, paragraphs, lists } — authored as start_level and numbering: { resets, headings, paragraphs, lists }
Data blockNothing. Its payload is its fields

A module generates no heading of its own and carries no level in the hierarchy — hierarchy comes only from block levels. A container's numbering control is a bulk write onto its descendant content blocks, not a stored parent value, so a Title module's blocks carry start_level: 1 themselves, and a schedule restarting numbering is its first block's resets.

Amended 2026-09-30 (GB-253): this page used to describe three stored tiers — a document scheme with base defaults, module defaults with sequence: reset, and block overrides — resolved first-setter-wins. The Folio schema unification kept only the content block's own numbering, and this section, the resolution rule and Resets below now describe what the store holds.

The six-slot scheme

Every document has exactly six levels. They are fixed slots, not a growable list, so level 3 always means the same position and nothing shifts when a template is reconfigured.

scheme:
  1: {label: Title,        glyph: none}
  2: {label: Section,      glyph: none}
  3: {label: Clause,       glyph: decimal,     compound: false, format: "{n}."}
  4: {label: Subclause,    glyph: decimal,     compound: true,  format: "{n}"}
  5: {label: Paragraph,    glyph: lower_alpha, compound: false, format: "({n})"}
  6: {label: Subparagraph, glyph: lower_roman, compound: false, format: "({n})"}
PropertyMeaning
labelDisplay name only. Optional; falls back to "Level 3"
glyphCounter style: none, decimal, lower_alpha, upper_alpha, lower_roman, upper_roman
compoundWhether the number includes its ancestors (5.1.2) or stands alone ((a))
formatHow the counter renders, including punctuation and brackets

glyph: none means the level renders no number and consumes no counter.

Incrementing a level always resets every deeper counter. There is no per-level control over this — see Open items for the deferred resets_children flag that would need one.

Typography, spacing, indentation and fonts are not part of numbering. They come from the renderer's document style.

A new document ships with a preset. The default is the six levels above; a second preset drops the legal labels for non-legal use.

start_level, not level

A block stores start_level: where its outermost # sits in the scheme. Everything inside is relative to it. A block does not have a level, which is why the field is not named as though it does.

The selectable range is 1-4, stated once in packages/domain/src/numbering/start-level.ts (START_LEVELS, startLevelSchema, isStartLevel); the stored column, the About card's reader and the projection's guard all read that declaration (GB-160). A scheme has six slots, but 5 and 6 carry the glyphs paragraphs and lists render with, so they are not choosable as a block's outermost heading.

A block has no type. It holds Markdown, and that Markdown is normally a mix — a #, some paragraphs, a ##, more paragraphs. A block-level type: heading would describe its first element and mislabel the rest, so no such field exists. Element kind comes from parsing the Markdown at projection time and is never stored.

Markdown heading depth is relative to start_level:

MarkdownLevel, where start_level: 3
#3
##4
###5

start_level is stored on the content block itself (payload.numbering.level). A block with none starts at the Clause level, 3 — a new block should be given its level when it is created, so no block relies on that fallback. There is no module or document level to inherit from.

A block whose start_level pushes an element past level 6 is a resolve-time error. It is never clamped — a clamped document looks nearly right, which is worse than one that fails.

`start_level` is 1–4, though the scheme has six slots

Levels 5 and 6 carry the glyphs paragraphs and lists render with — (a) and (i). They are not choosable as a block's outermost heading. A block at start_level: 5 would put its paragraphs on 6 and leave its lists nowhere to go, so the picker offers four levels and the annotation is |range(1-4).

Level 4 is already effectively leaf-only: add a ## and its paragraph reaches 6, so no list fits beneath it. Blocks with subheadings and lists start at 3 or above.

Nesting rules

Paragraphs hang off the nearest heading

Paragraphs number one level below the nearest preceding heading. A heading opens a level; everything following it, until the next heading, sits one deeper.

Headings and paragraphs share the counter at each level. Whichever comes first in document order takes the next number. A heading differs only in that it also opens a deeper level for what follows.

A clause with a single paragraph still gets a number — there is no special case for a lone paragraph, which keeps the counter logic free of lookahead and makes every paragraph addressable.

At start_level: 3:

# Confidentiality                                   3.      Confidentiality

The Recipient must keep it confidential.            3.1     The Recipient must keep it confidential.

The Recipient must limit access.                    3.2     The Recipient must limit access.

## Exceptions                                       3.3     Exceptions

The obligation does not apply if required by law.   3.3.1   The obligation does not apply if required by law.

## Duration                                         3.4     Duration

The obligation continues for three years.           3.4.1   The obligation continues for three years.

It survives termination.                            3.4.2   It survives termination.

Each further heading depth takes the next level down:

# Services                                          3.      Services
The Consultant must provide the Services.           3.1     The Consultant must provide the Services.
## Standard                                         3.2     Standard
The Consultant must act with skill.                 3.2.1   The Consultant must act with skill.
### Qualifications                                  3.2.2   Qualifications
Personnel must hold current registration.           3.2.2.1 Personnel must hold current registration.

Qualifications is 3.2.2, not 3.2.1.1: it is a level 5 element, and the paragraph above it already took 3.2.1 from the same counter.

Accepted consequence: paragraph numbers depend on heading position. Inserting a ## above a paragraph moves it from 3.2 to 3.3.1. This is the conventional legal reading — a subheading is a numbered provision with children, not a signpost — and the trade was made deliberately.

Lists

Markdown lists number one level below the paragraph that introduces them, and each nesting level goes one deeper:

## Exceptions                                       3.3     Exceptions
The Recipient may disclose if:                      3.3.1   The Recipient may disclose if:
- required by law                                           (a)  required by law
- the Discloser consents                                    (b)  the Discloser consents

Levels accumulate across headings, paragraphs and lists alike, so the six slots are a real budget:

ElementLevel, where start_level: 3
# heading3
paragraph, or ## heading4
paragraph under ##, or ### heading5
list item under that paragraph6

One nested list item beyond that is level 7 and a resolve-time error, not a clamp. Deeper drafting starts the block at a lower level.

The Markdown marker carries no meaning. All list items are stored as plain dashes, and the scheme decides how they render at that level: (a), (i), 1. or a bullet. A stored dash is convention-neutral, so one firm's template renders a sublist as (a) and another's as (i) from the identical block. Authoring with dashes is the house convention for every block in the library.

A list level carries its own glyph and format like any other level, so ("n") and (a) are per-level scheme settings rather than list-specific machinery.

Tables

Consecutive | a | b | lines are one table — a signing block. The |---| divider row, if any, is dropped. A table is never numbered: it takes no number, moves no counter and opens no level, so the line after it numbers as though it were not there (GB-329, 2026-10-02). The canvas draws it as a plain table at the heading's words, Word as a borderless table and the PDF as a table. The canvas editor does not type into a table; edit it in Code.

# Execution                                         1.      Execution
| Signed by the Client | ______ |                           Signed by the Client   ______
Dated on the date last signed.                      1.1     Dated on the date last signed.

Two rules that are easy to get backwards

An unnumbered element consumes no counter but still opens a level

This is the single exception to "consumes nothing", and it is deliberate.

An unnumbered element takes no number, so absence of a number never leaves a gap in the sequence. But it does open a level for what follows — which is what decides the glyph a list takes.

At start_level: 3 with numbering: {paragraphs: no}, the paragraph sits at level 4 and its list at level 5, so the list renders (a) from level 5's lower_alpha — not 1. from level 4's decimal:

                                                            The Recipient may disclose if:
- required by law                                           (a)  required by law
- the Discloser consents                                    (b)  the Discloser consents

The alternative collapses the list up to its parent's level, which means turning paragraphs: no on silently changes list glyphs throughout the document. A list should look like a sublist whether or not its parent happened to be numbered.

The cost, stated plainly: because an unnumbered ## still opens a level, headings: no does not flatten paragraph numbering, and inserting a subheading still moves paragraph numbers. That stability property was only available under the collapsing reading and is unavailable in every configuration.

Lists never compound; paragraphs do

A list item renders (a), a nested list item (i), whatever number sits above it. No prefix, no condition. 3.3.1(a) never appears.

This is the paragraph/list distinction. A paragraph is a numbered provision in the hierarchy, so it compounds onto its parent heading — 3.3.1 under 3.3, from level 4's compound: true. A list is an enumeration within a paragraph, local to it, so it starts fresh at (a) or (i). Levels 5 and 6 carry compound: false to say so.

An earlier draft made list compounding conditional — honour compound where an ancestor number exists, stand alone otherwise. With lists never compounding at all, that condition can never fire, and a conditional that cannot trigger is a trap for whoever writes pass two. The rule is flat.

The four numbering flags

Four independent controls, each yes, no, or absent:

numbering:
  headings: yes
  paragraphs: no
  lists: yes
  resets: no

Expanded block-style YAML is the canonical authored form: put each flag on its own line, indented beneath numbering:. The equivalent inline flow mapping — numbering: { headings: yes, paragraphs: no, lists: yes, resets: no } — is valid YAML and may be used when compactness is useful, but the expanded form is easier to edit and produces clearer diffs.

FlagWhere it may be setSays
headingscontent blockWhether # elements take numbers
paragraphscontent blockWhether paragraphs take numbers
listscontent blockWhether list items take numbers
resetsblock onlyWhether counters restart at this block's start_level and deeper

The four sit under one numbering key because they answer one question at four grains. A flat numbered_headings / numbered_paragraphs / numbered_lists triple says the same thing with the noun repeated.

resets is the one that names an action rather than an element kind. It shares the key because it is a numbering decision made at the same place; splitting it out would mean two numbering keys on a block for no gain. The current Display facet exposes the schema key resets directly in typed source.

These are not glyph choices. Which counter style renders at a level — decimal, roman, alpha — belongs to the document scheme, so a firm's convention is set once rather than per block. The flags say only whether an element participates in numbering at all.

Rules:

  • An authored block stores all three flags; unanswered ones are saved as yes (convex/folio/numberingPlan.ts). A block that authors no numbering at all stores no numbering object.
  • Each control is all-or-nothing within its block. Either every heading in a block is numbered or none is; the same for paragraphs, and for lists. There is no per-element exception.
  • Where a block sets yes at a level whose scheme glyph is none, the scheme wins and the block control is disabled with a note. Silently numbering one heading in an unnumbered tier reads as a rendering bug.
  • Data blocks and guide blocks are never numbered and consult nothing.
  • An unnumbered heading is still a heading. It keeps its kind and depth through pass one, so the renderer gives it heading typography; only the counter ignores it. Suppressing a number does not turn a heading into a paragraph.

Worked flag examples

numbering: {headings: no} — both # and ## render bare while still shaping the levels beneath them:

# Confidentiality                                           Confidentiality
The Recipient must keep it confidential.            3.1     The Recipient must keep it confidential.
## Exceptions                                               Exceptions
The obligation does not apply.                      3.1.1   The obligation does not apply.

The # consumes no counter, so the level 3 slot stays available and the block's paragraphs begin at 3.1. The ## likewise takes no number, but it still opens level 5, so the paragraph under it is 3.1.1 rather than continuing at 3.2. No number is skipped: the unnumbered headings take nothing, and every number that does render is consecutive within its level.

numbering: {paragraphs: no} — headings keep their numbers, and no level is consumed beneath them:

# Confidentiality                                   3.      Confidentiality
The Recipient must keep it confidential.                    The Recipient must keep it confidential.
## Exceptions                                       3.1     Exceptions
The obligation does not apply.                              The obligation does not apply.

numbering: {lists: no} — list items render as bullets, the rest untouched. This is the control that makes a genuine bullet list possible inside numbered content: the scheme sets the glyph for every list at a level, so without a block-level override one lettered sublist anywhere would force every list at that level to be lettered.

The Recipient may disclose if:                      3.3.1   The Recipient may disclose if:
- required by law                                           •  required by law
- the Discloser consents                                    •  the Discloser consents

The resolution rule

There is no chain: each content block's numbering is its own. What a blank answer means is stated once, in convex/folio/numberingPlan.ts (resolveNumberingFromDisplay): level is start_level, restart is resets ?? false, the three participation flags default to yes, and a block that authors nothing stores no numbering object. Blocks in the same schedule share a decision because the module's control wrote the same value onto each of them.

resets is block-only: a module boundary is marked by its first block.

Resets

One reset control, unscoped — it sets the counters at a point in the stream and the stream carries forward from there.

  • numbering: {resets: yes} on a block (stored as restart: true) restarts counters at the block's own start_level and everything deeper; higher levels are untouched. So a module of six blocks numbering 1–6 becomes 1, 2, 3, then 1, 2, 3 again where the fourth block sets it.

A schedule or annexure that restarts numbering sets resets on its first block. The earlier design put a sequence: {reset: n} on the module for this boundary case; it was never stored, and the engine no longer accepts it (2026-09-30).

Accepted consequence: a document with resets renders the same number more than once, so "clause 2" is ambiguous to a reader. That is what resetting means, not a defect in it. References are unaffected — they bind to stable block identities and resolve through pass two's map, so a reference to a block in the second run resolves to that block whatever its rendered number collides with.

Rendering: two passes

Two passes, with a flat array as the contract between them.

Pass one — resolve

Walk the document's blocks in reading order — the caller has already dropped any a condition or view filter withholds. Read each content block's Markdown with the canvas's own line grammar (chunkContent), so the lines the engine numbers are exactly the lines the screen draws, and emit its elements with a resolved absolute level. A --- line is a page break: it takes no number and moves no counter.

Resolving the level is this pass's work: a heading's level is the block's resolved start_level plus its Markdown depth minus one; a paragraph's is one below the nearest preceding heading; a list item's is one below its paragraph, going a level deeper per nesting. Elements the flags leave unnumbered are marked as such here, so pass two can skip them without knowing why.

The output contains no modules:

[
  { "key": "blk_a1", "level": 2, "kind": "heading",   "text": "Operative Provisions" },
  { "key": "blk_b2", "level": 3, "kind": "heading",   "text": "Confidentiality" },
  { "key": "blk_b2", "level": 4, "kind": "paragraph", "text": "The Recipient must ..." },
  { "key": "blk_c3", "level": 3, "kind": "heading",   "text": "Intellectual Property" }
]

Pass two — number

Take the flat array. Hold one counter per level. For each element that participates in numbering, increment its level's counter and reset every deeper counter. Apply the scheme's glyph and format.

Output is a map from key to number path:

{ "blk_b2": [3], "blk_c3": [4] }

Why the split matters:

  • The engine is trivially testable. Input is a flat array of level and kind; output is a list of numbers. Every convention can be unit tested without a document, a module or a database.
  • Conditions cannot corrupt numbering. Hidden blocks were dropped in pass one, so they were never in the stream and cannot consume a counter.
  • Cross-reference resolution is a map lookup. Nothing traverses anything.
  • Numbering never modifies stored Markdown.

Cross-references

The :number mention modifier reads pass two's output: it resolves a block key to a number path and formats it using the scheme. Numbers are resolved per document at render time and never stored.

Pass two emits paths, not formatted strings — emitting only strings would mean parsing numbers back out of the output. The reference resolver itself lives with the @ reference module; see DSL Spec → References.

Two properties of this projection are what make "split the block to cite one element" safe advice:

  • A block's numbers are contiguous. Conditions drop whole modules in pass one, so nothing removes an element from mid-block and leaves a hole in its span.
  • Splitting a block is number-neutral. Both halves keep their start_level, paragraphs still hang off the nearest preceding heading, and the counter walk sees the same element sequence. Only an explicit resets: yes moves counters at a block boundary.

Worked example: a whole document

document: consulting-agreement

- module: front-matter
    - block: doc-title           start_level: 1    # "# <@doc_meta.title|Consulting Agreement>"
    - block: heading-operative   start_level: 2    # "# Operative Provisions"
- module: services
    - block: services-scope      start_level: 3    # "# Services"
    - block: services-standard   start_level: 3    # "# Standard of Care"
- module: confidentiality
    - block: conf-obligation     start_level: 3    # "# Confidentiality"
    - block: conf-exceptions     start_level: 3    # "# Permitted Disclosure"
- module: term
    - block: term-duration       start_level: 3    # "# Term"
- module: schedule-services
    - block: sched-heading       start_level: 2    # "# Schedule 1 - Services"
    - block: sched-scope         start_level: 3    resets: yes   # "# Scope"

Projection:

Consulting Agreement
Operative Provisions
1.    Services
1.1   The Consultant must provide the Services.
2.    Standard of Care
2.1   The Consultant must act with reasonable skill.
3.    Confidentiality
3.1   The Recipient must keep the Confidential Information confidential.
3.2   The Recipient may disclose the Confidential Information if:
      (a)  required by law
      (b)  the Discloser consents in writing
4.    Permitted Disclosure
5.    Term
5.1   This agreement starts on the Commencement Date.

Schedule 1 - Services
1.    Scope
1.1   ...

Clauses 1 to 5 run straight through four modules: the counter never resets at a module boundary because it never sees one. Levels 1 and 2 carry glyph: none, so the title and the section heading render unnumbered and consume no counter.

Authoring surface

Typed source, not a widget

The display: keys are edited as monospace key: value text inside the block's About rows, alongside the rest of the block's declaration. Typing no is about as quick as operating a control, needs no explaining, and gives the human, the agent and the export one shared surface with one grammar.

An interactive chip row was prototyped and rejected: it needed a roving tabindex, arrow keys contesting the block navigation the card already owns, a reveal-on-hover clear affordance or a three-state click cycle to learn, and a third glyph for unset — all to replace typing three letters.

What is given up: changing one flag means editing text, and a typo is possible where a control would have made it impossible. The parser's validation messages carry that weight.

Canonical YAML in About

About always renders and accepts the block declaration as YAML. Display uses ordinary nested maps, with one flag per line:

start_level: 3
numbering:
  headings: unset
  paragraphs: no
  lists: unset
  resets: no

flags is a fixed set of named keys, each independently yes or no, and every key in the set is always written. That is the point — a record of only the set values shows what is on but never what could be. numbering: [headings] would not reveal that paragraphs and lists exist; the full map does. The same argument rules out writing only the overridden keys inside a flags record.

Where a field participates in the resolution chain, a key may also read unset. This is a display value only: the formatter writes an absent key as unset on the way out, and the parser turns it back into absence on the way in. Storage keeps absence as the single representation. The word is unset rather than inherit because it names the state rather than its consequence, and cannot be misread as a value someone chose. The former |flags and |range(1-4) annotations are accepted only as compatibility input; About never emits them.

Display editing in the current block card

The current block card does not render a separate read-only summary row under the work bar. Display settings are edited as typed source in About's Display facet:

start_level: 3
numbering:
  headings: unset
  paragraphs: no
  lists: unset
  resets: no

start_level also has a direct picker on the content block's gutter glyph because authors change structural level frequently. The numbering flags remain in the Display facet alone; the work bar does not repeat them as filled or hollow indicators.

Within the typed source, unset means the block has not answered; an unset switch numbers (yes) and an unset start_level is the Clause, 3. It is a display value only: parsing converts it back to absence. resets is block-only and uses yes or no; it does not inherit.

Content resolves, About shows source

The two facets divide Markdown between them:

  • Content renders resolved. A ## appears as a styled heading carrying its projected number; no ## is visible. This is what a drafter reads.
  • About's content: block literal shows the raw Markdown source, ## included. This is where a drafter changes structure.

Heading structure is therefore About-only. A drafter cannot type ## in Content to make a heading — Content is for prose, About is for structure. This is a deliberate narrowing of today's behaviour, where the Content facet accepts anything the renderer tolerates.

Typography follows the element and numbering follows the flags, independently: heading styling is per-chunk, which is what keeps an unnumbered ## looking like a heading.

start_level in two places

start_level appears in the gutter menu and under display: in About; the flags appear in About only.

  • Menu: start_level alone. It is the one structural fact an author changes constantly while drafting, so it earns a one-click reach from the gutter with no panel. It shows the resolved level, muted where inherited.
  • About: everything, start_level included. display: is the complete record of the block's display envelope; omitting the level would make it an incomplete view of its own concern.
  • Flags: About only. The numbering flags are set when a block is authored or templated, not adjusted while writing.

The level is the single field reachable two ways, so it is the single field where an open dirty About buffer and a menu selection can conflict.

Placement is not a display key

The display facet says how a block looks, never where it goes. Since 2026-08-25 nothing says where: there is one venue — the document — and every folio item lands on it. See Permissions and Visibility → One venue.

History — this section specified `surfaces`, retired 2026-08-25

A third display key stood here. surfaces: {form: yes, document: yes} declared which of two venues a block appeared on — "two venues, not four", replacing the earlier four-value targets list (surface-form, surface-document, export-form, export-document) on 2026-07-30. Both are retired and neither is authorable; display holds start_level and numbering only. The completed lineage is at Permissions and Visibility → The completed lineage.

In plain terms: a clause used to carry a note saying which of two views it belonged in. There is one view now, so the note is gone.

Option vocabulary

FieldWhereValues
start_levelcontent blockInteger 1–4. Resolve-time error outside that range, or where content pushes an element past level 6
labelscheme levelAny string. Optional; falls back to "Level N"
glyphscheme levelnone, decimal, lower_alpha, upper_alpha, lower_roman, upper_roman
compoundscheme levelyes, no. Always no at list levels — lists never compound
formatscheme levelTemplate string with {n}, e.g. "{n}.", "({n})", "(\"{n}\")"
numbering.headingscontent blockyes, no, absent
numbering.paragraphscontent blockyes, no, absent
numbering.listscontent blockyes, no, absent
numbering.resetscontent blockyes, no, absent

glyph: none and numbering.*: no differ in scope, not effect: none unnumbers a level across the whole document; the flags unnumber a kind of element within one block. Both consume no counter.

Open items

  • resets_children, deferred. Removed from the scheme 2026-07-30. It would let a level increment without resetting deeper counters, so clause numbering could run continuously across numbered sections — Section 2 opening at clause 8 rather than clause 1. A real convention, but unreachable in the default preset, where levels 1 and 2 have glyph: none and so cannot reset anything. Every worked example above relies on the unconditional reset. Reinstate it when a template genuinely needs numbered sections with continuous clauses, and write the examples at the same time.
  • Skipped heading depth. Decide the rule where an author writes # then ### inside one block: normalise to the next level, or reject.
  • Word output. Literal text numbers, or native Word list numbering. Native survives a lawyer editing the docx and renumbering; literal does not.
  • Section ranges. A section is conventional, not structural — a level 2 heading block starts one and it runs until the next — so selecting, moving or conditioning a whole section needs a derived range helper, built once.
  • Final versions. Where a document is finalised, decide whether the number map is snapshotted so citations in correspondence stay accurate against later drafts.

On this page