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:
- Markdown is the source of truth.
- Numbers are never stored in the content.
- Numbering is derived during projection.
- The same Markdown projects under different schemes.
- 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
| Folio | Stores |
|---|---|
| Document | Nothing. It has no payload |
| Module | Nothing. Its numbering control writes onto the content blocks inside it |
| Content block | payload.numbering: { format?, level?, restart, headings, paragraphs, lists } — authored as start_level and numbering: { resets, headings, paragraphs, lists } |
| Data block | Nothing. 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})"}| Property | Meaning |
|---|---|
label | Display name only. Optional; falls back to "Level 3" |
glyph | Counter style: none, decimal, lower_alpha, upper_alpha, lower_roman, upper_roman |
compound | Whether the number includes its ancestors (5.1.2) or stands alone ((a)) |
format | How 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:
| Markdown | Level, 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 consentsLevels accumulate across headings, paragraphs and lists alike, so the six slots are a real budget:
| Element | Level, where start_level: 3 |
|---|---|
# heading | 3 |
paragraph, or ## heading | 4 |
paragraph under ##, or ### heading | 5 |
| list item under that paragraph | 6 |
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 consentsThe 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: noExpanded 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.
| Flag | Where it may be set | Says |
|---|---|---|
headings | content block | Whether # elements take numbers |
paragraphs | content block | Whether paragraphs take numbers |
lists | content block | Whether list items take numbers |
resets | block only | Whether 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
yesat a level whose schemeglyphisnone, 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 consentsThe 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 asrestart: true) restarts counters at the block's ownstart_leveland 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 explicitresets: yesmoves 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: noflags 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: nostart_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_levelalone. 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_levelincluded.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
numberingflags 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
| Field | Where | Values |
|---|---|---|
start_level | content block | Integer 1–4. Resolve-time error outside that range, or where content pushes an element past level 6 |
label | scheme level | Any string. Optional; falls back to "Level N" |
glyph | scheme level | none, decimal, lower_alpha, upper_alpha, lower_roman, upper_roman |
compound | scheme level | yes, no. Always no at list levels — lists never compound |
format | scheme level | Template string with {n}, e.g. "{n}.", "({n})", "(\"{n}\")" |
numbering.headings | content block | yes, no, absent |
numbering.paragraphs | content block | yes, no, absent |
numbering.lists | content block | yes, no, absent |
numbering.resets | content block | yes, 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 haveglyph: noneand 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.