# Changelog

Notable changes, newest first. Versions follow [semver](https://semver.org);
while the major is 0, a minor may break something and will say so here.

## Unreleased

- **The operating record (E43 §13).** The record page and peek's properties
  rail rows ARE the controls: a column declaring `control:` renders the same
  trigger, the same ladder and the same `applyControl` POST the table cell
  renders — one shared builder (`controlbutton.tsx`), so the two surfaces can
  never drift. The ladder gains its TEXT rung (an edit form whose single
  non-section field is `text`/`textarea` opens as a modal from a pencil-glyph
  trigger), which gives the record page title-edit and body-edit with zero new
  format keys. A `control:` naming a form that fits no rung now warns
  **`control-unbound`** at load — a new user-visible diagnostic; the control
  renders nothing and the form keeps its row-action surface — and definition
  warns now also print when rendering (`dj dashboard --out`) and serving, not
  only under `dj lint` / `dj parse`. Precedence rule: the record view's title
  column never renders the text-rung trigger in list/card cells (a title click
  reads, never renames); its rename affordance is the record page's pencil.
- **One collection, two projections (E43 §14).** The board widget gains a
  board ⇄ list toggle: the list projection renders the SAME filtered
  collection through the rows presentation (dense table mode, clamps, cell
  controls), filter and search state survive the flip, and the choice
  persists per widget id (localStorage `dj-proj`, non-default only). New
  format key: the board widget accepts **`toggle: off`** to pin the board
  projection — the only payload byte the feature spends, and only when
  declared. The board's append footer now opens its form as a MODAL (the
  board is a spatial surface, the E19 board-card precedent) — which is where
  §15's composer renders.
- **The composer (E43 §15).** A modal append form now renders as Linear's
  composer, DERIVED from what the form already declares — zero new format
  keys: the required text field bound bare to the block's title column
  becomes the borderless hero input, the first textarea the description
  area, and every options/person/record field a chip in the chip row, each
  opening the picker its kind already owns (the one menu palette, the
  directory palette, the relation picker). A "Create more" toggle keeps the
  modal open across submits through the existing onDone seam, resetting the
  panel and returning focus to the title; the posted op is byte-identical to
  what the old layout posted. Append forms' `row:` binding now travels in
  the payload beside `set:` so the client can read the title binding.
- **Radios die; options are a chip everywhere (called-out).** The
  radio/checkbox fence is replaced by the chip-and-menu control in every
  presentation (modal edit forms and stacked panels keep their labeled
  layout with the chip swapped in). Serialization keeps the exact wire
  bytes via the hidden-input precedent — same names, same `'; '` joins, the
  same `_rejected` companion computed from the declared choices minus the
  picks. **Semantic change:** the free-text "Other:" escape now renders
  ONLY where the field declares `other: true` — the historical default-ON
  escape let every options field silently undermine its declared
  vocabulary; a format relying on it declares the one key it always meant.
  One degenerate corner moved with the fence: `_rejected` is now computed as
  declared-choices-minus-picks (the spec's own definition), so a typed Other
  value equal to a declared label is no longer simultaneously accepted AND
  rejected in the posted body; the wire format itself is byte-identical.

## 0.25.0 — 2026-08-13

- **Relations become visible and queryable (E38 t3+t4).** Record chips render
  resolved titles and open the peek; a picker adds a relation through the one
  write door; removing from the inverse end patches the record that actually
  stores it. Graph views gain a record mode drawing `rel:<name>` edges, and
  the query grammar gains `has` plus derived fields — so "blocked" is a
  `where:` clause, not a feature. The linear template declares blocks /
  blocked-by / relates-to / duplicate-of and ships a Blockers view; no
  relation cells ship, so a fresh scaffold is unchanged.
- **`surface: form` (E40 t1).** A standalone intake page rendering one
  declared form: submit lands an operational record through the guarded
  doors, and the thank-you state degrades by sentence rather than showing a
  reader an unsubstituted token. `dj form` renders it; the surface kind
  tables move together so a half-declared kind fails the build.

## 0.24.0 — 2026-08-13

- **The relation grammar (E38 t1).** `relations:` declares the vocabulary —
  names, direction, inverses — and a block carries one by declaring
  `relation: <name>` on an authored column: nothing synthesized, no reserved
  prefixes, `from:` derived from who declares it. Targets are uuids (the
  portable address), gated by identity and tier, shape-checked in the record
  funnel (non-uuid, self, >64, duplicates), and resolution-checked at lint
  with the canonical scope rule. `style: record` and `kind: record` enter the
  catalogue; their chip renderer and picker land next release.
- Relation cells prove themselves cloud-side with ZERO platform changes —
  they ride the same cell machinery as every other column, which was the
  design's claim and is now a contract.

## 0.23.0 — 2026-08-13

- **`compileRecordWrite` (E40 t1, engine half).** The record-write compiler
  extracted from `annotateRecords` — one compiler for every record write,
  container or none: the cloud's operational command door consumes it
  directly, and `annotateRecords` is now a container shell around it.
  Byte-identity pinned: compile + container writer equals the old path
  exactly, refusal sentences included.
- **Forms learn who may run them.** `open: true` on an append form says any
  member may file this; `self: <PersonColumn>` on an edit form says the
  person that column names may run it — the grammar of capture-class writes,
  with `{actor}` substituting the resolved handle in `set:` templates.
  Findings: `form-open-canonical`, `form-self-not-person`, `form-self-append`.
- One id-number kernel: `nextId`, `maxRecordNumber` and the new
  `nextAutoId` all reduce through `maxIdNumber` — three allocators, one
  answer.

## 0.22.0 — 2026-08-13

- **The side peek.** Opening a record from a board or table shows it in a
  panel over the view you were reading — scroll, filters and place survive;
  Escape and the scrim return focus to the cell you opened from; prev/next
  walk the collection in its rendered order. A direct `#/r/` link still opens
  standalone: the peek is how a context opens a record, not a new address.
- **Board swimlanes.** `rows: { by: <column> }` beside `lanes:` — a declared
  second axis, bands collapsing and persisting like the sidebar. Drag stays
  within a band on purpose: a cross-band move would need two referee-gated
  writes, and a half-landed pair leaves a card where nobody dropped it; the
  band column's own control menu is the honest cross-band path.
- **The facepile.** Two or more people in one cell stack into overlapping
  initials with an honest +N and the whole roster in the label; a single
  handle keeps the exact chip bytes it always rendered. One builder, server
  and client, like every person surface before it.

## 0.21.0 — 2026-08-13

- **The comments composer (E36 t5).** The record view's dj/comments section
  becomes a living thread: fetch-on-open, a markdown composer built on the
  E34 mention control (@-people and tags work day one, Cmd/Ctrl+Enter posts),
  optimistic append with refusal-restore, own-message edit/delete,
  resolve/reopen — and honest read-only degrade where the door says no.
  Writing at conversation speed, no referee between colleagues.

## 0.20.0 — 2026-08-13

- **Back-references (E37 t4).** Mentions become views: "mentions of @me" and
  the record view's `of: subject` sections answer from the index using the
  vocabulary @me already speaks.
- **dj/work (E36 t6).** The cross-KB spine as a BINDING, not a table: an
  operational block maps its columns onto identity/title/state/people/dates
  roles; `closed_when` names the closed states the wipe report and the
  archive read. Skip the binding and your KB is invisible to org-wide
  queries — opt-in, never a tax.
- **Extension without forking (E36 t2).** A format extends an installed
  plugin's schema in its own namespace; plugin upgrades touch only
  plugin-owned columns, so an extended KB survives an upgrade intact.
- **dj/activity (E36 t4).** Declared tier MIXED and saying so: git history is
  the canonical activity log for free; the plugin supplies only what git
  never saw.
- **dj ops archive (E35 t5 CLI).** Close-out as one command: the snapshot
  document lands on the _archive/ shelf through the referee, archived reads
  answer with the shelf address, and the work index rebuilds.

## 0.19.0 — 2026-08-12

- **The record view (E37 t1).** A view whose subject is ONE record: properties
  from declared columns, prose, plugin slots, addressable at `#/r/…` — and
  identity cells on boards and tables open it. Collection views stop being the
  only way to see work.
- **The mention index (E37 t2, engine half).** The parser collects `@handle`
  mentions per document, block and row the way it collects links — prose and
  cells, fence/code/email-safe, person columns split per the chip grammar.
  `dj mentions` queries it; `dj parse --json` carries it; the payload gains a
  `mentions` key only on formats that adopted the new grammar.
- **The plugin contract (E36 t1, engine half).** A plugin declares schema,
  surface and tier; `plugins:` installs by name from the `dj/*` registry;
  operational plugin TABLES are refused at compile (one operational store —
  an operational plugin ships block definitions). `dj/comments` is declared:
  its tables, its thread widget, tier conversational.

## 0.18.0 — 2026-08-12

- **`dj pull --records` (E35 t4).** Materialises a KB's operational records
  into `.records.local.yaml` mirrors — wholesale rewrite, per-block `op-`
  stamps in a `# dj:pull` header, stale mirrors deleted, the one gitignore
  pattern ensured idempotently. `dj rows patch` falls back to the mirror's
  header stamp when no read was recorded, so a cloned repo plus one command
  is a working picture whose writes still meet the revision guard.
- **`dj ops wipe-report`** prints the working-layer honesty check: what would
  be lost if the ephemeral store were cleared tonight.
- **The linear template is born migrated**: Issues declare
  `tier: operational` and ship no committed records — a new KB starts with
  its working layer in the database, no migration ever needed.
- Operational writes say what they are: "updated <uuid> · working layer at
  op-N (no commit — operational write)" instead of a committed line with
  placeholder garbage.

## 0.17.0 — 2026-08-12

- **The tier declaration (E35 t1, decision 0021).** A block may declare
  `tier: canonical | operational | conversational`; canonical is the default
  and normalizes to absence, so a tier-less format compiles and renders
  byte-identically. Non-canonical blocks require `identity: uuid`, refuse
  committed records in both spellings (inline rows and `.records.yaml`
  entries), and suppress empty-table checks — their canonical body is a bare
  heading forever.
- **The interchange layer (E35 t2 groundwork).** `.records.local.yaml` is the
  materialised working copy of operational records: exempt by filename,
  refused when committed, git-ignored by one stable pattern. `serializeSidecar`
  round-trips byte-stable; `validateRecords` exposes the validation funnel to
  hosts; `resolveOwner`/`idOf` resolve any location up to its owning entity;
  one text flattener serves the parser and the graph.

## 0.16.3 — 2026-08-12

- **A mention is atomic only once it NAMES somebody.** `@fff` typed toward
  the picker resolves to nobody, so it stays ordinary text: Backspace takes
  one character and a typo is fixable. A resolved handle, and any document
  link (nobody hand-types one), still deletes whole. With no directory at
  all, nothing is atomic — exactly the plain-text behaviour fields had before
  mentions existed. The tag paints under the same rule, so what looks like a
  tag deletes like one.
- **The mention menu is driven from the field, with honest keys.** Arrows
  move the highlight, Tab or Enter takes it, Escape leaves the text alone —
  and the numeric badges are gone from this menu, because the reader is
  typing and a digit belongs in the text, not in a shortcut that never fired.

## 0.16.2 — 2026-08-12

- **A mention behaves — and looks — like ONE thing.** Backspace beside a
  mention deletes the mention, not its last letter: erasing
  `[Feedback Capture](</Flows/Feedback.md>)` used to take 40-odd keystrokes
  through a path the reader never typed. A selection is still the reader's
  own intent and is never overridden, and away from a mention the browser's
  editing stands untouched.
- Text fields paint a tag behind each mention (a person's handle as typed, a
  document link as its title). The value a field holds and submits is
  unchanged portable text — the tag is a mirror layer, aria-hidden and
  pointer-transparent, so a browser that lays it out badly costs a highlight
  and never the field.

## 0.16.1 — 2026-08-12

- **@-mention candidates arrive in SECTIONS.** A list mixing one person with
  seven epics made the reader classify by eye; the menu now draws a header
  whenever the kind changes — People first, then documents under the type
  their own format declared (Epic · Decision · Service…). The section names
  are the corpus's vocabulary, never the engine's: a vault about kitchens
  groups recipes and suppliers without the engine learning either word.
  Typeahead hides a section whose every item is filtered out, and a menu
  whose items declare no group renders exactly as it always did.

## 0.16.0 — 2026-08-12

- **Person-scoped queries (E34 t5).** A filter value spelled `@me` scopes a
  view to whoever the host says is reading — resolved client-side from the
  live directory's own answer, because the baked artifact is role-independent
  by design (one bake, every reader). With no host naming the reader, `@me`
  matches NOTHING and the token says why: an empty board reads as "nothing
  assigned to me", so a filter that quietly matched everyone would lie in the
  one direction nobody checks. Any `@handle` value matches a cell's handles
  rather than its text, so a row assigned to two people answers to either.
  A value carrying no `@` compares exactly as it always did — the vocabulary
  is opt-in by the reader's own spelling.
- Facets on a person column label their buckets with display names while
  their values stay handles: readers pick a person, documents keep the
  portable spelling.
- **The linear template speaks person (E34 t4).** `Assignee` renders as a
  chip, the `assign` form offers the host's directory, a starter
  `people.yaml` ships beside the format, and a "My issues" view is there to
  type `@me` into.

## 0.15.0 — 2026-08-11

- **The person kind (E34 t1+t2, decision 0020).** People enter the engine as
  a HOST-SUPPLIED DIRECTORY, never as engine knowledge: documents store
  handles as portable text (`@felipe` or `felipe`), the format names what a
  person column means, and the host maps handles to display names. No
  declaration → every person surface degrades to plain text, and the payload
  grows no new key (the step-0 byte pins stay green by construction).

  - **Column style `person`** — a handle, or a comma/space-separated list,
    drawn as chips: a round initial plus the resolved display name. An
    unresolved handle renders as the muted handle itself — a state, not an
    error, so a vault read away from its host still reads. One chip builder
    (`render/personchip.ts`, the statusicon precedent) serves the server
    cell ladder and the client alike.
  - **Field kind `person`** — the palette menu over the directory (display
    name over @handle); a pick writes the HANDLE. With no directory the
    field is a plain text input: degrade, never disable.
  - **People mentions** — after `@` in a form text field, directory entries
    are offered beside document matches (people first, capped at 5); a
    person pick inserts `@handle`, a document pick keeps the markdown link.
    Without a directory the behaviour is byte-identical to before.
  - **The directory seam** — `buildAppPayload` input gains `people:`
    (`{mode: inline, entries}` or `{mode: url, url, entries?}`), carried
    verbatim as `D.people`. A url directory is fetched once per page-life by
    the client (`ui/peoplesource.ts`, the rowsource doctrine: reading never
    blocks, a dead endpoint costs nothing) and re-resolves baked chips
    through their `data-handle` identity when it lands.
  - **The local host** — `dj dashboard`/`dj serve` accept `--people <file>`
    and auto-discover `people.yaml` beside format.yaml
    (`people: [{handle, name}]`, extra keys carried through). A malformed —
    or explicitly named but missing — file is one warn finding
    (`people-file-invalid`) and no directory; everything else degrades
    silently by design. dj serve re-reads the file per rebuild and sweeps
    its mtime, so a rename lands in an open page.

## 0.14.0 — 2026-08-11

- **The report surface (E32 t4).** The second member of the surface family
  (decision 0019) ships: a `surface: report` YAML renders as one scrolling,
  page-headed, print-shaped document — and the t3 contract is proven from
  the consuming side: the whole surface landed without touching a view-layer
  file, and a parameter-free named view placed on both surfaces produces
  byte-identical payload entries (one constructor built both).

  - **The grammar** (`report.yaml`): a `report:` header (`title` ·
    `subtitle` · `date` — a malformed date warns `report-date` and the
    render date takes over) and `sections:` in the SAME placement grammar
    composite views use (`label` · `notes` markdown · `place` — named views
    by slug, `label:`/`with:` overrides, inline widgets verbatim). No/empty
    `sections:` refuses (`surface-sections-empty`). `theme:`/`motion:` work
    as on any surface; the report's motion default is `off`.
  - **Views stay portable** — a parameter-free named reference keys under
    its own name; overridden references and inline entries mint `sec<i>-v<j>`
    (reports mint no anonymous nav vids). A bare `stat-row`/`coverage`/
    `reverse-gap` renders empty with one `view-dashboard-bound` warning —
    the R8 rule, now exercised by a real second surface. A missing name
    substitutes the same stub view the dashboard shows; a placed `for_each`
    view refuses (a section places ONE slot).
  - **`dj report --surface report.yaml [--json | --out report.html]`** —
    one evaluation drives the terminal render (new per-kind printers over
    the evaluated widgets), the exact JSON, and a self-contained artifact
    baked under the report ceiling (capture-only writes, static sources —
    clamped through `applyCeiling`, warn `surface-ceiling`). `dj report`
    with no flags and `dj report --dashboard` are unchanged forever.
  - **The `surface:` routing is wired** (the t3 promise): `dj dashboard`
    and `dj report --dashboard` now refuse a file declaring another kind by
    pointing at the right command, and a typo'd `surface:` finally refuses
    with `surface-unknown` + did-you-mean instead of rendering as a
    dashboard.
  - The client artifact gains a `data-surface` root attribute, the report
    chrome (title block, generated TOC, per-section headings and notes, the
    `#/c/` document overlay — live on screen, hidden on paper) and
    `@media print` styling; the catalogue's `report` surface entry now
    documents the shipped grammar (`accepts: report · sections · theme ·
    motion`).

- **The surface contract: dashboard ported byte-stable (E32 t3).** Surfaces
  are now an engine-owned family (decision 0019) and the dashboard is its
  first member — ported onto the contract without moving a byte (the step-0
  pins: evaluator, payload incl. key order, split mode, hosted shell,
  client DOM all hash-identical).

  - **`surface:`** — a surface file declares its own kind with a top-level
    `surface:` key; the filename is never semantic. Absent means `dashboard`,
    so every existing dashboard.yaml stays valid forever; `surface: dashboard`
    written out is legal and ignored. An unknown kind refuses
    (`surface-unknown`) with did-you-mean. `dj catalog` gains a **Surfaces**
    group documenting the family (`dashboard` free-traversal, direct/live;
    `report` linear, capture-only — its renderer ships next ticket).
  - **The ceiling** — `applyCeiling` clamps a host config to what a surface
    can honestly render (warn `surface-ceiling`, never a refusal). The
    dashboard's ceiling permits everything, so its path is the identity; it
    is threaded through `dj dashboard` and `dj serve` now so lower-ceiling
    surfaces clamp through the same seam later.
  - **Bare shared-state widgets degrade portably** — a bare `stat-row` /
    `coverage` / `reverse-gap` (no `indicators:`/`bind:`) reads the host
    dashboard's pack/indicator state; evaluated on a surface without that
    state it now renders an EMPTY widget with one `view-dashboard-bound`
    warning instead of failing — the rule that lets one view library serve
    every surface.
  - **Internals, moved not changed** (for anyone importing the engine): the
    view evaluator lives in `core/view-eval.ts` (`evaluateView`), the
    view-payload constructors in `render/views/payload.ts` (re-exported from
    `render/page.js`, so no import path breaks), the dashboard payload
    slices in `render/surfaces/dashboard.ts`, and the client's dashboard
    chrome in `ui/surfaces/dashboard.tsx` behind an `App` dispatch on
    `D.surface ?? 'dashboard'` — dashboard payloads gain **no** new key.

- **Views place views: the placement grammar and composition (E32 t2).** A
  view (a `views/` file or an inline one) may now compose OTHER views. One
  placement grammar, two doors:

  ```yaml
  # views/quarterly.yaml — the sections: body
  view:
    label: Quarterly
    sections:
      - label: Health
        notes: Prose printed above the section's content.
        place:
          - status-tiles                 # bare name ≡ { view: status-tiles }
          - view: open-risks
            span: 8                      # 1..12 of a 12-column grid
            label: Risks this release    # this placement's heading only
            with: { quarter: "Q3" }      # {placeholder} substitution over
          - widget: rows                 #   this placement's copy only
            from: tasks                  # inline widgets place too
            where: "status is open"
  ```

  Inside a `widgets:` list the same placement is spelled
  `{ widget: view, use: <name>, span?, label?, with? }` — `widgets: [a, b]`
  is exactly `sections: [{place: [a, b]}]`, so the sugar and the grammar can
  never disagree. `sections:` and `widgets:` on one view refuse (in a library
  file the same contradiction is a `view-file-invalid` skip). Spans mount a
  12-column grid that collapses to today's stacked flow below the narrow
  breakpoint; no spans means today's DOM, untouched.

  **The two channels stay two channels.** Grammar mistakes in the file being
  edited refuse (bad span, a section without `place:`, `with:` supplying a
  placeholder the engine resolves itself, a placed `for_each` view — a
  placement is one slot; the set-shaped spelling is navigation's `views:`).
  Whether a placed NAME resolves is the warn-level `view-*` family: a
  missing name, a cycle (`view-cycle` — a repeated name on the resolution
  path), or the breadth valve (`view-expansion-limit`, 500 placed-view
  expansions per evaluation) each degrade to a **labelled unresolved slot**
  in the page — the evaluation always completes. Diamonds are legal.

  Every placement is an independent instance: two placements of one view get
  disjoint widget-id subtrees (facet/tab/disclosure state isolates per
  placement for free), `with:` fills only that placement's clone, and an
  outer `for_each` fills its `with:` values first so per-document parameters
  thread down. Placement is transparent to the tabs-in-tabs guard. A
  placement-free dashboard is byte-identical — the step-0 pins prove it.

  `dj catalog` grows the **Placement** group and the `view` widget entry.

- **Named views: a `views/` library beside the definition (E32 t1).** One
  reusable view per file — `views/open-work.yaml`:

  ```yaml
  view:
    label: Open work
    widgets:
      - { widget: rows, from: tasks, where: "state == open" }
  ```

  **The id IS the filename** (basename minus `.yaml`/`.yml`, verbatim). A
  declared `id:` that disagrees warns (`view-id-mismatch`) and loses — the
  filename wins, and references to the declared id will not resolve.
  Discovery is `<vault>/views/`, non-recursive, overridable with the new
  global `--views <dir>`; a missing directory is simply an empty library.

  **References.** Anywhere `dashboard.yaml views:` lists a view, a name now
  works — a bare string, or the explicit form with per-placement overrides:

  ```yaml
  views:
    - open-work                          # by name
    - view: open-work                    # explicit form
      label: Open items                  #   label for this placement only
      with: { owner: "felipe" }          #   {placeholder} substitution
    - id: inline-one                     # inline views stay valid FOREVER
      widgets: [ ... ]
  ```

  Resolution is in place (list position preserved), `for_each` templates
  work by name (nav `views: <name>` mints as before), and an unknown name
  substitutes a **visible stub page** with a `view-missing` warning — never
  a dropped entry, never a crash: every `view-*` finding is warn-level.
  Names containing `/` are reserved for engine-shipped `dj/*` views (none
  ship yet).

  **Shadowing, one table.** An inline or include-fragment view whose id
  matches an *unreferenced* library view wins locally and warns
  (`view-shadowed`) — except an `include:` whose path IS the library file
  (both roles, no finding: that is the migration state below). Referencing
  a name that is also placed inline keeps the first occurrence and drops
  later copies (`view-name-collision`). Fragment-vs-inline duplicate
  mapping ids still refuse at compose time; inline-only duplicates stay as
  legal as ever.

  **Migration from `include:` fragments is zero-edit.** A fragment file's
  one-entry `views:` list is accepted as a view file as-is — move it under
  `views/`, delete the `include:` line, write the name. The legacy
  one-entry `views:` file shape is deprecated (documented here, accepted
  silently) and will be removed no earlier than 1.0; the canonical shape is
  the `view:` mapping above.

  Also: `dj records audit` now counts blocks queried in library files
  (referenced or not) toward the queried axis, so a block read only through
  a named view no longer buckets as dead weight; serve mode watches
  `views/*.yaml` and re-renders on an edit; `dj catalog` gains a **Named
  views** group. Cloud hosts do not read `views/` yet — the engine support
  ships inert there until the render-pin bump adopts it.

## 0.13.5 — 2026-08-11

- **The sidebar speaks Linear.** Navigation items may name an `icon:` from a
  new engine catalogue of ~30 generic outline glyphs (`dj catalog` → Nav
  icons) — the engine draws the glyph, the format decides what it means. A
  typo warns (`dashboard-nav-icon`) and drops rather than failing the
  dashboard.
- **Sub-paths, Linear's anatomy.** A nav item with `children:` renders as an
  expandable parent — and may carry its own `view:`, so clicking it lands
  somewhere while the chevron expands it. An icon-bearing child indents with
  its glyph; an icon-less child indents behind a rail. Groups and parents
  collapse, and the shut set persists per browser.

## 0.13.4 — 2026-08-11

- **The undo toast is optimistic.** It shows when the op posts — like the
  paint it narrates — instead of waiting out the write round-trip. A refusal
  still replaces it with the referee's message, so the correction channel is
  unchanged; only the timing moved.

## 0.13.3 — 2026-08-11

- **The write answer's delta holds the page order too.** 0.13.2 stabilized
  the live refetch but the answer's delta still replaced a concept's rows
  wholesale — in the server re-render's own order, a third ordering — and
  reshuffled the board at toast-time. Both doors into the payload now merge
  into the order the page already shows.

## 0.13.2 — 2026-08-11

- **Row order belongs to the page — the kanban reshuffle fixed at the root.**
  The baked payload orders rows by document; a live host may answer /rows in
  its own order (the cloud projection: ORDER BY a text id). A write hears
  both — invalidation refetch, then the answer's delta — and wholesale
  replacement reshuffled the whole board into the host's order and back a
  second later. Fresh rows now MERGE into the order the page already shows:
  persisting rows keep their seats with fresh content, vanished rows leave,
  new rows append.
- The board card row now carries its `data-concept|block|row` identity, so
  the FLIP reflow actually reaches cards — lane moves slide instead of
  flashing.
- A drag-dropped card is skipped by the reflow once: it is already at its
  destination under the cursor; only its neighbours slide.

## 0.13.1 — 2026-08-11

- **The kanban jitter on ANY update, fixed at the root.** Every cloud write
  repaints the board two or three times (the optimistic paint, the write's
  answer, the invalidation re-query), and FLIP re-measured cards MID-SLIDE
  on each — getBoundingClientRect reads the transformed position, so the
  next render computed a bogus move and launched a reverse slide from the
  halfway point. A card whose slide is still playing is now neither
  measured nor re-animated; the running slide finishes and the burst of
  re-renders is idempotent to the eye.

## 0.13.0 — 2026-08-11

- **The Linear-grade surface (E30).** Every enum control becomes a palette
  picker — typeahead when the vocabulary is long, tone dots or progress
  icons, numeric shortcuts by declared position, arrows/Enter/Escape. A new
  `progress` column style draws an enum value as WHERE IT SITS in its
  declared order (dashed muted start, filling wedge, success check, error ×)
  — geometry from the declaration, meanings stay the format's. Board cards
  take the tracker anatomy: separated surfaces on quiet lanes, dim mono
  eyebrow, compact badge rows. ⌘K gains the palette half: jump targets from
  the declared navigation and the corpus's document titles. @-mention in
  every form text field completes the corpus's documents into the link
  grammar the vault already speaks. Side panel and dialog polish on the
  same tokens.

- **The kanban glitch, fixed.** A dragged card's clone was held until the
  host answered — a double-rendered card for the seconds a cloud write
  takes since the optimistic paint. The gesture now settles at drop, and a
  refusal restores the payload while FLIP walks the card home. The entrance
  stagger re-ran on every data repaint; it now runs once per view entry.
  FLIP finally covers the board's actual cards, so a status change slides
  the card to its new lane.

## 0.12.0 — 2026-08-10

- **A motion layer, declared and reader-respecting (E30).** The Astryx
  duration/ease tokens the UI barely spent are now spent: a board card
  SLIDES to its new lane on a status change (FLIP, keyed on the card's
  identity, WAAPI so it never fights React), a view-swap settles, tiles /
  cards / lanes rise in a short entrance stagger, and the hover/press
  feedback the base sheet left flat is filled in. Declarative and
  catalogued — `motion: full · reduced · off` on the dashboard (a new
  Surface group in `dj catalog`), full by default. A viewer's own
  prefers-reduced-motion always downgrades full to reduced: the author sets
  the ceiling, the reader the floor. Additive — with motion off or
  reduced-motion set, the page is byte-for-byte its old self.

- **The `linear` template: Linear's defaults as a format you own.**
  `dj init --template linear` scaffolds a tracker — teams hold issues with
  Linear's exact default lifecycle (Triage / Backlog / Todo / In Progress /
  In Review / Done / Canceled / Duplicate) as a stored enum, set-status /
  set-priority / assign as declared forms on the cells, projects as
  documents with milestones and health, docs as the PRDs beside the work.
  The five Linear categories are the dashboard's where-clauses; the triage
  accept-guard is the needs-priority indicator. Scaffold lints clean and
  its dashboard evaluates with zero findings.

## 0.11.0 — 2026-08-10

- **The cloud verbs: `dj auth`, `dj rows`, `dj kb`, `dj format push` (E29,
  decision 0018 "One CLI").** djc is deleted; its verbs live here now, the
  `gh` model with the engine as the open half. `auth` is the RFC 8628
  device flow, credential store and git credential helper, ported onto the
  SAME hosts.json (`DJC_*` environment variables honoured) so an existing
  djc login carries over with no re-login. `rows get` is the agent API's
  freshness-stamped read; `rows patch` is the guarded write with the
  revision guard as the DEFAULT — `get` records each `readAt` (reads.json
  beside hosts.json), `patch` sends the recorded stamp, a patch with no
  prior read is refused with the fix named, and `--unguarded` is the
  opt-out by name. `kb create / list / open` are the registry verbs;
  `format push` is the set-format govern door, compiling the definition
  locally with the same engine before sending and relaying a red certify
  trial with its findings. A minor rather than a patch because the command
  surface grew; nothing existing changed shape.

- **`type: number` columns defer to the evaluator in the SQL count
  compiler.** REAL storage collapses the author's text ("3"/"3.0"/"3.00"
  become one value) while the reference evaluator compares strings — so
  `==`/`!=`/`in`/`contains`/`startswith` on a number column now return
  null (ask the evaluator) exactly as an unresolvable frontmatter field
  does, and a mixed-affinity multi-block selection refuses the whole
  union. Latent: nothing wired `sqlCountFor` into a read path yet; fixed
  before anything could.

## 0.10.3 — 2026-08-10

- **Facet buckets follow the vocabulary, not the casing (E13 t18).** The
  filter strip bucketed rows by exact cell text, while the server chip
  raises a value's first letter and a declared `default:` or an optimistic
  paint carries the raw enum value. Measured on production: one status
  change showed "Open (5) · Open (1) · Deferred (1) · Deferred (1)" until
  the server delta merged them — and, the latent half, every rows view's
  declared `default:` has been silently dead ('open' never matched a cell
  reading 'Open'), so views opened unfiltered. Buckets are now keyed by
  the case-folded value with the first-seen casing as the label, and
  selections match case-insensitively — the rule parseDoc's enum check
  and the board's lanes already keep. Authors' `filter: default:` values
  start applying on entry, which is what they always declared.

## 0.10.2 — 2026-08-10

- **A control pick paints immediately, and snaps back on refusal (E13
  t17).** Measured on production with an instrumented page: the status
  dropdown's pick fired its POST at the click, and then nothing visible
  happened for the seconds a cloud command write takes to commit and
  answer its delta — so the pick read as "did nothing" and users submitted
  the same write twice. Direct mode now keeps the rule the board drop and
  proposal mode's `stageOp` already keep: the picked value is painted into
  the payload rows before the host answers, and a refusal restores the
  previous value beside the referee's toast, so the page never keeps a
  state the vault refused.

## 0.10.1 — 2026-08-10

- **Indicators drawn as view widgets now live-refresh (E13 t16).** Measured
  on 0.10.0, on production, the day the command door started announcing its
  writes: one status change moved the dashboard home's OPEN QUESTIONS tile
  7 → 6 while Triage's own OPEN QUESTIONS stat-row held 7. `refreshIndicators`
  merged fresh values into `dash.tiles` and nothing else, and `tilesReading`
  filtered the same array — so a block only a widget reads routed no fetch at
  all, and a widget copy of a shared tile was never written even when the
  fetch happened. Not an authoring mistake: a stat-row naming its indicators
  by id holds the same tile object at build time, and JSON has no reference
  sharing, so the payload's round-trip makes them distinct copies. One new
  walker (`core/tilewalk.ts`) is now the single answer to "which objects are
  tiles" — the client routes and merges over every copy, and `GET /indicators`
  serves one value per id, which gives an indicator declared inline on a
  widget (the `for_each` person boards) a wire address for the first time.
  Hosts embedding the engine should serve
  `uniqueTilesById(collectTileObjects(result))` instead of `result.tiles`.

## 0.10.0 — 2026-08-08

- **BREAKING (small, and it was already wrong): an indicator naming an
  undeclared block is now refused (E28 t1).** `dj dashboard` previously
  rendered `value: 0` for `from: not-a-real-block` and exited 0. It now
  refuses with `block "X" is not declared by the format` — the exact
  sentence the chart, board and timeline paths have always used, because two
  spellings of one mistake must not give two different answers. A dashboard
  carrying a stale `from:` will start failing; that number was never real.

- **Schema lint over dashboard references (E28 t1).** A `where:` field that
  resolves in NONE of the block's declared columns, any type's declared
  frontmatter or flags, the reserved names (`id` · `state` · `type` · `path`
  · `domain`), `cites_<scheme>`, or the keys the corpus carries is reported
  as `dashboard-unknown-field` (warn; fatal under
  `--warnings-as-errors`). Measured before this: `where: "stattus == open"`
  — one transposed letter — rendered `"value":0` with `dj dashboard` and
  `dj lint --all` both exiting 0. The set is a union because
  declared-but-unused and used-but-undeclared are BOTH legitimate; consulting
  the definition alone or the corpus alone invents a false positive either
  way.

- **Live indicator values (E28 t2).** Each tile now carries `blocks` — what
  its selection actually read — and the payload declares `indicatorSource`
  beside `rowSource`. `dj serve` answers `GET /indicators` with the tiles of
  the CURRENT payload, so a live client and a reloaded one cannot disagree.
  The client half (`render/ui/indicatorsource.ts`) has NO events channel of
  its own: the row driver's `{path, block}` frame already arrives, so an
  invalidation is ROUTED rather than broadcast — a block no tile reads costs
  no fetch at all. Measured before this: a status change moved the number in
  the tab that made the edit and in no other, until a reload.

- **Declared column types (E28 t4).** `type: number | date | text` plus an
  optional `unit:` on a column. Absent means text, so every existing format
  is untouched. A cell that breaks a declared type is an ERROR where the
  record is parsed, beside the enum check and with the same code convention
  (`<block>-<column>-type`, overridable by `code:`). Declared empty markers
  are exempt — "not applicable" is an answer, and it is what becomes NULL
  rather than zero, which is what keeps an average honest.

- **A summed column should be one the format calls a number (E28 t5).** A
  chart series whose `y:` sums an untyped column now reports
  `dashboard-metric-type`, naming the column, the fix, and how many cells
  actually failed to parse and were silently skipped. The numbers the chart
  draws are unchanged; this says the quiet part out loud.

- **Typed projection tables (E28 t6).** One table per block, `rec_<block>`,
  derived from the compiled definition on every rebuild and never migrated:
  id, the declared identity (so a record's `uuid` is addressable for the
  first time), then EVERY declared column, REAL where the format says
  `type: number`. Identifiers are QUOTED rather than sanitised — a lossy
  transform is what creates collisions, and block names are already unique.
  `row_` is joined, never replaced, so FTS and `cite` are untouched and the
  largest column is not copied. `SqlSink` gains an optional `all` used for
  one thing: sweeping tables the format no longer declares, because a
  lingering one is stale-but-queryable. Measured on a real vault: `SUM` 123,
  `AVG` 41 rather than 30.75, `WHERE now > 10` no longer returning every row
  because `'5.00' > '10'` lexically.

- **The dashboard's query language compiles to SQL (E28 t7).** `expr.ts` now
  parses to an AST with two backends over one parse: `evalQ` — the reference
  semantics — and a SQL compiler over the typed tables. Authors write exactly
  what they wrote before; no raw SQL is exposed, so the physical schema stays
  free to change. It refuses rather than guesses: a frontmatter field is
  projected nowhere, so the compiler returns null and the evaluator answers.
  Tested differentially — both paths, one vault, same number, every operator.

- **The authoring skills teach the new surface (E28 t9).** A coding agent
  only uses `type:` if the guide names it. Four pitfalls in
  format-authoring and three rules in dashboard-authoring, each stating a
  measured failure rather than advice.

## 0.9.3 — 2026-08-07

- **The RowSource seam: tables ask a driver where rows come from (E27
  t1/t2).** One client module (`render/ui/rowsource.ts`) now answers "the
  rows of {path, block}"; the payload declares the source. Absent a
  declaration the page is exactly what it always was — `rowSource:
  {mode:'static'}`, rows baked in, file:// works with no network. A host may
  declare `mode: live` with a rows endpoint and an events channel: the
  driver serves the snapshot synchronously (reading never blocks on the
  network), kicks one background refresh per block per page-life, lands
  fresh rows with a store bump, and degrades to the snapshot when the
  endpoint is dead — a live page falls back to today's page, never to a
  blank one. One invalidation message shape (`{path, block}`) over two
  transports (websocket or SSE), so the module cannot tell which host it is
  on — which is what makes the loop testable locally before any cloud
  exists. Payload rows now carry a sidecar record's `uuid` (conditional —
  a table row's payload is byte-identical), the stable address a live
  write holds across renames.

- **dj serve is the local live host (E27 t5).** The serve payload declares
  `{mode:'live', url:'/rows', events:'/rows/events'}` — the exact shape a
  cloud host mints. `GET /rows` answers the payload's own rows (they can
  never disagree); `PATCH /rows` is the RAW record write beside the form
  write — same referee gate, same byte-preserving writers, identity never
  settable, undeclared columns refused, by-uuid addressing (`by: uuid`),
  and an append into a table-backed block refused by the gate itself as
  `sidecar-both-sources` rather than by a special case. One SSE message
  announces a PATCH, a form write, and an out-of-band editor save alike;
  the watcher is the existing mtime sweep on a timer, running only while
  someone is listening. Git is not in the local loop: disk is truth, you
  commit when the work deserves it.

- **`dj records audit` — the records census (E27 t7).** Every persisted
  record type, cross-referenced over three axes — DECLARED (format.yaml),
  POPULATED (raw sidecar YAML, because `readSidecar` drops undeclared
  blocks before the IR: a census over the IR could never see an orphan) and
  QUERIED (`from:` and `bind.rows` collected by spelling, fragments
  included, WITHOUT evaluating — a broken dashboard still answers) — into
  four buckets: healthy, dead weight, orphaned, empty. Phantoms (queried,
  exists nowhere), templated `from:` values and twinless sidecars surface
  beside them. Orphaned records exit 1: a shape change must never strand
  records silently. The `_archive/` shelf is carved out of
  `sidecar-not-a-concept` (a vault that accepts a deletion gate's archive
  offer stays green) and always reports as dead weight — parked is not
  invisible. Command surface 14 → 15, recorded in the ratchet with the
  full design decision.

## 0.9.2 — 2026-08-06

- **Modal form polish: Astryx chrome in the body, one date-range picker for
  the gesture shape (E19 t7).** The t6 dialog hosted the legacy panel markup
  bare — two visual languages in one surface, and the reschedule form Felipe
  called out ("working but it looks bad, and we should be using a date range
  picker not 2 single date pickers"). The modal body now speaks Astryx
  end-to-end: `FormLayout` owns the field spacing, every field renders under
  Astryx `Field` chrome (FieldLabel typography, the declared `hint:` as the
  field's description, group-shaped kinds as labelled groups), and the button
  row is a footer-shaped Cancel + primary pair under a divider. And a form
  whose shape IS the date gesture's — exactly two date-kind fields feeding
  two distinct `set:` keys — renders them as ONE `DateRangeInput` instead of
  two single date pickers, in both containers (a block's rows form and a
  type's frontmatter form). **Same detection, no new grammar**: the range
  presentation asks `gestureDateForm`, the ONE two-date-two-keys matcher the
  drag already stands on, through a third thin consumer (`formDateRange`,
  form-first over the form's own two `set:` keys); orientation, which the
  gesture takes from the timeline's bind, is deterministic here — field
  declaration order, first declared date field is the start. **The submit
  contract is untouched**: the Astryx widget is strictly controlled and
  renders no form input, so two hidden inputs mirror its state under the
  fields' own `data-fname` names (the image field's hidden-input precedent)
  and `submitPanel` serialises the SAME two fields byte-identically — the
  suite pins the modal's POST against the stacked panel's, through the range
  markup. Where a DateRange cannot hold the row's truth — one date set and
  the other empty, non-ISO cell text, a reversed pair — the form FALLS BACK
  to the per-field date inputs (still Astryx-wrapped) rather than render a
  widget whose untouched submit would blank the lone value through
  annotate's whole-set replay; clearing the range is the same explicit act
  emptying the two inputs always was. Stacked panels are structurally
  unchanged — the shared control markup moved into one `FieldControl`, but
  the in-flow presentation renders byte-identical legacy chrome. The
  toast/dialog lesson, checked: the calendar popover needs no token bridge —
  app.css carries the compiled component styles and every custom property
  they lean on is served by tokens.ts in both themes; and the t6 constraint
  "no modal-hosted form field opens either today" is retired — the range
  popover uses the NATIVE popover API, so it stacks on the browser top layer
  ABOVE the open `<dialog>` (unlike the body-appended menu.ts dropdown), and
  the Dialog's Escape defers to the popover's focus trap: calendar first,
  dialog second. Real-browser check of that layering (the stub-DOM suite
  cannot see the top layer) rides the next manual pass. Also corrected here:
  the t10 entry's NUL-sweep sentence now names all three files the sweep
  covered (`store.ts`, `forms.tsx`, `graph/build.ts`), not two.

- **Genericity Audit 2 pre-merge hygiene (E18 t10).** The nine S-sized
  actions the audit ruled before merge, each shown failing without its fix
  (test/genericity-hygiene.test.ts). Refusals, first: `sharedEnums` is now
  prototype-free with an own-key lookup, so `enum: shared.constructor` as a
  reference miss gets the normal unknown-shared-enum refusal (now with the
  loader's did-you-mean) instead of crashing the loader with a raw TypeError
  off Object.prototype — discipline 7, actively enforced. A **block-less op
  that names a `row:`** — the one silent-wrong-container hole in the write
  grammar — is refused loudly at BOTH entry points (`applyChangeSet` and the
  CLI/serve `applyAnnotation`), naming the op's contradictory shape instead
  of silently dropping the row. A multi-block board (or timeline `color:`)
  whose lane/colour column resolves to **DIFFERENT enum vocabularies across
  `from:`** refuses at load naming both vocabularies — never first-block-wins
  with the other block's rows mis-laned against words their column never
  declared. A `wip:` key naming a lane `lanes.order` excludes is a dead
  declaration (it validated and could never render) — refused at load, the
  d717a26 rule. Rulings, second: **over-limit presentation is now doctrine**
  — a lane holding more cards than its `wip:` number marks itself with a
  `data-over-wip` attribute and shifts the header count to the warning tone
  (was the error tone under a bare class), no new English, no behaviour
  beyond the tone — and **`limit:` is accepted as wip's generic-name alias**
  (one meaning, two spellings; refusals speak the spelling the author wrote;
  both at once is refused as a contradiction). The **ISO-8601 date grammar
  and engine-English calendar chrome** are written into the timeline
  catalogue entry as a ruling (one engine-owned grammar across lint, gate,
  evaluator and renderers; chrome speaks engine words by the q6 precedent) —
  doctrine, not accident. Prose and bytes, last: the timeline's
  `filter:`/`filters:`/`search:` refusal now names filtering/search as
  not-yet-supported (the old hint misdirected to the zoom/duration planned
  list); the one vault-namespace literal in catalogue prose ("a
  self-referencing Depends") is now the engine's neutral phrase; and the
  four literal NUL bytes in `store.ts`, `forms.tsx` and `graph/build.ts`
  (two in the last) are `\u0000` escapes — same
  strings, and diffs of those files stop going binary (a guard test now
  sweeps `src/` for raw NULs).

- **Modal form presentation: spatial surfaces open their forms OVER the page
  (E19 t6).** Where the invoking surface is spatial, a declared form now opens
  as a modal instead of a stacked in-page panel: the gantt's edit affordance
  first — the rail's ✎, the missing-list's labelled button, and the
  under-threshold bar click, in BOTH containers (rows-mode date form and the
  concepts-mode frontmatter form) — because the old panel rendered after the
  whole grid and scrolled the reader away from the bar they touched; and every
  form a board CARD invokes (its action buttons, and the capture forms offered
  inside the card's comments panel). Presentation is decided **by invocation
  context, engine-owned, at open time**: `togglePanel` gained a `modal`
  argument the surface passes, the fact rides `ui.openPanel`, and it is fixed
  for the panel's life — uncontrolled inputs never change parents. Normal
  page flow (table rows, concept pages, append forms — the board's re-hosted
  append forms included, which render where their toggle lives and scroll
  nothing away) keeps the stacked panel unchanged. Same machinery, same write
  path: the app-level `ModalFormHost` renders the EXISTING `FormPanel`/
  `FbPanel` inside the vendored Astryx Dialog — the suite pins that the
  modal's submit POSTS the byte-identical op the stacked panel posts, a
  refusal keeps the modal open with the referee's finding in the panel's own
  error surface, and success closes it through the delta merge. The dialog
  title is the form's declared label; the one-panel invariant holds by one
  rule (`isPanelOpen` answers false for a modal panel, so every inline site
  yields to the host — the timeline's below-the-grid panel sites are gone
  entirely). Dismissal policy, stated: Dialog `purpose="form"` — **Escape
  closes without submitting; backdrop click does NOT dismiss** (a stray click
  must not discard half-typed uncontrolled input); close is explicit — Escape,
  the header's ✕, or the panel's own cancel. Focus: `showModal()` moves focus
  into the dialog; on close the ONE close funnel (`closeOpenPanel`) restores
  the remembered invoker, re-queried by its own data-* facts when a delta
  merge re-created the element — focus never falls silently to body.
  The toast lesson applied BEFORE shipping this time: the dialog panel
  inherits the engine-served tokens, but `::backdrop` historically does not
  inherit custom properties, so page.css supplies the scrim with the token
  plus its literal per-theme fallback (doubled-class specificity, values from
  tokens.ts light and dark), and the hosted `.fform` drops its stacked
  padding inside the dialog's own padded container (scoped — the pinned
  global rule is untouched). ⌘K is guarded while a modal is open (the palette
  would open under the inert-enforcing top layer). **Format hint deferred**:
  the ticket allows the format to hint placement, but the engine default per
  invocation context needs zero grammar, while a `present:` key would touch
  both schema nodes, both parse loops, the payload whitelists and the
  catalogue — and under the V070 unknown-key ruling would hard-error every
  older engine on vaults using it; it waits for a format that demonstrates
  the need. Known constraints, on the record: the imperative body-appended
  dropdown (menu.ts) and the toast host paint UNDER the native top layer — no
  modal-hosted form field opens either today (options render as radios/
  checkboxes, the concept picker is inline), and panel submit errors use the
  in-panel surface, so nothing is currently unreachable; re-parenting menus
  into the dialog is the follow-up if a field kind ever grows one.

- **Review hardening: the modal's a11y claims become wired, tested facts (E19
  t6 review).** Four fixes, each behind a test its mutation fails. First, the
  claims were reachable only by mouse: every form affordance was an
  unfocusable `<span class="fa-btn">`, so a keyboard or screen-reader user
  could never OPEN a board card's or the timeline missing-list's modal at
  all, and `document.activeElement` at capture time was never the affordance
  — the remembered "invoker" was body or a stale element. All `fa-btn`
  affordances are now real `<button type="button">`s (page.css resets the
  button chrome), and the modal surfaces pass the CLICKED element
  (`event.currentTarget`) into the invoker memory instead of trusting
  `activeElement` (which pointer clicks never set on some hosts — Safari);
  the gantt bar's under-threshold click keeps the `activeElement` fallback.
  Second, the re-query selector was form-scoped, not row-scoped —
  `data-form-toggle` names the form, and every card on a board wears the same
  one, so a post-submit restore focused the FIRST card's affordance. The row
  affordances (and the comments panel's capture Button, which had no selector
  fact at all) now wear `data-concept`/`data-block`/`data-row`, and
  `invokerSelector` composes ALL the facts — the suite pins that with t1's
  equal-named button mounted first, focus still returns to t3's reborn
  affordance. Third, the restore rode an untested `useEffect` in the host
  (deleting it left the suite green); it is gone — every close path (chrome
  Escape/✕, the panel's cancel, a successful submit, the toggle-off) now
  meets in `closeOpenPanel`, forms.tsx's one close funnel, which restores the
  invoker one tick later (while the native `<dialog>` holds the top layer,
  everything outside is inert and refuses focus). Fourth, the dismissal
  policy was narrated, not asserted: `ModalFormHost` is now hook-free by
  design so the suite holds the element it returns and asserts the policy at
  the boundary the engine owns — `purpose="form"` on the vendored Dialog
  (Escape closes, backdrop click does not), the header ✕ and the dialog
  sharing ONE `onOpenChange`, and that calling it closes without a POST and
  restores focus. Mutations that previously survived — no-op `onOpenChange`,
  `purpose="info"`, the deleted restore wiring, a form-name-only selector —
  each now fail the suite.

- **Review hardening: the frontmatter date gate stops trusting shapes (E18
  t8/t9 review).** Four fixes, each with a test that fails without it. First,
  the t9 claim "the declared `kind: date` is enforced at the write" was
  anchored to the wrong side of the template: the loader restricted `set:`
  TARGETS to date-kind frontmatter fields but accepted ANY string as the
  template feeding them, and the write validated only the raw form inputs —
  so `set: { start: "circa {s}" }`, a literal `start: TBD`, a text-kind field
  feeding a date target, or an unresolvable `{x}` (fill leaves it verbatim)
  all loaded clean and landed garbage in a declared date field with only a
  warn finding. The loader now demands each date target's template be a bare
  `{field}` naming a date-kind form field (or the built-in `{date}`) — the
  strictness the client matcher (`gestureDateForm`) always demanded, now
  server-enforced — and `annotateFrontmatter` re-validates the COMPOSED value
  against the target field's declared kind, so a hand-built TypeDef that
  bypasses the loader hits the same wall. Second, the write gate and the
  `frontmatter-date` lint both spoke a shape-only regex while the reader the
  feature serves (`parseDateCell`) demands calendar validity: `end:
  2026-02-30` was accepted, linted clean, and immediately listed without a
  bar. Both now speak `parseDateCell`'s grammar — a gated, finding-free write
  can no longer destroy the bar it was dragged from, and lint names every
  value the timeline will refuse to draw. Third, the byte-preservation
  contract's refusal was one line deep: the list-carrying-key guard checked
  only the next line, while the reader attaches `- item` lines to the LAST
  seen key across intervening blanks/comments — so a date key whose list sat
  behind a comment was spliced, reported ok (a list-valued date field is not
  even a warn: the lint requires a string), and the reader kept resolving the
  orphaned list; on a timeline that surfaced as a drag that toasts success,
  then reverts on the delta re-render. The guard now walks the key's whole
  carry window — every line up to the next column-0 key — refusing carried
  lists AND the shapes the old guard never saw (indented continuation
  scalars, nested maps: bytes real YAML parsers read as a different value).
  Trailing comments and blanks with nothing carried stay writable. Fourth,
  two skills still taught the pre-t8/t9 world: view-composition called
  `progress` a planned bind "refused loudly today" and concepts timelines
  "read-only by construction — frontmatter fields have no write op"; both
  now document the shipped `bind.progress` (both mode spellings) and the
  `types.<t>.forms` write path, and format-authoring gains the frontmatter
  form grammar plus the block-less `dj annotate` spelling (`--block` is no
  longer shown as mandatory). Honest limit: block forms' `set:` templates on
  table date columns remain unrestricted — the table gate has the block's
  own checks, and tightening it is a separate ruling.

- **Frontmatter writes through the referee, and the concepts-mode gantt drag
  they unlock (E18 t9).** A TYPE may now declare a frontmatter form —
  `types.<t>.forms`, the exact block-form grammar over declared frontmatter
  fields instead of columns — and the loader validates every target at load:
  an undeclared field is refused with did-you-mean, `type`/`uuid` are refused
  as identity (the 0.9.0 never-settable rule at its third container), and
  only DATE-kind fields are writable — grammar-enforced, not convention,
  because every other frontmatter field is load-bearing structure (hierarchy
  roots, graph edges, facets) that a write surface must not open by accident;
  widening past dates is a future ruling. There is deliberately no append
  spelling (`row:` fails the unknown-key walk loudly). The write itself is
  `annotateFrontmatter()` beside `annotate()`/`annotateRecords()`: a
  BYTE-PRESERVING line splice over the `---` region — only the edited keys'
  lines change; comments, key order, quote styles and the body survive
  byte-for-byte; a declared-but-absent key appends one line before the fence
  (the ensureColumns analog); a duplicate key edits the LAST occurrence,
  matching the reader's later-wins rule; and shapes the splice cannot re-emit
  (block scalars, list-carrying keys) are refused rather than rewritten into
  orphaned bytes. The op spelling is BLOCK-LESS — no `block`, no `row` on the
  same one op literal every write speaks — and all this repo's hosts route it
  (`dj annotate` without `--block`, the dashboard `/annotate` endpoint,
  `/propose` change sets via `applyChangeSet`'s new `typeOf` resolver, which
  defaults to "no types" so an older caller refuses loudly instead of
  no-opping). The referee is the identical gate: trial-parse the would-be
  document beside its sidecar, refuse on new error-level findings, refusal
  reaching the UI as the finding with snap-back. Because no frontmatter parse
  rule is error-level for a garbage date, the declared `kind: date` is
  enforced at the write itself, and lint gains a warn-level
  `frontmatter-date` (warn like `frontmatter-enum` — existing vaults must not
  gain errors). On the timeline, concepts mode now ships write facts —
  resolved field keys as `cols`, per-item `type` (the multi-block `block`
  lesson, per document type) — and a concepts bar whose type declares a
  conforming form (the same two-date-fields strictness rule, `fmDateForm`)
  drags, resizes, click-opens the form as its popup (prefilled from the
  document's own meta) and stages/discards in proposal mode, all through the
  one buildOp/postOp write path; a type declaring no form keeps byte-inert
  bars, exactly the pre-t9 widget. Honest limits: frontmatter forms cover
  scalar date fields only; a staged frontmatter gesture repaints widget items
  but not meta badges elsewhere on the page until the set lands; the
  docujoint-cloud git-host has not yet adopted (or explicitly refused) the
  block-less op and must not be handed one until it does.

- **Timeline progress fill: the format declares what "complete" means, the
  engine counts and draws (E18 t8).** `bind.progress` leaves the timeline's
  planned list — in both its spellings, because the bind is mode-polymorphic
  and each mode refuses the other's shape loudly. In CONCEPTS mode it is
  `{ from: <block>, done: "<query>" }`: the format's own predicate for a
  complete row, compiled once at load time (a bad query is a load error
  carrying the widget's `where`, never a per-bar crash; an unknown block is
  the loader's did-you-mean refusal) and counted per document over THAT
  document's OWN rows of the named block — never the corpus's, so two epics
  never share each other's tickets — with the count in the payload
  (`done`/`total`) and in the bar's hover ("2 of 5"). A document with zero
  rows is 0 of 0: shown as 0%, never hidden. In ROWS mode it names a number
  column, normalized onto 0..1 by one rule (at or below 1 a fraction of 1,
  above 1 a percentage of 100 — "0.4" and "40" both mean 40%): outside
  0..100 clamps with a warn finding naming the row, a non-number cell is a
  finding and the bar draws with no fill (its dates are fine, so it never
  loses its bar), and an empty cell or the declared empty marker is simply a
  row without progress — no finding, no fill. The render is a two-tone
  linear-gradient of the bar's ONE resolved colour: strong to the fraction,
  the same colour mixed toward transparency for the remainder — an opacity
  step, no new colour vocabulary, and `color-mix` keeps it working whether
  the declared colour is a hex or a `var(--token)`. The stops are
  percentages of the BAR's own box, so the t7 resize preview (which restyles
  only left/width) keeps the fill true mid-gesture. Both the live page and
  the `--out` static render draw it, since it is the same component. Honest
  limits: progress is server-computed, so in proposal mode a staged date
  gesture keeps its pre-stage fill until the change set lands (the widget's
  catalogue `about:` says so); `duration:` and `parent:` remain on the
  planned list and are still refused loudly; the recompute runs the
  done-query per document on every delta rebuild — fine at vault scale, not
  measured beyond it. The three refusal surfaces (the bind-key loop, the
  unknown-key hint, and the catalogue entry) moved together, and the pins on
  the old "progress is planned" wording were consciously renegotiated.

- **Review hardening: the write identity, the one write path, and honest
  surfaces for the gestures that shipped (E18 t5/t6/t7 review).** Seven
  fixes, each with a test that fails without it. The big one is IDENTITY: a
  rows-timeline item was keyed by `row.id` — the id-grammar column's value,
  or the ROW INDEX when a block declares none — while the write gate resolves
  an edit target by the row's FIRST cell and every page row ships that same
  string as its key. On the most gantt-shaped block there is
  (Milestone/Start/End, no id grammar) every bar rendered live handles whose
  every drag posted `row: "1"`, was refused `no row "1" in the … table`, and
  snap-backed forever — writable in looks only, the exact state t7's own
  "plain bars, no handles" rule forbids. Items (and their `deps`, and the
  rows-shaped members a rows/board widget filters page rows by) are now keyed
  by the first DECLARED column's value — the write identity — so the gesture,
  the edit popup's prefill, the optimistic stage and the members join all
  speak one name, even when the id-grammar column is not first. Second, the
  ONE WRITE PATH is structural again: t7's `applyDates` had grown its own op
  body and its own POST beside `applyControl`'s, failing the render suite's
  standing pin — both now route through one shared op builder and one POST
  site (`buildOp`/`postOp`), and the pin was consciously renegotiated to
  count the builder instead of the collapsed literal. Third, proposal-mode
  polish on the bar drag: a SECOND gesture on an already-staged bar no longer
  poisons Discard (the stored visual revert is built by `stageDates` over the
  base it kept, never over the replacing gesture's intermediate origin — bars
  and cells now agree after discard); the staged-move lookup walks widgets
  hosted inside `tabs` (a tabbed timeline's bar used to snap back while the
  change set silently carried the new dates); and it matches the item's
  owning BLOCK, so two blocks sharing a row key and date columns no longer
  move together. Fourth, the t5 ref splitter learned that an id may LOOK like
  a list: the whole cell is tried against the existing row ids first (an id
  grammar may legally hold a `,` — "M, part 1" — and splitting it dangled
  both fragments and silently flipped `open_question_about` off), and a cell
  that is nothing but separators ("·") is refuted as one value instead of
  passing as none (no-silent-caps). Fifth, the dependency overlay now shares
  the rows' own layout box (`.tl-in`, rows at fixed heights): sized against
  the scrolling container it compressed against the tracks' min-width on any
  narrow screen — arrows landing inside the wrong bars on exactly the phone
  reader the standard protects — which closes the honest limit the t5 entry
  recorded. Sixth and seventh, the board's shipped drag reached its
  discoverability surfaces and its refusals stopped lying about it: the
  unknown-key hint no longer promises "sort and drag are planned" (sort is;
  drag SHIPPED, keyless), `drag:` gets its own refusal teaching that the
  gesture rides the lane control's existence and no spelling will ever
  switch it, and both `dj catalog`'s board entry and the view-composition
  skill now carry the drag story the timeline entry always carried for its
  own gesture — the write path it shares, the refusal snap-back, the unset
  lane as a non-target, and the lane menu as the accessible path.
- **Timeline interactivity — bars that write through the block's own date
  form (E18 t7).** The gantt's write half, rows mode only. On a writable
  host, a bar whose block declares a gesture-shaped date form — an edit form
  of exactly TWO `date` fields `set:` onto the two bound columns, each
  through a bare `{field}` template — can be dragged (move: both dates shift
  together) or resized at either edge (the other date rides the op
  unchanged). The strict form shape is load-bearing, the control-form rule at
  two fields: `annotate` replays a form's WHOLE `set:` and fills a missing
  field as empty, so a looser match (a third column, a composite template)
  would let a drag blank cells the gesture never touched. The gesture is the
  board drag's grammar over dates: ~5px promotes (under it the press stays a
  click — which opens the form as the EDIT POPUP, the existing FormPanel
  prefilled from the row's cells, no new form vocabulary), travel converts to
  a DAY DELTA through pure math shared with the evaluator (`tlDragDays` /
  `tlGestureDates` in src/render/timeline.ts), snapped to the visible grid —
  day cells under week ticks, week steps under month ticks — while the bar
  previews its would-be geometry and a floating tip previews the would-be
  dates in the ledger's own `YYYY-MM-DD` grammar. A delta, never an absolute
  pixel read back into a date: that is what makes the end-inclusive bar
  geometry (right edge at end+1) safe to invert without an off-by-one, and a
  resize handle dragged past its partner clamps to a one-day bar — the
  evaluator's end-before-start finding is unreachable from a gesture. ONE
  write per settled gesture, never per pixel, and it compiles to the SAME
  POST the form's own submit produces — one op, both date fields, the form's
  action name, one referee gate. A refusal reaches the reader as the
  referee's own message and the bar glides back to the geometry the payload
  still holds; proposal mode stages the op (one op — two would each blank the
  other's column; discard reverts the bar and both cells). The payload grew
  what a write target needs: each rows-mode item now carries its OWNING
  `block` (a multi-block `from:` fans rows out of several blocks, and a write
  must replay that row's block's form, not the widget-level first-hit
  resolution) and the widget ships `cols` — the RESOLVED lowercased column
  keys beside the display-spelled `bind`. Vendor ruling recorded: we GREW the
  hand-rolled renderer instead of vendoring SVAR react-gantt (MIT). The
  renderer already draws the grid, bars, arrows and today line from pure math
  the evaluator shares — vendoring would fork that single source into two
  geometry systems, add real weight to an app.js already at ~1.3 MB, and
  bring SVAR's own optimistic edit state, every internal mutation of which
  would need intercepting before the referee answers — the exact problem
  snap-back and staging already solve. No capability wall justified the
  cost: the drag inverts math we own, `zoom:` is a future bind over
  `timelineScale`, and virtualization is moot at markdown-table row counts.
  What stays read-only, and says so: CONCEPTS MODE (the epics gantt) — its
  dates are frontmatter fields and the write gate (`ChangeOp` → `annotate` /
  `annotateRecords`) carries block rows only; no op shape can spell a
  frontmatter write, so concepts bars render inert, no handles, no dead
  affordances — same widget, one mode draggable, one not, the read-only-in-
  fact rule the derived-state board set. Likewise a block with NO conforming
  date form: plain bars, and the rail shows no edit button (an item listed
  without a bar gets the edit popup as its one path to dates, labelled with
  the form's own declared label). Honest limits: pointer events only — the
  rail's edit button and the listed rows' form buttons are the accessible
  path; the client validates nothing the referee doesn't (the gesture can
  only produce calendar-valid dates by construction, but a form typed by hand
  still meets the gate's grammar-blind `sanitize`, and a bad hand-typed date
  surfaces as the next eval's `dashboard-timeline-date` finding, as before);
  a successful write may RESCALE the whole domain (the padded min/max are
  data-derived), so bars can shift geometry on the delta merge — by design,
  the payload is the one truth; and frontmatter writes, `zoom:` and the
  `duration`/`progress`/`parent` binds stay planned and loudly refused (the
  planned-keys hint no longer names the drag/edit writes).
- **Board drag-and-drop — the gesture spelling of the menu-move (E18 t6).**
  A card on a writable board can now be dragged between lanes: pointer-down
  plus ~5px of travel promotes to a drag (under the threshold the press stays
  a click, so every card control keeps working), a pixel-identical floating
  clone of the card follows the pointer — the card's own markup in the card
  layout's own skeleton, body-appended like the dropdown menu so no ancestor
  overflow clips it — the source card dims into a ghost slot, and the lane
  under the pointer highlights. The ruling holds: drag is a GESTURE BINDING
  over the board's one write path. The drop reads its whole write target off
  the card's own control trigger and compiles to the SAME `applyControl` POST
  the lane menu's pick produces — byte-identical op, one referee gate — and
  carries the lane VALUE and nothing else: the insertion index is ignored by
  design, within-lane order stays document order. When the referee refuses,
  the card snaps back: the refusal reaches the reader as the referee's own
  message (the same toast every control write uses), no delta was applied, so
  the clone glides home and the unchanged payload re-renders the card where it
  always was — the board never shows a state the vault refused. What never
  drags: the derived-state board (you cannot drag a card to built — evidence
  makes it built; the gesture is refused at pointerdown, before any threshold
  or clone, by the same laneCol gate that already withholds the menu — a
  stored column literally keyed `state` still lends nothing), a lane column
  with no bound control, a host that cannot write, and the unset lane as a
  drop target (`data-lane=""` is not a declared value, so the form could not
  spell the write — the menu never offers it, and the drop snaps back instead
  of inventing it). In proposal mode the drop STAGES the op exactly as the
  menu would — no POST until the set is proposed, and the optimistic move is
  the staged behaviour, so there is nothing to snap back from at drop time.
  Honest limits: pointer events only this pass — the lane control's menu IS
  the accessible path (keyboard, screen reader, touch tap) and stays on every
  draggable card; the board does not auto-scroll while a drag hovers its
  edge; a wip: limit stays a header number — dropping into an over-limit lane
  is not refused client-side, because the referee has no such rule and the
  client invents none; `drag:` as a spelling stays loudly refused — the
  gesture needs no configuration, it rides the lane control's existence; and
  with no jsdom in the dev dependencies the gesture is tested over a stub DOM
  driving the real drag module and the real write path (fetch captured, real
  server payload), while the React wiring itself is pinned through the
  rendered `can-drag` gate rather than an end-to-end pointer simulation.
  `applyControl` now returns the write's verdict so the drag can time its
  snap-back; the menu path and the undo closure ignore it unchanged. Timeline
  bar drag/resize stays planned (E18 t7 territory, with the rest of the cloud
  KB backlog).
- **Depends-typed row-ref columns — the typed edge between rows, and the two
  widgets that show it (E18 t5).** A `ref:` column could already point at
  another block's ids; it can now point at its OWN block — `Depends` on a
  tickets block resolving against sibling ticket ids — and a cell is a LIST:
  `t1 · t3` (or the comma spelling) splits on the separators the vault's
  records already use, each value refuted, resolved or reported on its own, so
  one bad name never hides the good ones beside it and a single-value cell
  keeps every finding it had, byte for byte. A dangling value is still the
  declared finding at the declared severity, now with the loader's own
  did-you-mean over the ids that DO exist. Every resolved value lands in the
  IR as `Row.rowRefs` (a new field — `Row.refs` stays the URI-scheme
  citations): column, target block, target id, and the TARGET's uuid when the
  record carries one, so a stored edge can survive a display-id rename —
  though resolution itself still matches display ids, per file, exactly as the
  dangling check has always meant. The edge then travels: the slimmed row
  payload carries `deps` (ref id + the target row's DERIVED state, precomputed
  server-side because the ref column need not be a display column), a board
  card badges each dep with its id and the target's state chip — toned through
  `shared.colors`, the format's words, no engine opinion about what "blocked"
  would mean — and the timeline's rows mode accepts `bind.depends`, the first
  of its four planned binds to ship: each item carries its dep targets'
  key/state/uuid and the client draws an elbow connector from the target's bar
  to the item's. Honest limits: an arrow needs BOTH endpoints drawn in the
  same widget — a dep whose target is listed without a bar, excluded by
  `where:`, or is the row itself still ships in the payload but draws nothing;
  the connector overlay originally assumed the track fits its container — a
  recorded limit this same unreleased cycle closed (see the review-hardening
  entry above: the overlay now shares the rows' own layout box); a
  self-reference and a dependency cycle are representable and produce no
  finding — judging them is a `checks:` question a format may some day ask,
  not the engine's; and a `bind.depends` naming a column with no `ref:` is
  refused, as is the bind in concepts mode. The questions wiring splits the
  same way, so a question about `f1 · f2` now marks BOTH rows it names instead
  of exactly neither. `duration`/`progress`/`parent` binds, drag/resize and
  the zoom presets stay planned and loudly refused (E18 t6/t7 territory, with
  the rest of the cloud KB backlog).
- **Review hardening for the board and the timeline** — the loud-refusal rule
  the two widgets stated now holds at every key, and two quiet write/read
  hazards are closed. The derived-state board is now read-only in FACT, not
  just in prose: a stored enum column that happens to be keyed `state` (with a
  bound control and `card: hide`) no longer lends its menu to a
  `lanes.by: state` board — that menu wrote todo/doing while the lanes read
  built/open, a referee write that posted and visibly moved nothing; the
  control host now travels only when the lane source is a stored column. The
  bind guard generalises to top-level widget keys, with the catalogue's
  `accepts:` list as the single source: `zoom:` on a timeline (the spec's own
  canonical example) and `sort:`/`drag:` on a board are refused loudly today
  instead of meaning nothing until they ship; a typo inside `lanes:` (`oder:`)
  is a load error with did-you-mean instead of silently rendering the full
  vocabulary; `lanes.order` refuses a repeated lane (it rendered its cards
  twice and double-counted the lede); the `lanes.by` unknown-column refusal
  gets the loader's did-you-mean like every timeline resolver; and a
  multi-block board refuses a lane column (or a `state` with no derive rules)
  that only SOME fanned blocks declare — the lacking block's whole population
  landed in the unset lane, indistinguishable from blank cells. A concepts
  timeline now refuses a bind on the four core fields the parser keeps outside
  frontmatter meta (`title` · `description` · `tags` · `timestamp`) — they
  validated cleanly and then read undefined forever, every document listed as
  'no timestamp' with no bar. A board-only view re-hosts the block's append
  forms once below the lanes (they are suppressed per lane fragment), so the
  canonical "Add task" flow never requires leaving the board. Timeline client:
  React keys carry the document path (row ids are unique per document only, so
  two documents both holding a `t1` mis-reconciled on re-render), and the
  today line reads the viewer's LOCAL civil date instead of the UTC calendar
  date — west of UTC it sat on tomorrow's grid all evening. The
  view-composition skill now teaches `widget: timeline` instead of using it as
  the invented-widget example, and the skills test refuses any real widget in
  that example — the coverage loop had matched the stale warning vacuously.
- **`widget: timeline` — a gantt's read half, from `dashboard.yaml` alone.**
  Items as bars on a horizontal time grid, from either of two sources:
  `select: concepts` draws one bar per document (`bind.start`/`bind.end` name
  frontmatter date fields — the `date` field kind the loader already types —
  and bars colour by document type), or `from: <block>` draws one bar per row
  (`bind` names date columns; `color:` names a column whose values colour the
  bars). Colour goes through the one resolution every renderer uses: declared
  `shared.colors` win, the deterministic palette covers the rest, and the
  colour vocabulary shipped to the page is the column's DECLARED enum in
  declared order — observed values only where nothing is declared. The payload
  is PER-ITEM like the rows pipeline, never aggregated like the chart: every
  selected item ships, carrying either its normalized dates or the reason it
  gets no bar. Dates are validated at eval time against the ledger's own
  grammar (YYYY-MM-DD, calendar-checked): a malformed date — or an end before
  its start — is a finding naming the row, and the item is LISTED under the
  rail with the reason spelled in the format's own column names (`no Due`,
  `Start "soonish" is not a date`); an item is never silently dropped and the
  client never computes NaN pixels. A bind naming a column or frontmatter
  field that does not exist is a load error with the loader's own did-you-mean
  phrase, and the unknown-widget refusal now names `timeline`, so an old
  engine fails loudly on the new spelling. The client is plain deterministic
  DOM over pure shared math (src/render/timeline.ts, unit-tested: Monday-
  aligned week ticks under ~10 weeks, month ticks after, end-INCLUSIVE bars so
  a one-day item still has width, a today line only when today falls inside
  the data's own span) — no vendored gantt runtime, nothing added to the
  bundle's third-party weight. The frozen left rail is the concept title or
  the row's card-title column and opens the document through the same
  delegated click as every view; hovering a bar shows the label and the exact
  dates. Render-only THIS pass, and the refusals say so: bar drag/resize and
  the edit popup (writes through the same referee gate as the board's
  menu-move), `duration`/`progress`/`parent`/`depends` binds, and zoom
  presets are deferred — a planned bind key is refused loudly today, never
  silently ignored, so tomorrow's spelling cannot mean nothing on today's
  engine.
- **`widget: board` — a kanban over block rows, from `dashboard.yaml` alone.**
  The board is the rows pipeline fanned into lanes: `from`/`where` select rows
  exactly as `widget: rows` does (the members ride the same item shape, so
  `filter`/`filters`/`search` and split-mode trimming all come along for
  free), and lanes are the `lanes.by` column's DECLARED enum vocabulary — the
  format's words, in the format's order, never observed values. `lanes.order`
  subsets/reorders it, `lanes.empty` names the lane a row with an unset (or
  out-of-vocabulary — surfaced, never dropped) cell lands in, `wip:` puts a
  number in the lane header (`3/2` reads over-limit). Lane colour is
  `shared.colors` through the one tone system; the lane's hover and its
  empty-state copy are the format's per-value `about:` — the engine still
  authors no meaning. Cards are the block's OWN card grammar
  (`display.columns[].card`), forced to card presentation whatever the block's
  tables declare, and each card hosts the lane column's `control:` menu — on
  the card header when the natural kanban spelling hides the lane column
  (`card: hide`), in its cell otherwise. Moving a card POSTS byte-identically
  to the table cell's pick: one write path, one referee gate, direct mode's
  undo toast and proposal mode's staging unchanged; a refused write moves
  nothing, because nothing moves optimistically. `lanes.by: state` (the
  derived state, laned by the derive rules' own then/else vocabulary) or a
  host that cannot write renders a READ-ONLY board — a feature, stated in the
  widget's `about:`: you cannot drag a card to "built", evidence makes it
  built. Every bad spelling is a load error quoting the declared vocabulary —
  an unknown `lanes.by`, a non-enum column, an `order:`/`wip:` word outside
  the enum, a lane column the page carries no value for (neither displayed nor
  form-written: every card would land in the unset lane, silently) — and the
  unknown-widget refusal now names `board`, so an old engine fails loudly on
  the new spelling. Pins in test/board.test.ts follow the card-controls style:
  real client components over a real server payload, JSON round-tripped;
  the board payload keeps its members under the exact key `groups`, asserted
  with `payloadRowGaps` against a split build, because split mode finds a
  view's blocks BY SHAPE and a renamed key renders empty only on the hosted
  copy. Drag-and-drop is deliberately deferred: menu-move first, drag as a
  gesture binding over the same single-field write when it comes.
- **`filter: { by: document }` — the epic filter on rows views and boards.**
  A rows facet validated `by:` strictly against observed cell keys, so the one
  thing every cross-document view wants to narrow by — the document a row
  belongs to — was reachable only through power search. `document` is now the
  same special word search already resolves (resolved before the column check,
  so it keeps the special meaning even beside a column literally named
  "document"), and it travels in the root-facet shape the client has resolved
  since 0.6 (`roots`: concept-path → title, built over the rows the view's
  `where:` keeps) — the client needed zero changes, and `as:`/`multi:`
  compose on it like on any facet. The catalogue's `filter` entry documents
  the spelling.

- **Review hardening: `value:` must be one scalar word — everywhere `enum:`
  is spelled.** Two review findings against the entries above, both the same
  disease the entries claim to cure. First (must-fix): `resolveEnumEntries`
  checked `value:` for absence but not for SHAPE, so `value: {nested: word}`
  stringified into the vocabulary as "[object object]" and `value: [a, b]` as
  the single word "a,b" — zero findings, every real cell suddenly outside the
  vocabulary. A non-scalar `value:` is now an error naming the position and
  the shape, and the entry contributes nothing. Second (should-fix): the
  SIBLING enum site — `types.<t>.frontmatter.{required,optional}.<f>.enum` —
  still compiled via `map(String)`, so a `{value, about}` entry there became
  "[object Object]" silently and then EVERY document carrying the real value
  was flagged per document against a vocabulary nobody wrote. The changelog
  below announces the entry grammar unqualified, so that site now speaks it:
  same parser, same refusals, values in their written case (the frontmatter
  check compares verbatim), prose on the compiled field spec (`FieldSpec.
  about`) and printed by `dj catalog --format` as `note.priority = high` under
  formatValues — prose reaching nobody is the failure `about:` exists to
  close.
- **The phone reader keeps a write path under `narrow: cards` + `card: hide` +
  `control:`.** Review finding: the loader refusal below covers only
  `wide: cards`; on narrow-only cards the control was suppressed in card mode
  AND the bound form was filtered out of the row's action buttons as "already
  surfaced by a control" — so on a phone that column had no write surface at
  all, silently. And this is the natural kanban spelling (hide the lane
  column on cards; the lane conveys it), exactly where the board widget's
  write primitive will live. Ruling: the fallback is free, not forbidden —
  when card mode suppresses a hide-role control, its form now falls BACK to
  the row's action buttons. Menu-move first, accessible fallback free; the
  `wide: cards` load-time refusal stays, because there the hide role's intent
  itself is unsatisfiable.
- **Format prose can no longer break out of the menu's title attribute.**
  Review finding: routing per-value `about:` into the control menu's `desc`
  sent format-authored prose through the CLIENT `esc()`, which escaped `&
  < >` but not quotes — and menu.ts interpolates desc into `title="…"` via
  innerHTML. A `"` in the prose ended the attribute; crafted prose injected a
  live event handler into every reader's control menu, on the page where
  writes are authorized. The client `esc()` now escapes `"` and `'` too,
  matching the server-side esc that guards the SAME prose on chips — two
  consumers of one contract, one answer.
- **A value named `constructor` fabricates nothing; `__proto__` keeps its
  prose.** Review finding: every per-value lookup indexed a plain `{}` with a
  document/format-supplied word, so `enumAbout['constructor']` walked
  Object.prototype and returned a FUNCTION — an engine-fabricated hover, a
  garbage formatValues entry, function source as a chip colour — while prose
  on a value literally named `__proto__` vanished into the setter. Plausible
  words in a code-documentation format, not an exotic attack. Every
  value-keyed map the loader and renderer build is now prototype-free
  (`Object.create(null)`: enum prose, shared.colors, tones), and every
  client-side lookup over a JSON-revived payload map goes through an own-key
  guard (`own()` in the store: choiceAbout, tones, colors).
- **Card-mode control behaviour is pinned by rendered markup, not source
  text.** Review finding: the only fix-sensitive assertion for the card-mode
  control was a regex over the fix's own source inside PAGE_JS — tautological
  (it can never catch a regression that keeps the string, and it breaks on
  any harmless refactor), and the positive half of the spec ask ("cards must
  render the bound column's control menu") had no assertion at all. New
  test/card-controls.test.ts renders the real client component against a
  payload built by the real server pipeline, JSON round-tripped as the page
  revives it: the card carries the control trigger; `card: hide` suppresses
  it structurally and the form falls back to an action button; the wide
  viewport flips both back. Each pin verified against a mutated engine that
  keeps the old behaviour. The PAGE_JS source regexes those pins replace are
  retired.
- **A control keeps its write surface in the cards layout.** The standing ask
  from the reference vault: a column's `control:` menu rendered only in table
  layout, so the phone reader (and soon the board widget, whose cards move
  between lanes through exactly this menu) lost the write surface the desktop
  had. The rewrite already renders cards as the SAME row markup restyled, so
  the control was structurally present — what was missing was the card-grammar
  interplay, and the tests that keep it true. Now pinned: one control site
  serves both layouts, one op body, one POST — a card's pick posts what the
  table's pick posts, referee gating and the derive-read refusal ("derived
  state is computed, never edited") identical, sidecar routing untouched. The
  control attaches to whatever card role its column renders (badge, field);
  a `card: hide` cell never renders in card mode, so its control now does not
  render either — structurally, not as an accident of `td.c-hide`'s
  `display:none`.
- **A control that could never render is a finding, not a quiet absence.**
  `control:` on a `card: hide` column under `layout.wide: cards` means the
  block is cards at EVERY width and the write surface would appear exactly
  nowhere — the silent-disappearance failure this changelog keeps naming, on
  the write path this time. The loader now refuses it (`definition`: "control
  on a card: hide column"), telling the author to give the column a rendered
  card role or drop the control. `narrow: cards` stays legal with a hidden
  control column: the desktop table still renders the menu, and hiding a
  column on cards is a choice the card grammar exists to express.
- **`about:` reaches enum values.** 0.8.0 let a format explain its types, its
  blocks and its columns; the vocabulary itself stayed mute — `enum: [chore,
  spike]` pins the words and cannot say that "spike" is work this vault is
  willing to throw away. The board widget needs exactly that prose for lane
  tooltips and empty-state copy, and it has to come from the FORMAT: the
  engine carries prose, it never authors any. An enum entry may now be a plain
  string (unchanged) or a `{value, about}` map, mixed freely, inline or in
  `shared.enums` — one contract at both declaration sites, parsed once for a
  shared vocabulary. The prose lands on the compiled column as `enumAbout`,
  keyed by the LOWERCASED value so it lines up with the lowercased `enum` (key
  it by the written casing and every hover silently misses); the prose itself
  stays verbatim, per the `about:` ruling, and reuses `parseAbout` — empty
  warns, a list or mapping errors, both as `definition-about`, at the entry's
  own path (`blocks.b.kind.enum[1].about`).
- **The map spelling fails loudly, because the schema cannot catch it.**
  `enum:` is `free` in DEFINITION_SCHEMA, so the unknown-key walk never looks
  inside it — on the old engine a `{value, about}` entry sailed through
  `map(String)` as the vocabulary word `[object Object]`: no finding, prose
  reaching nobody, every real cell suddenly outside the vocabulary. The worst
  failure the loader file names for itself. So the shape is validated where
  the entry is parsed: a misspelled key inside the map is a
  `definition-unknown-key` error with the same "did you mean "about"?" answer
  the 0.8.0 sites got, a map without `value:` is refused and contributes
  nothing, and a non-list `shared.enums` entry is now an error instead of a
  crash.
- **Three consumers, no engine English.** `dj catalog --format` gains a
  `formatValues` group (`tasks.Kind = spike` + the prose) after the engine
  catalogue — addition, never substitution, and only for values a format
  bothered to explain: the column entry already names its whole vocabulary,
  and flooding the list with unexplained words would change what every
  existing format prints. Chips carry the value's prose as their hover
  `title=` where they draw. An options field's `from_enum:` choices stay plain
  strings (every payload consumer types them that way) with the prose beside
  them as `choiceAbout` — the form's option list shows it as the description
  line, and control menus render it as their hover for free, the same way
  per-row `from:` options always could.

## 0.9.1 — 2026-08-04

- **The packaged templates practice what 0.9.0 preaches.** Both formats declare
  `identity: uuid` on their id-bearing blocks; the starter's example question
  and every register row of the programming template's worked vault live in
  `.records.yaml` sidecars, signed. Migrated with the same IR-equivalence
  verification the real vault got — 102 rows compared row for row, zero
  mismatches, lint at exact pre-migration parity (29 clean, 0 errors, 1
  pre-existing warning). A vault scaffolded by `dj init` now demonstrates
  records-beside-the-document from its first minute.
- The template test loaders learned to load sidecars — they read `.md` files
  only, which after the migration was loading half the fixture. Assertions
  untouched.

## 0.9.0 — 2026-08-04

**Signed records in separated files** — the engine half of a decision made in a
real vault after a real merge went wrong: three id collisions where the same
`f15` had come to mean a different feature on each branch, every
cross-reference remapped by hand.

- **Records can live in a YAML sidecar beside the document.** `Foo.md` pairs
  with `Foo.records.yaml` — opt-in by suffix, so an unrelated `X.yaml` is never
  mistaken for records, and an orphaned records file is reported rather than
  silently becoming an asset. Records route through the same validation a
  markdown table does, so the IR is identical either way: derive, links,
  render, dashboards and the graph cannot tell the containers apart. A broken
  sidecar is an ERROR, never an empty register — a typo must not read as "no
  records". A block carried by both a table and the sidecar is an error too.
  Scalars are parsed with YAML's failsafe schema: `1.10` stays `"1.10"`,
  because a cell is text and the author's characters are the value.
- **`identity:` on a block gives every record a uuid that IS its identity**,
  with the display id (`f15`) as a rendered mask over it. Minting is
  WRITE-ONLY: `dj lint` never mutates the vault — a record with no uuid is a
  finding, and **`dj mint`** (the 14th command) is the deliberate write that
  clears it, byte-preservingly. Uniqueness is linted within a file and across
  the vault, case-insensitively. The uuid never renders unless the format
  declares it as a column.
- **` ```dj-records ` embeds** pull a block, or chosen columns of it, back into
  the prose — expanded on read, the markdown keeps no copy, and an embed that
  cannot resolve says so at parse time AND on the page.
- **Blocks render themselves.** A sidecar block with neither a hand-written
  heading nor an embed synthesizes a section under its declared `heading:`,
  after the prose, in the type's declared block order. `display: { page: off }`
  is the opt-out. Precedence: embed, then hand-written heading, then
  auto-render.
- **Form writes route to the sidecar** when the block lives there — `dj
  annotate`, dashboard forms and changesets edit the YAML byte-preservingly,
  the referee trials the would-be sidecar and refuses error-introducing writes,
  `{auto}` keeps one allocator rule across both containers, and the identity
  key is never settable through a form.

### Tests

- 345 tests (93 → 345 since 0.7.0), of which 60 are new to this release across
  seven files; every new test was shown to FAIL without its fix before being
  trusted. The golden fixture predates the current reference-vault commit and
  those 4 still skip — re-auditing it is open work, stated rather than hidden.

## 0.8.2 — 2026-08-04

- Split-mode rendering keeps the blocks that nested rows widgets need — the
  asset collector walked only top-level `kind: rows` views and dropped the rest.

## 0.8.1 — 2026-08-03

- The graph projection reports rows it DROPS (`GraphDropped`), and finds row
  groups by shape anywhere in a view tree rather than only at the top level.

## 0.8.0 — 2026-08-02

- **`dj dashboard` watches `format.yaml`.** The dev server hot-reloaded the
  vault and `dashboard.yaml` but froze the format: the definition was compiled
  once at startup and the change sweep never looked at `format.yaml` at all. The
  way anyone adds anything is to declare a block AND a view over it in the same
  sitting, and that pair rebuilt against the startup definition — so
  `evaluateDashboard` threw `block "risks" is not declared by the format`, `GET /`
  swallowed it and served the previous page (an edit that visibly did nothing),
  and the write handlers did not swallow it: `/annotate` wrote the document to
  disk, called `rebuild()`, and returned the format error as a 400 inside
  whatever form the reader happened to be using — an error about the format,
  attached to a write that had in fact succeeded. The write gate refused the new
  block outright (`unknown block "risks"`), because the referee was a definition
  from minutes ago. `format.yaml` is now stamped with the rest, the definition is
  recompiled when its mtime moves, and the sweep runs BEFORE a request is
  answered rather than only after a write — so the gate referees against the
  format on disk, not the one this process started with. A `format.yaml` caught
  half-saved (every editor writes one) prints its findings to the console and
  leaves the last definition that DID compile refereeing: `dj lint` answers an
  invalid definition with exit 1, and a running server may not do that to a
  reader who is only looking at a page. The repair has a new mtime and is picked
  up, so a definition that failed to compile never becomes the reason the format
  is not read again.
- **A question may be ABOUT any declared block.** `ref: { block: … }` now takes
  a LIST as well as a single name —
  `ref: { block: [features, tickets], column: ID, dangling: error }` — and the
  `questions:` wiring over it targets every block named, so ONE `open-questions`
  block serves a `flow` whose questions are about features and an `epic` whose
  questions are about tickets. With one target, a question about a ticket had
  nowhere to put the ticket id, so the shipped template taught naming it in the
  question TEXT: an untyped reference that joins to nothing, derives nothing and
  cannot be counted, sitting in the format's own question block. Every type
  carrying `open-questions` but not the single named block was worse still —
  every non-empty `About` in those files dangled. The widening is a ref TARGET,
  not new vocabulary: `block: features` compiles and behaves exactly as before,
  resolution stays PER FILE (a named block this document's type does not carry
  still dangles, and questions never leak across blocks), and the dangling and
  id-grammar findings now name every block that would have been valid —
  "does not exist in this file's features or tickets", as blocks, not as one
  coerced string. A `block:` naming an undeclared block is now a load-time
  error: alone, a typo announced itself by making everything dangle, but inside
  a list the other targets keep resolving and it would do nothing at all,
  silently. `questions-wiring-undeclared` is unchanged in force — every block a
  wired ref names counts as wired, and a block nobody points at that reads
  `open_question_about` is still told to declare its wiring.
- **`about:` — a format can finally say what its own names MEAN.** An optional
  string on a type, a block and a column, kept verbatim by the parser and
  carried into the compiled definition. `enum: [chore, spike]` pins a
  vocabulary and says nothing about what "spike" means to this vault; that
  knowledge was written as a YAML comment, and comments are stripped before the
  loader ever sees them, so it reached no consumer at all — not `dj catalog`,
  not a rendered page, not the coding agent filling in the table. The shipped
  skills teach the format LANGUAGE; nothing but the format itself can teach
  what a particular Kind column means, because no skill has read your vault.
  `dj catalog --format format.yaml` now prints your types, blocks and columns
  with their prose AFTER the engine catalogue — an addition, never a
  substitution, since a definition picks from the catalogue and can never add
  to it, and `dj catalog` with no format prints exactly what it always has. It
  is prose the referee cannot check: the same format with and without it
  produces the same findings, the same rows and the same render. A misspelled
  `abuot:` is still refused and now answers "did you mean "about"?", because
  the key was added to the loader's schema as a real sibling rather than by
  exempting those three sites from the unknown-key check.
- **`dj scan` — the inventory finally has a producer.** The engine has always
  consumed one (`links.ts` reconciles `ref.resolved` against it, `report.ts`
  counts the reverse gap from it, CI is told to pass `--inventory
  artifacts.json`) and nothing in this repository ever wrote one, so in practice
  a row could cite a file deleted six months ago and stay `built` forever.
  `dj scan --source app=../app --source lib=../lib --out artifacts.json` walks
  the checkouts you name and emits `{ artifacts: [{ uri, kind }] }` — the exact
  shape `Inventory` declares, so producer and consumer meet with no adapter.
  A citation is `<scheme>://<source>/<path>`, so `--source` binds the FIRST PATH
  SEGMENT rather than the scheme: one source answers for `repo://app/…` and
  `test://app/…` alike, because they are the same checkout, and the scanned
  directory's own basename never appears in a URI. Only schemes that name files
  are walked (`repo`, `test`, `src`, `code`, `file`, plus any the author claims
  with `--scheme`); `api://`, `db://`, `event://` and `route://` name endpoints,
  tables, topics and URLs no directory walk can confirm, so they are reported as
  skipped rather than emitted green. Every extension counts as evidence and
  paths are spelled the way a citation must spell them (`Example%20Concept.md`),
  because an allowlist or a raw space would turn a TRUE citation into false
  drift. Output is sorted and deduplicated, so two scans of an unchanged tree
  are byte-identical and a committed `artifacts.json` diffs only where the world
  moved.
- **Templates stop teaching a deprecated spelling.** Both scaffolds compose
  their home view from widgets instead of the `pack:` shorthand 0.7.0
  retired, so `dj init` no longer hands a new user a deprecation warning on
  their first render. Every number survives the move — the tiles a pack used
  to push now come from the composed Overview view, and the scaffold tests
  assert them there. This supersedes 0.7.0's decision to keep `pack:` in the
  templates through a grace release: the grace period is for existing
  dashboards, not for the artifact newcomers learn from.
- **The programming template ships a development backlog.** A new `epic` type
  and `tickets` block put work under `Backlog/` and hold it to the format's
  own rule: NO stored status and no priority column (both are `forbid`den, so
  the rule is enforced rather than merely followed). A ticket derives
  `missing` → `partial` → `built` from its evidence, so "done" means the
  `Implemented` cell cites shipped code. A `Context` column links the
  documentation holding each ticket's requirements, and a check fires when it
  is empty — the link is the reason a backlog belongs in the vault at all.
  The sample epic shows all three states and a dependency map; the dashboard
  gains a Backlog view.

## 0.7.0 — 2026-07-31

The genericity release: the engine sheds its last hardcoded vocabulary.
**Breaking** — every removal is listed with its migration line, and the four
that need one are gathered under MIGRATION below.

- **`tested` is gone; every declared scheme gets `cites_<scheme>`.** A format
  declaring `schemes: { test: … , repo: … }` gets per-row boolean builtins
  `cites_test` and `cites_repo` in derive `when:` and dashboard `where:` —
  syntactic citation without an inventory, resolved citation with one, exactly
  the rule the old builtin had. A scheme named `test` gets no special
  treatment; `Row.tested` left the IR and the graph's `row_` table has no
  `tested` column. *Migrate:* replace `tested` with `cites_test` (or whichever
  scheme carried your claim); the coverage pack's second number now binds by
  query — `bind.tested: "cites_test"` — symmetric with `bind.built`.
- **Question wiring is declared, never guessed.** The heuristic that watched
  for a `ref:` column beside an enum containing the literal `open` is removed.
  A questions block declares its wiring:
  `questions: { ref_column: About, open_when: "status == 'pending'" }` —
  `ref_column` resolves by column name, `open_when` is the query language, so
  any status vocabulary works. A derive rule reading `open_question_about`
  without a declaration is a load-time finding telling you what to declare.
  *Migrate:* add the `questions:` line to the block your About column lives in.
- **Flags are a generic declared registry.** Every flag a type declares
  reaches the dashboard concept context under its own name — `where:` selects
  by any of them, not just the historical `stub`/`canonical` pair — and the
  index generator suffixes entries with every declared flag by its own name.
  Undeclared `stub`/`canonical` get no special treatment: a frontmatter field
  with one of those names now survives into the query context instead of
  being clobbered.
- **The catalogue is the dispatch table.** `catalog.ts` single-sources every
  composable vocabulary: chart marks, widgets (the evaluator's vocabulary IS
  the `WIDGETS` export, and the built page carries a dispatch branch per
  entry), field kinds, and display-column styles — an unknown `style:` is now
  a load-time finding naming `plain · mono · md · chip`, and an unknown
  widget's refusal quotes the catalogue verbatim.
- **Chrome and marker hygiene: the engine's own UI speaks no format's
  words.** "Requirement", `repo://` and other template vocabulary left the
  client bundle; the ref-map's resolved/missing chips use an engine-owned
  marker (`uri-ok`/`uri-bad`) instead of resolving through the format's tone
  namespace; naive `+"s"` pluralization got a `plural()` guard. The path
  explorer's ring and legend derive from the DECLARED tone of a row's state —
  no literal state word, no hardcoded English in the client. And declared
  `shared.empty_markers` finally reach the app payload (`emptyMarkers`), so
  the client's empty/not-empty checks read your markers, not a literal em
  dash.
- **Packs are deprecated toward preset compositions.** The three builtin
  packs are now preset spellings on the generic views/widgets path —
  `{ widget: stat-row, preset: traceability, bind: {…} }`,
  `{ widget: coverage, bind: {…} }`, `{ widget: reverse-gap, bind: {…} }` —
  same bind vocabulary, same numbers. `use: [{ pack: … }]` still evaluates
  for this release but emits `dashboard-pack-deprecated` (warn) per use,
  naming the replacement; a dashboard on the new path carries no bespoke
  result fields (`tiles`/`coverage`/`coverageLegend`/`reverseGap` absent —
  the numbers live in the composed view). Top-level `indicators:` keeps
  writing `tiles`.
- **`dashboard.yaml` composes from fragments.** A top-level `include:` lists
  vault-relative fragment paths (conventionally `views/*.yaml`), each a
  mapping whose only key is `views:` with exactly one entry — inline views
  first, then fragments in include order. Composition is checked: a duplicate
  view id names both fragments, a fragment with zero or two views is
  rejected, and dashboard-level keys (`theme:`, `navigation:`, …) may not
  hide in a fragment — every violation is a `DashboardError` naming the
  offending path. `evaluateDashboard` gains `opts.fragments` (path → yaml
  text); the engine stays a pure text evaluator, the CLI reads the files.

### MIGRATION

- `use: [{ pack: <name>, bind: {…} }]` → the preset spelling in
  `views[].widgets[]`: `traceability` becomes
  `{ widget: stat-row, preset: traceability, bind: {…} }`, `coverage-by-type`
  becomes `{ widget: coverage, bind: {…} }`, `reverse-gap` becomes
  `{ widget: reverse-gap, bind: {…} }`. Binds are unchanged.
- `tested` in any `when:`/`where:`/`pct_where:` → `cites_<scheme>` for the
  scheme that carried the claim (usually `cites_test`); the coverage bind
  gains `tested: "<query>"` — without it the second number counts every row.
- Implicit question wiring → declare
  `questions: { ref_column: <Column>, open_when: "<query>" }` on the
  questions block; the load-time finding names the rule until you do.
- Monolithic `views:` → optionally split into `views/*.yaml` fragments (one
  view per file) listed under top-level `include:`; semantics are identical
  to the inline spelling.

## 0.6.4 — 2026-07-30

- **Review and Delete are host-declared capabilities.** The status and delete
  endpoints travel in the payload (`--feedback-status-url` /
  `--feedback-delete-url` on a static render; serve mode declares its own), and
  the buttons render only where the matching endpoint exists — a host that only
  accepts captures never shows controls that would 404. Review buttons no
  longer assume the default status is literally `new`; the declared default
  (or the vocabulary's first status) decides.
- **Card structure follows the layout the reader sees.** `layout: {narrow:
  cards}` markup (header trigger, comments panel, beside links) now switches
  with the viewport, matching the breakpoint-gated stylesheet — a narrow-cards
  block renders as a plain table on desktop again. `cards-wide` is unchanged.
- **Block filters match the declared contract.** An enum column's filter
  offers its *declared* values, not whatever one document's rows contain; a
  filter naming a column outside `display.columns` is an authoring **error**
  (the runtime matches displayed cells); a non-list `filters:` is an error too.
- The per-row comments panel's internal key moved out of the capture-kind
  namespace, so a capture kind named `history` no longer collides with it.
- Serve-mode HTTP tests for `GET /feedback`, `POST /feedback/status` and
  `POST /feedback/delete`; `dj catalog` now lists capture `review:`/`beside:`
  and `display.filters`; skills document the same.

## 0.6.3 — 2026-07-30

- **Block tables get the power search.** A block may declare
  `display.filters: [state, Kind, Gap, Tests]` — the same property · operator
  · value token bar the rows views use, scoped per concept + block. `state`
  searches the derived state; text columns answer presence questions
  ("Gap is not empty", "Tests is empty").

## 0.6.2 — 2026-07-30

- **The capture conversation lives with its row.** Card layouts gain a
  per-row header with a Comments trigger; records and capture forms move into
  a panel behind it. A capture kind may declare `beside: {column, label}` to
  surface its records from a column's label ("View proposal" beside Gap).
- **Records can be reviewed and retired.** `review:` on a capture kind
  declares verdict buttons (label → declared status); `dj feedback rm` /
  `POST /feedback/delete` remove junk outright; `GET /feedback` in serve mode
  lets the page reflect a new capture without a reload.
- Cards in a block join into one hairline-separated frame, rounded at the
  ends; the comments panel continues its card's frame.
- The programming template adopts the set: an always-available Comment kind,
  an agent-authored `gap-proposal` kind with Endorse/Ignore review, an
  `archived` status, block filters, and features as cards at every width.

## 0.6.1 — 2026-07-30

- **Feedback captures can declare review actions.** `review:` on a capture
  kind renders verdict buttons on its records (each targeting a status from
  the declared vocabulary) wherever a host accepts them; serve mode gains
  `POST /feedback/status`, the HTTP twin of `dj feedback set`.

## 0.6.0 — 2026-07-29

- **Facets can render as a searchable dropdown, and a view can carry more than
  one.** A facet now declares how it draws: `as: tabs` (the default — the
  segmented strip, single-select, byte-for-byte unchanged) or `as: dropdown`, a
  compact searchable dropdown for a high-cardinality field where a strip would
  overflow (100+ buckets, a large `root:` rollup). `multi: true` makes a facet
  multi-select — its chosen values **OR** together (rows in ANY selected bucket),
  nothing selected still means all. Tabs stay single-select.
- **`filters: [ … ]`** carries a LIST of facets on one view, alongside the
  singular `filter:` (which keeps working). Facets **AND** across each other
  (`Status == open` AND `flow ∈ {selected}`) while a multi-select facet ORs its
  own values — so a low-cardinality field can stay tabs while a high-cardinality
  one is a multi-select dropdown on the same view. Each entry takes the full
  facet vocabulary (`by` · `label` · `empty` · `default` · `root` · `as` ·
  `multi`) and is validated exactly as `filter:` is; the 0.5.0 `root: true`
  rollup composes with `as: dropdown` + `multi: true`.
- **Validation:** an unknown `as:` value is a load **error** (`filter.as "grid"
  is unknown (tabs · dropdown)`), and every `filters:` entry is validated the way
  `filter:` always was — a bad column, an unresolvable `root:` field, or a
  missing `by:` all fail at load, never as a silent empty facet. Field-agnostic:
  no field or value name is hardcoded. The dropdown reuses Astryx's
  `MultiSelector`/`Selector`; tabs still use `SegmentedControl`.

## 0.5.0 — 2026-07-29

- **A `select: rows` query now inherits its document's frontmatter.** `rowCtx`
  spreads the concept's `meta` before the reserved fields, exactly as `conceptCtx`
  already does — so a row can be filtered or searched by any field its document
  declares (`app`, `status`, `parent`, anything authored), not only its own
  cells. The row's cell columns and the reserved fields (`state`, `type`,
  `document`, `id`, `path`, `domain`) still win where names collide, so nothing
  that queried a column before changes; `where: "app == Storefront"` over a rows
  view now keeps only rows whose *document* names that app.
- **New facet: `filter: { by: <field>, root: true }`** rolls a view up to the
  ROOT of a self-referencing hierarchy. `by` is any self-referencing frontmatter
  field — the same shape `tree: { by }` walks — and each row or concept is
  bucketed under the topmost ancestor its chain reaches, resolving each value to
  a document by title or `name` (the resolution graph edges use). A document with
  no value, or one nothing points through, is its own root. The walk is
  cycle-guarded and the rollup is precomputed once over the whole corpus, so
  `root: true` works identically on `select: rows` and `select: concepts`. A `by`
  that is not a resolvable self-referencing field is a load **error**, not a
  silent empty facet. Field-agnostic: no field name is hardcoded.

## 0.1.2 — 2026-07-28

- **A key the engine does not read is now a finding**, with a suggestion:
  `blocks.tasks.columns[0].id_grammer: not a key this engine reads — it would
  have no effect — did you mean "id_grammar"?`. Previously an unknown key was
  ignored, so a one-character typo silently disabled a rule and surfaced much
  later — as an unrelated dangling-reference error in some document, if at all.
  For a tool whose claim is that the format is data and the linter is the
  referee, a mistyped key passing in silence was the worst failure it had.
  The schema lists every key the loader, renderer and dashboard evaluator read;
  adding a key to the code means adding it here too.
- **Authoring mistakes report themselves instead of crashing.** A bad
  `dashboard.yaml` threw an uncaught `DashboardError` with a Node stack trace,
  burying a message that already named the view, the widget and the valid
  options. `DashboardError`, `DefinitionError` and `EmbeddingError` now exit 1
  with just that message; anything else still throws, because an unexpected
  error is the engine's fault and a stack trace is the right output for it.

## 0.1.1 — 2026-07-28

- **Dot-directories are no longer part of the vault.** `dj lint`, `dashboard`
  and every other command skipped them for assets but not for markdown, so
  `.claude/skills/*.md` — instructions an agent reads — were parsed and linted as
  though someone had written them as documents. The rule now lives in one place
  (`src/vault.ts`) that the CLI and the tests share; three copies of the walker
  had already drifted apart.
- New skill **`docujoint-operating`**: the loop around authoring — serving the
  dashboard, using `dj lint` as the gate that decides when an edit is done, and
  working the capture ledger (`dj feedback`) that reader form submissions land
  in, locally and against a host.
- `dj dashboard --help` now lists `--feedback-upload-url`, which the command has
  always read. The help-drift test only checked that documented flags exist, not
  that read flags are documented; the new skill's flags are asserted in both
  directions.
- `dj <command> --help` sizes its flag column to the widest flag, instead of a
  fixed width the longest one overran.

## 0.1.0 — 2026-07-28

First public release. Everything below is "new" in the sense that nothing was
published before it; what is worth reading is the shape of the surface.

### The engine

- **A format is data.** `format.yaml` declares document types, typed block
  tables, evidence schemes, tree placement, derive rules and checks. The
  engine ships no vocabulary of its own: `built`, `open`, `blocked` mean
  whatever your format says, and nothing if it says nothing.
- **State is derived, never stored.** Derive rules compute a row's state from
  what it says and from whether the evidence it cites still exists;
  `forbid_columns` keeps anyone from writing that state down.
- **Evidence is reconciled.** With `--inventory`, a cited URI that the scan
  does not contain is a finding (`evidence-broken`, severity declarable via
  `vault.evidence.broken`) and drives the drift state. The reverse gap —
  artifacts nothing documents — is reported too.
- **The `when:` language is a parser**, not `eval`: `==`, `!=`, `in`,
  `startswith`, `contains`, truthiness, `&& || ! ( )`.
- Structural-ambiguity checks the usual linters miss: unclosed fences and
  comments, comments that swallow a heading, multiple H1.
- Diagrams are data — every mermaid fence parses into the IR as
  (kind, nodes, edges).

### Surfaces

- `dj dashboard` renders an app declared in `dashboard.yaml`: composed views,
  widgets, tabs, facet filters, power search, `for_each` view templates that
  become one board per document. Served by default, with forms writing back
  through the linter; `--out` writes one self-contained file that opens from
  `file://`.
- `dj graph` projects the vault into SQLite — nodes, edges, rows, citations,
  FTS5 — with a named query library shipped as SQL text, so the same statements
  run locally, in a Durable Object, and on D1.
- Embeddings are a validated side input; the engine never calls a model.
  `--embed-plan` says what needs a vector, `--embeddings` files what comes back,
  and query-time scoring happens after SQL has narrowed the candidates.
- `dj skills install --all` installs five authoring skills for coding agents.

### Command surface

- 12 commands, one per output artifact: `init` `lint` `dashboard` `report`
  `graph` `index` `annotate` `media` `feedback` `catalog` `skills` `parse`.
- `serve` was merged into `dashboard` — serving is what it does without
  `--out`. `check-refs` was removed: broken evidence is a lint finding now, so
  `dj lint --inventory --warnings-as-errors` is the single gate. Both retired
  names still answer, pointing at what replaced them.
- Help is generated from a command table, so `dj <command> --help` cannot drift
  from the flags the code reads — a test asserts both directions.

### Tests

- Golden tests read their vault path *and* their expected numbers from a fixture
  outside this repository (`DOCUJOINT_GOLDEN`). Nothing about a private corpus —
  its name, its size, the systems it documents — lives in the public repo, and a
  clone without the fixture skips those four and passes the other 93.

### Naming

- The project was called **docfmt** during development and was renamed to
  **docujoint** before this release. The command is `dj`, with `docujoint` as an
  alias. `DOCFMT_MEDIA_*` environment variables are still honoured for this
  release, with a warning naming their replacement.
