---
name: docujoint-format-authoring
description: Author rich docujoint format.yaml definitions — types, typed blocks, derived state, expression checks, evidence schemes, tree and index rules. Use when creating or evolving a documentation format enforced by docujoint.
---

# Authoring format.yaml

A format definition is a **profile on top of OKF**: everything you don't
constrain stays tolerant, so start small and tighten. The engine enforces
exactly what you declare — never invent a rule in prose that the YAML doesn't
carry.

## Mental model

- Three deterministic parse planes: the **tree** (placement + reserved files +
  index), each **file independently** (frontmatter → sections → typed blocks),
  and **links/evidence** (doc-to-doc links, scheme URIs reconciled against a
  scan).
- **State is derived, never stored.** Blocks forbid status columns and compute
  state from what is written (plus scan reconciliation), so docs cannot lie.
- Comments (`<!-- -->`, `%% %%`) are stripped before ANY parsing; the parser
  is fence-aware (`##` or `|` inside ``` is content, not structure).

## Skeleton

```yaml
format: { name: my-docs, version: "0.1", okf_version: "0.1" }
vault:
  reserved_files: [index.md, log.md]
  links:
    forbid: [{ rule: wikilink, severity: error, code: wikilink }]
    broken_internal: warn
  index: { generated: true }          # enables index validation + `dj index`
  tree:                               # placement: longest prefix wins
    - { path: "Flows/", types: [flow] }
schemes:                              # evidence URIs docs may cite
  repo: { pattern: "repo://[\\w.-]+/\\S+" }
  test: { pattern: "test://[\\w.-]+/\\S+" }
shared:
  empty_markers: ["—", "-", ""]
  stub_marker: "_To be documented._"
  enums: { q_status: [open, answered, deferred, wontfix] }
  # shared.colors: ONE colour vocabulary for every value renderers colour —
  # derive states, path kinds, graph node types, enum values, feedback
  # statuses. The ENGINE SHIPS NO VOCABULARY: it does not know that "open"
  # means caution or that "built" means done. Declare every value your format
  # produces, including the states your own `derive:` rules emit. Semantic
  # tokens (success · warning · error · info · brand · muted) adapt to
  # light/dark and give chips a matching background; raw CSS colours pass
  # through (validated). An undeclared value still renders — it just gets a
  # deterministic palette colour and carries no meaning.
  colors: { happy: success, unhappy: error, app: brand, database: "#0e7490" }
**Column drift.** The format DESCRIBES the documents, so when the two disagree
about what columns exist the engine says so:

- `<block>-column-undeclared` — a table carries a column the format does not
  declare, so its cells are untyped and unchecked.
- `<block>-column-unused` — the format declares a column no table has, so any
  `required:`, `enum:` or `refs:` on it applies to nothing.
- `<block>-required-blank` — a `required: true` column left BLANK. A declared
  empty marker (`—`) counts as answered: the author said "not applicable".

Mark a column `optional: true` when some tables legitimately carry it and
others do not — that silences `column-unused` for it and nothing else.

**Block-scoped checks.** Some things are only true of a SET, not a row. A check
may declare `scope: block`:

```yaml
    checks:
      - { code: features-owner-split, severity: warn, scope: block,
          when: "owner_varies",
          message: "assigned to more than one person" }
```

The context is booleans over all rows: `<col>` (any row has it), `<col>_varies`
(more than one distinct value), `<col>_all` (every row has it), `empty`.

**Inline controls.** A column may name an edit form; a single-field options
form then renders as a menu on the cell itself instead of a panel:

```yaml
    forms:
      set-status:
        set: { Status: "{value}" }        # consequences live here, as always
        fields: [{ name: value, kind: options, from_enum: Status }]
    display:
      columns:
        - { col: Status, style: chip, control: { form: set-status } }
```

`from_enum:` reads the choices from the column's DECLARED enum, so a value can
be offered before any row uses it — unlike `from:`, which reads a row's own
proposed options. A control adds presentation only: the write goes through the
same form, the same `set:`, the same referee gate.

Two refusals, both deliberate. A control must name an `edit` form, not an
append one. And a column that a `derive:` rule READS cannot carry one — derived
state is computed, never hand-edited, the same rule `forbid_columns` enforces.

Where the write LANDS is the host's business, not the format's: `dj dashboard`
applies it immediately (with undo), `--propose` stages edits into a change set
applied together, and a read-only host offers no control at all. One
declaration, every surface.

**Narrow layouts.** A table is unusable on a phone. `display.layout.narrow`
picks what the block becomes below the narrow breakpoint — `table` (default) or
`cards` — and `card:` on each column says what it becomes inside one:

```yaml
    display:
      layout: { narrow: cards }
      columns:
        - { col: Q,        style: mono, card: eyebrow }   # small, above the title
        - { col: Question, style: md,   card: title }     # the card's heading
        - { col: Status,   style: chip, card: badge }     # no label, sits inline
        - { col: About,    style: mono }                  # default: labelled field
        - { col: Notes,    style: md,   card: hide }      # dropped on narrow
```

It is ONE render: the same table markup carries `data-label` from each declared
column and restyles under a media query, so nothing is duplicated and no script
runs. An unknown layout or card role is a definition error, not a silent table.

markdown:
  comments: [html, obsidian]
blocks: { … }                         # see below
types: { … }
```

## Blocks: the heart of a rich format

A block claims a `## Heading` and parses its table(s) into rows:

```yaml
blocks:
  features:
    heading: Features
    columns:
      - { name: ID, id_grammar: "f<n>", unique: error, sequential: warn }
      - { name: Feature, required: true }
      - { name: Kind, enum: shared.feature_kind, unknown: warn }
      - { name: Implemented, refs: [repo, api, db] }   # cells cite evidence URIs
      - { name: Gap }
      - { name: Tests, refs: [test] }
      # `type:` — what the cell HOLDS. Omit it and the column is text, which is
      # what every column was before this existed. Declare it and the engine
      # REFUSES a cell that breaks it, at error level, where the record is
      # parsed — so nothing downstream ever has to defend against bad data.
      - { name: Estimate, type: number, unit: days }
      - { name: Reviewed, type: date }                 # YYYY-MM-DD
    forbid_columns:
      - { name: Status, severity: error, code: feature-status-column }
    derive:                            # FIRST MATCH WINS (CASE semantics)
      state:
        - { when: "implemented && !gap && ref_broken", then: drift }
        - { when: "implemented && !gap", then: built }
        - { when: "implemented && gap", then: partial }
        - { when: "!implemented && gap", then: missing }
        - { when: "!implemented && !gap && open_question_about", then: unspecified }
        - { else: unknown }
    checks:                            # row-level lint, same expression language
      - { code: feature-unbound, severity: warn,
          when: "implemented && !gap && !has_refs",
          message: "claims complete but cites no evidence" }
      - { code: feature-empty-table, severity: warn, on: empty-table,
          message: "section exists but records no rows yet" }
    display:                           # how consumers render the rows
      intro: prose
      columns:
        - { col: ID, style: mono, state: marker }
        - { col: Implemented, style: md }    # md = inline code/links + colored URIs
        - { col: Kind, style: plain }        # chip = enum value pill · mono = code
      # explorer: enables an interactive PATH TREE on documents carrying this
      # block: root → branch docs (whose `children` frontmatter field names
      # this doc) → paths (colored by `kind` via shared.colors) → result
      # boxes, where equal `result` labels CONVERGE. Give rows a short
      # canonical Result column. ANY number of blocks may declare explorers —
      # a document renders one tree per explorer it participates in.
      # explorer: { kind: Kind, result: Result, children: parent_flow }
      # graph: chains a document and its `children` into a PROCESS GRAPH from
      # this block's rows — one row per transition: `to` names the next node
      # (a LINKED cell resolves to a document node; plain text becomes a
      # converging terminal outcome box) and `when` carries the business rule
      # drawn on the edge (revealed on hover). Rollback cycles are detected
      # and drawn as dashed return edges instead of stretching the layout.
      # graph: { to: To, when: When, children: parent_flow }
```

**Media.** Documents may embed images with standard `![alt](path)` — files
live IN the vault (any non-.md file), referenced root-absolute or relative
to the document. Refs are parsed into the IR; a local ref that resolves to
nothing is a `broken-media` warning. Renderers embed resolved media as data
URIs up to `vault.media: { embed_max_kb: 1024 }` (the page stays
self-contained); oversized files degrade to a labeled placeholder. Prefer
SVG for diagrams — text-based, diffable, small. Policy is enforced: a local
ref that resolves to nothing is an ERROR (`vault.media.missing:` to relax);
a local file above `prefer_remote_kb` (default = embed cap) warns toward
cloud storage — heavy captures don't belong in git. Fix with
`dj media upload --file <rel> --replace` or `dj media push`
(uploads to `--endpoint <url>`, rewrites the doc refs to the returned URL).

**Expression language** (derive `when:` and checks): identifiers, `!`, `&&`,
`||`, parentheses. Identifiers resolve to: column truthiness (cell not an
empty marker) + builtins `ref_broken` (a cited URI the scan no longer
confirms), `open_question_about` (an open question points at this row — see
the `questions:` wiring below), one `cites_<scheme>` per declared scheme
(e.g. `cites_test` — a URI of that scheme is cited; a syntax-level claim
without an inventory, a resolved one with), and in checks additionally
`has_refs` and `has_<column>_column`.

**Intra-file refs**: a column can point at another block's ids —
`ref: { block: features, column: ID, dangling: error, code: q-about-dangling }`.
`block:` also takes a LIST when the thing a cell is about lives in more than
one table —
`ref: { block: [features, tickets], column: ID, dangling: error, code: q-about-dangling }`
— so ONE `open-questions` block serves a `flow` (features) and an `epic`
(tickets). A block may name ITSELF — `Depends` on a tickets block with
`ref: { block: tickets, column: T, ... }` types "this row waits on those rows"
— and a cell may carry SEVERAL values, split on ` · ` or `,` ("t1 · t3"
resolves each id on its own; one bad name never hides the good ones beside
it). A value resolves if any named block in THAT FILE carries it (the resolved
edge lands on the row as `rowRefs`, with the target's uuid when the record has
one); otherwise it is the declared `dangling:` finding, naming every block
that would have been valid, with the loader's own did-you-mean over the ids
that exist. Resolution stays per-file: a named block the document's type
does not carry answers for nothing. A `block:` naming an undeclared block fails
the load — inside a list the other targets keep resolving, so a typo would
otherwise be silent. The wiring behind
`open_question_about` is DECLARED on the questions block, never guessed:
`questions: { ref_column: About, open_when: "status == 'open'" }` — any
status vocabulary works, and a derive rule reading `open_question_about`
without the declaration is a load-time finding.

**Forms — declarative write actions.** A block can declare how readers write
back to the documents; the engine has exactly two primitives (edit a row's
cells, append a row) and everything else is your declaration:

```yaml
    forms:
      reply:                                   # edit: targets a row by its FIRST column value
        label: Answer
        set: { Resolution: "{text}", Status: answered }
        fields:
          - { name: text, label: Answer, kind: textarea, required: true }
      add:                                     # append: a new row from templates
        label: Comment
        row: { C: "{auto}", Comment: "{text}", Author: "{author}", Date: "{date}" }
        fields:
          - { name: text, kind: textarea, required: true }
          - { name: author, kind: text }
```

Field kinds: `text`, `textarea`, `date`, `number` (`min`/`max`/`step`),
`rating` (`max`, default 5 — stars), `image` (paste/pick, downscaled and
uploaded to the host's endpoint, thumbnail on the record), `section`
(structure, not data — a titled divider), and `options`. Every field takes
an optional `hint:` (muted helper text). Typed kinds validate their values
server-side — junk never enters the ledger. `options` reads proposed
choices from a column of the target row, Claude-Code style:

```yaml
        - { name: choice, label: Answer, kind: options, from: Options, mode: single, other: true, required: true }
        - { name: note, label: Additional context, kind: textarea }
```

`from:` names any column (author proposals there, ';'-separated — declare an
optional column like `Options` on the block); `mode: single|multi`; `other:
true` (default) adds a free-text escape hatch. Rows without proposals
degrade to plain text input. Submissions carry the selection in `{name}`
AND the unaccepted proposals in `{name}_rejected` — exactly the context an
agent needs to see what the user turned down. Propose options whenever you
author an open question you can already see the likely answers to.

Templates fill `{fieldname}` from submissions plus `{date}` (ISO today) and
`{auto}` (next id minted from the block's `id_grammar`). Submissions are
sanitized (pipes escaped, newlines collapsed) and every write is
referee-gated — refused if it would introduce parse errors. Rendered
dashboards show the forms only when served with a write endpoint
(`dj dashboard`); a static file stays read-only. Apply from the CLI with
`dj annotate --concept <path> [--block <name>] --action <form> [--row <key>] --field k=v`
— with `--block` the action resolves against that block's declared forms;
without it, against the concept's TYPE's frontmatter forms (next section).
Use forms for the workflows that close documentation loops: answering open
questions, posting comments (declare a comments block), proposing rows.

**Frontmatter forms — a TYPE's own write action.** A type may declare
`forms:` too: the same grammar over its declared frontmatter fields instead
of a block's columns. This is what makes a concepts-mode timeline's bars
draggable (see the view-composition skill):

```yaml
types:
  epic:
    frontmatter:
      required: { title: string, start: date }
      optional: { end: date }
    forms:
      reschedule:
        label: Reschedule
        set: { start: "{s}", end: "{e}" }
        fields:
          - { name: s, kind: date, label: Start }
          - { name: e, kind: date, label: End }
```

Edit-only — frontmatter has no row to append, so the `row:` spelling fails
the load loudly. The loader validates every target: it must be a DECLARED
frontmatter field of the type (did-you-mean on a miss); `type`/`uuid` are
never settable (identity); only `date`-kind fields are writable (every other
frontmatter field is load-bearing structure — hierarchy roots, graph edges,
facets); and each `set:` template must be a bare `{field}` naming a
`date`-kind form field (or the built-in `{date}`), so a composite or literal
template can never compose a non-date into a declared date field. The write
re-validates the composed value with the timeline's own calendar grammar
(2026-02-30 is refused, not just misshapen strings). It lands as a
byte-preserving line splice over the `---` region — only the edited keys'
lines change; comments, key order and quote styles survive byte-for-byte —
and shapes the splice cannot re-emit (block scalars; keys carrying list
items, continuation or nested lines anywhere in their carry window) are
refused rather than rewritten into orphaned bytes. Invoke it with a
BLOCK-LESS op: `dj annotate --concept <path> --action <form> --field k=v`
(no `--block`), the dashboard's `/annotate` endpoint, or a `/propose` change
set op naming no block — same referee gate as every other write.

**Feedback — the capture layer (repo-agnostic).** Where forms write the
documents directly, `feedback:` records reader input in a LEDGER
(`feedback.jsonl`), linked to any entity — a whole document or a specific
row — without touching the docs. An agent later reads the ledger
(`dj feedback list --json`), applies accepted items to the docs
(`dj annotate`), and advances their status. Statuses are yours to
declare, with a default for new submissions:

```yaml
feedback:
  statuses: [new, acknowledged, applied, rejected]   # your vocabulary
  default: new
  capture:
    comment:                       # on any document (optionally: types: [actor, page])
      on: concept
      label: Leave feedback
      fields:
        - { name: text, label: Comment, kind: textarea, required: true }
        - { name: author, label: Name }
    answer:                        # on the rows of one block
      on: rows
      block: open-questions
      label: Suggest answer
      fields:
        - { name: text, label: Proposed answer, kind: textarea, required: true }
```

Each capture kind declares its submission semantics: `submit: append`
(default — every submission is a new record; comments, multiple proposals)
or `submit: replace` (ONE record per entity+kind — the single source of
truth; a new submission supersedes the old under the same id and resets
its status for re-triage). Use replace for answer-like captures, append
for comment-like ones.

`dj dashboard` exposes `POST /feedback` (targets validated against the
parsed vault — ghost concepts/rows are refused) and renders capture buttons
plus pending records with status chips; a static `dj dashboard` render
can display records (`--feedback feedback.jsonl`) but never capture. Prefer
feedback over forms for anything reader-facing: capture stays decoupled
from doc-write access, which is what keeps the dashboard portable across
repos and hosting.

## Types

```yaml
types:
  page:
    frontmatter:
      required: { title: string, description: string }
      optional: { tags: "string[]", timestamp: date, app: string,
                  status: { enum: [draft, live] } }
    sections: { expect: [Features], duplicates: error }   # missing → warn
    blocks: [features, open-questions]
    flags: { canonical: { from: "tags contains canonical" } }
```

Declared optional/required fields beyond the core become **queryable metadata**
(dashboard `where: "app == Storefront"`) and render as badges — declare the
fields you want to group or filter by (e.g. `app`, `database`, `status`).
Give join targets a short key (a database's `name: main`) so metadata joins
and `db://main/...` URIs agree.

## `about:` — say what your names MEAN

`enum: [chore, spike]` pins a vocabulary and says nothing about what "spike"
means here. `about:` is where you say it, on a **type**, a **block** or a
**column** — prose the parser KEEPS, unlike a `#` comment, which is stripped
before the loader ever sees it and therefore reaches nobody.

```yaml
blocks:
  tickets:
    heading: Tickets
    about: The unit of reviewable work — one row per change a reviewer can accept alone.
    columns:
      - name: Kind
        about: |-
          NOT priority.
          "chore" — routine upkeep, no decision to record.
          "spike" — a time-boxed investigation we are willing to throw away.
        enum: [chore, spike]
types:
  epic:
    about: A body of work with an outcome. If it has no outcome it is a label, not an epic.
```

It is prose: it changes no finding, no parse and no render. What it changes is
who can READ your choices — `dj catalog --format format.yaml` prints your
types, blocks and columns beside the engine's catalogue, so an agent about to
fill in a table can learn what your columns mean. This skill teaches the
format LANGUAGE; only `about:` can teach YOUR vault, because no skill has
read it. Write it wherever a name would otherwise need a hallway conversation.

## Pitfalls (each one cost a real debugging session)

- YAML: quote `"string[]"` in flow maps; keep `enums:` under `shared:`.
- Escape literal `|` in cells as `\|`; a cell-count mismatch is an error.
- One `# Title` per document; `##` starts sections; deeper headings are content.
- Multiple tables under one heading are allowed — a row whose first cell
  repeats the header's first cell starts a new table.
- Unknown mermaid diagram kinds, unclosed fences/comments, and comments hiding
  `##` headings are lint findings — don't fight them, fix the doc.
- A column holding a number needs `type: number`, or every total computed from
  it is a guess. Without it, `18.00` and `about five` are equally acceptable
  cells, the bad one is silently skipped by any sum, and `WHERE cost > 10`
  compares as TEXT — where `'5.00' > '10'` is TRUE, because `'5'` sorts after
  `'1'`. Declaring the type moves the guarantee to the record.
- Keep the DISPLAY string and the MACHINE value in separate columns. `$5.00/mo`
  is for people and belongs in a plain text column; `5.00` with
  `type: number, unit: USD/month` is what sums. One cell cannot do both — a
  currency symbol makes the whole value unparseable.
- `unit:` goes on the COLUMN, not in the cells, because it is true of all of
  them. If the unit genuinely varies per row it needs its own column, and
  totals across mixed units need a conversion rate and a date — which is a
  business rule, not something the format decides.
- A declared empty marker (`—`) in a `type: number` column is FINE and becomes
  NULL, which is what keeps an average honest. Writing `0` for "not
  applicable" is the mistake: NULL is excluded from `AVG`, `0` drags it down.

## Workflow

Iterate with `dj lint --all --vault <dir> --format format.yaml` after every
change; treat warnings as the incompleteness report and errors as broken
parses. Pin your format with a sample vault that lints 0/0.
