# ROADMAP.md — yxl

> **This file is the single source of truth** for direction, phase scope,
> architecture decisions (ADRs), open questions, risks, and the living
> changelog. Every change that touches scope, design, or status also touches
> this file (see `AGENTS.md §1`). Contributor & agent *workflow* lives in
> `AGENTS.md`; *direction* lives here.

---

## 1. Vision

`yxl` lets you **manage Excel spreadsheets as version-controllable YAML**. You
write a declarative `*.yxl.yaml` spec; `yxl build` compiles it into a real,
Excel-compatible `.xlsx`.

Spreadsheets-as-code, with the properties code has:

- **Diffable & reviewable.** A workbook is text under Git — meaningful diffs,
  code review, blame, CI. *Partly delivered:* a `cells:` mapping is keyed by A1
  address, so inserting a row rewrites every key below it and the diff looks
  total. External `data:` tables already escape that; diff-stable **inline**
  tables are Phase 11.
- **DRY / single-source-of-truth ("compression").** A value, formula, or style
  written **once** and referenced many times is managed in one place — and
  compiles down to Excel's *native* sharing (shared strings, defined names,
  shared formulas, a single style id). Change it once, it changes everywhere.
- **Flexible structure.** Keep data and formatting in separate files for large
  workbooks, or inline everything in one file for simple ones — same result.
- **A single native command.** `yxl build` — the spec can say where it goes.

The engine that writes the actual `.xlsx` bytes is
[`moonbitlang/mbtexcel`](https://mooncakes.io/docs/moonbitlang/mbtexcel) (a mature
MoonBit port of Go's excelize). `yxl` is the **compiler and CLI on top** — it
owns the language, the reuse/dedup model, validation, and ergonomics.

It is not another spreadsheet *library* (that is `mbtexcel`); it is a different
product category — declarative authoring for people who'd rather edit YAML than
write code.

**The direction matters.** `yxl` *generates* workbooks: reports, statements,
templates, anything whose shape is decided by its author and whose numbers come
from somewhere else. It does not fit the other common case, where a spreadsheet
*is* the human input surface and the file is the source of truth — there, edits
happen in Excel and a spec would be perpetually stale. Saying so up front (in
the README too) costs nothing and saves a mismatch later; `yxl import` (Phase
10) is the bridge *into* the model, not a promise to keep both sides in step.

## 2. Non-goals

- **Formula evaluation.** `yxl` *emits* formulas; Excel computes them on open.
  No built-in calculation engine.
- **Macros / VBA.** Out of scope (can be passed through verbatim at most).
- **Rendering / printing / a GUI.** Text in, `.xlsx` out. (Print *settings* —
  page setup, margins, print area, headers/footers — are still emittable config;
  `yxl` just never renders or prints anything itself.)
- **Imperative editing.** A spec declares the *final* grid; there are no
  insert/delete/move/duplicate operations for rows, columns, or cells (that is
  `mbtexcel`'s editing API — `yxl` emits the end state directly). Sheet order,
  visibility, and the active sheet are still declarative and in scope.
- **Watching files (`--watch`).** Decided out of scope 2026-07-26: a build is a
  single command, and a caller that wants rebuilds on change already has `make`,
  `entr`, or an editor task. (It was also unimplementable without adding a timer
  dependency — but the scope call stands independently of that.)
- **Being a general xlsx library.** That is `mbtexcel`; `yxl` depends on it.
- **Continuous xlsx ⇄ YAML round-tripping.** Keeping a spec and a
  human-edited workbook in step, in both directions, is not a goal: the two
  drift the moment someone types in Excel, and reconciling them is a different
  product. A **one-time import** (existing workbook → a skeleton spec, to get
  started) is a different thing and *is* planned — §6 Phase 10, §8 Q5.

## 3. Design principles

1. **Declarative & deterministic.** The same spec always produces the same
   workbook; no hidden state, stable ordering.
2. **Reuse is first-class.** Named definitions compile to Excel's native sharing
   mechanisms — never N copies of one declared thing. (ADR-004)
3. **Fail fast, explain well.** Invalid or ambiguous input is a diagnostic with
   file/line context, never a silent drop or a guess. (ADR-006)
4. **Backend behind a seam.** Model → bytes goes through one emitter interface,
   so the Excel backend is swappable and the core stays testable. (ADR-002)
5. **Core is I/O-free; the CLI does I/O.** `model` / `loader` / `emit` work on
   strings and bytes; filesystem access lives only in `cli`. Reading an
   `$include` or a `data:` table goes through a resolver the CLI supplies.
   (ADR-003, ADR-014)
6. **Type-safe boundaries.** No bare `Int`/`String` for cell refs, colors, or
   dimensions in internal APIs.

## 4. Package architecture

Target layout under `src/` (Phase 1 restructures the flat `moon new` scaffold —
ADR-008). Dependencies point *downward*; lower packages never import higher.
The compiler packages live under `src/internal/`, so no other module can import
them; only `cli`, `include` and `cmd/main` are public (ADR-028).

| Package | Purpose |
|---|---|
| `diag` | Diagnostics + subdomain errors (`YamlError`, `SchemaError`, `ResolveError`, `EmitError`, `CliError`), with source spans (file/line/col) |
| `units` | Type-safe values: cell references, colors, dimensions (or thin wrappers over the backend's typed values) |
| `yaml` | YAML source → a generic document tree (adopt or vendor a parser — §8 Q1); the parser sits behind a seam too |
| `model` | The typed intermediate representation: workbook / sheet / cell / value / style / named definitions |
| `loader` | Document tree → `model`; schema validation with diagnostics; expands `$include` through a reader the CLI injects (ADR-014) |
| ~~`resolve`~~ | **Never built.** Reference resolution fitted in `loader` and interning in `emit`, so ADR-012/ADR-013 folded this package away rather than add a stage with nothing of its own to do |
| `emit` | `model` → `.xlsx` bytes through the emitter seam; the `mbtexcel`-backed implementation lives here |
| `read` | `.xlsx` bytes → `model` through the reader seam (ADR-017); with `emit`, the only package importing the backend |
| `render` | `model` → document tree, the inverse of `loader`; where `extract` invents names for what the file shared |
| `cli` (`cmd/main`) | Argument parsing, file read/write, `--check`, `--set`, exit codes, help |
| `include` | The resolver types a caller of `@cli.compile` implements (`Included`, `IncludedBytes`); public because another module must construct them (ADR-028) |
| `examples` | No product code: the tier-2 test that compiles the `examples/` cookbook and asserts on its output (§5) |

## 5. Verification tiers

- **Tier 1 — In-repo MoonBit tests** (native). Unit + golden + round-trip tests.
  The bar for every phase. The compiler core is I/O-free so it tests on strings
  and bytes.
- **Tier 2 — Example specs** (CI). The `examples/` corpus is the cookbook, and
  it is tested twice over. `src/examples` compiles every `examples/*.yxl.yaml`
  in-process, re-opens the workbook, and asserts named cells hold what the
  example's comments claim — plus a coverage check both ways, so an example with
  no assertions, or an assertion naming a spec that is gone, fails. CI then runs
  the **shipped binary** over the same files, which is what exercises the on-disk
  include/data path resolution and the real exit codes.
- **Tier 3 — Real applications.** The cheap half is automated and runs in CI:
  `.github/scripts/validate-xlsx.sh` puts every `examples/` output through the
  Open XML SDK's validator — each part against the ECMA-376 schema plus the
  SDK's semantic rules — which is the first thing Excel does and the check
  Tiers 1 and 2 cannot perform, since both re-open our bytes with the library
  that wrote them. The other half stays manual and stays at the v1.0 gate: open
  the output in Microsoft Excel, LibreOffice Calc, and Google Sheets and *look*
  at it. A schema-valid workbook can still be a wrong one.

  **The validator only sees what the cookbook happens to use, and that is not
  the same as what the schema offers.** `examples/` is written to teach, so it
  reaches for one icon set out of twenty and four chart types out of fourteen;
  everything it never reaches for is unvalidated however long it has shipped.
  Three icon sets turned out to write a workbook Excel offers to repair, and
  they had been in the schema since Phase 9 (§9, §11). What closes this is a
  *second* corpus, `tests/validity/` — one spec per feature reaching for every
  variant of it, breadth over legibility, never shown to a reader. CI builds it
  with the shipped binary and puts it through the same validator, which is why
  it lives outside `src/examples`: that package asserts on cells and enforces
  a coverage check both ways, and neither is what this corpus is for.

  **A variant is not the same as a combination**, which the `<dxf>` order
  defect showed a phase later (§9, §11). `conditional.yxl.yaml` does reach for
  every rule kind and every comparison — twenty-four rules — and still missed
  it, because every look in it is made of a font and a fill, and the defect only
  appears once a `format:` or an `align:` joins them. So the corpus rule is
  wider than "one of each": where two features meet in one construct and the
  file records them together, build that meeting too.

---

## 6. Phase roadmap

Legend: `[ ]` not started · `[~]` in progress · `[x]` done.
The **active phase** is the first phase with any unchecked box.

### Phase 0 — Bootstrap
- [x] `moon new` scaffold (`t-ujiie-g/yxl`), Apache-2.0 license, README, `cmd/main`
- [x] `.githooks/` (`moon check && moon fmt`), `.claude/` plugin config
- [x] `ROADMAP.md` (this file) + `AGENTS.md` + `CLAUDE.md`→`AGENTS.md` symlink
- [x] Add `bobzhang/mbtexcel` dependency (`moon add bobzhang/mbtexcel@0.1.8`) — ADR-001
- [x] `.github/workflows/ci.yml`: `check` + `fmt --check` + `info` drift +
      `test --target native` + `build --target native`
- [x] Smoke test: `mbtexcel` builds a workbook and emits `.xlsx` bytes
      (asserts the `PK` ZIP magic), proving the toolchain end-to-end

### Phase 1 — Foundations (seam, model, diagnostics)
- [x] Restructure into the `src/` package map (ADR-008)
- [x] `diag`: source spans + subdomain error types
- [x] The **emitter seam** (ADR-002): a trait `model → bytes`, plus a minimal
      `mbtexcel` implementation (new workbook, one sheet, string/number cell,
      write)
- [x] Minimal `model`: workbook / sheet / cell / value
- [x] `units`: type-safe `CellRef` (needed by the model; §4 package)
- [x] Pick the YAML parser (§8 Q1) — record the decision as an ADR (ADR-009)

### Phase 2 — YAML → a sheet of values (walking skeleton)
- [x] Parse a minimal spec: `workbook › sheets › cells` (text / number / bool)
      — the `yaml` seam: source → a `Node` document tree (ADR-010)
- [x] `loader` maps it to `model`; `emit` produces `.xlsx` (incl. `bool` cells)
- [x] `cli`: `yxl build <in.yaml> -o <out.xlsx>` (native), exit codes (ADR-011)
- [x] Golden test: spec → bytes → re-open → assert cells

### Phase 3 — Rich cell values
- [x] Number formats (built-in + custom): the expanded cell form
      `{ value:, format: }`; equal format codes intern to one style id (ADR-004)
- [x] Dates / date-times (with a date system): `{ value:, type: date }`, the 1900
      (default) and 1904 systems via top-level `date1904`, serial conversion in `units`
- [x] Formulas (emit `<f>`; Excel computes on open) with optional cached value:
      `{ formula: "SUM(A1:A2)", value: <cached> }` (a leading `=` is accepted)
- [x] Booleans (Phase 2), error literals (`type: error`, validated), and explicit
      typing (`type: text | number | bool` coercion)

### Phase 4 — Styling
- [x] Named styles: font (bold/italic/size/name/color), fill, border, alignment
- [x] Number format as part of a style
- [x] Style **reuse** → a single interned style id per distinct style
- [x] Rich text: mixed fonts/colours within a single cell (`set_cell_rich_text`)
- [x] Column/row default styles and the workbook default font
      (`set_col_style` / `set_row_style` / `set_default_font`)

### Phase 5 — Reuse / dedup engine ("compression") — the differentiator
- [x] Named definitions for values, formulas, and styles (declare once) — the
      top-level `defs:` block (ADR-012)
- [x] Reference syntax: bareword name for styles, `{ $ref: name }` for values
      and formulas — explicit definitions, not YAML anchors (ADR-012 resolves Q3)
- [x] Compile references to Excel-native sharing: shared strings and shared
      style ids (already native — string dedup + style interning), and **defined
      names** for named values/formulas, referenced as `=name` (ADR-013). Shared
      *formulas* (fill-down grouping) await a range-formula construct — nothing
      references them, so not part of reference compilation.
- [x] Diagnostics for **dangling** references — every undefined style/value/
      formula reference is a fail-fast `@diag.SchemaError` (ADR-006), landed with
      the references above. *Cyclic*-reference detection is **not applicable
      yet**: definitions cannot reference one another (values are scalars,
      formulas are opaque strings, styles are self-contained), so no cycle can
      form. It belongs with whatever first lets a definition reference another
      (e.g. templating, §Phase 7) and will land there.

### Phase 6 — Layout & structure
- [x] Merged cells, column widths, row heights — a sheet `merges:` list of
      `A1:B2` ranges; `width:` on a column band and `height:` on a row band
- [x] Column/row visibility (hide/show) and **outline grouping** — collapsible
      row/column groups via outline levels (`set_{col,row}_outline_level`); a
      band takes `hidden:` and `group:` (level 0–7, validated in the loader)
- [x] Multiple sheets: order (declaration order is the tab order), active sheet
      (top-level `active: <name>`), sheet visibility (per-sheet
      `visibility: visible | hidden | very_hidden`)
- [x] Freeze / split panes, gridlines, tab color — per-sheet `freeze: <cell>`,
      `split: { x, y }` (points), `gridlines: false`, `tab_color: "RRGGBB"`
- [x] Page setup for print: orientation, margins, scaling, print area,
      headers/footers, page breaks (`set_page_layout` / `set_page_margins` /
      `set_header_footer` / `insert_page_break`) — a per-sheet `print:` block

### Phase 7 — Modular specs
- [x] Includes / data–format separation — `{ $include: path }` wherever a node
      may stand, resolved through a reader the CLI injects (ADR-014). A YAML
      `!include` tag is **not** possible: the parser drops unknown tags silently
- [x] External data sources — a sheet `data:` list anchors a CSV or JSON table
      at a cell; fields become ordinary cells, styled by the existing
      `columns:` / `rows:` bands rather than by the data block
- [x] Lightweight templating / parameterization — style `extends:` (a definition
      referencing another) and a top-level `params:` block substituted as
      `${name}`, overridable with the CLI's `--set` (ADR-015). **Cyclic-reference
      detection** (deferred from Phase 5) landed with both

### Phase 8 — CLI UX
- [x] Diagnostics rendered with file/line/col and carets, **for YAML syntax
      errors** — the library's `YamlError` carries `mark` and `info`, now
      destructured into a real `Span`; `Diagnostic::render_in` quotes the line
      and points a caret. Schema errors name the file and the construct
      (`cell 'A1'`, `column 'B'`) but no line: **decided out of scope**
      (ADR-016), since per-node provenance would mean vendoring or replacing the
      parser
- [x] `--check` (validate only), `--version`, help
- [x] Stable exit codes (0 / 1 / 2, documented in `yxl help` and the README, and
      exercised end-to-end); native binary build + install docs
- [x] **A Windows binary.** Releases ship Linux x86_64, macOS arm64, and
      Windows x86_64 (a `.zip` with `yxl.exe`), installed by `install.ps1`. The
      unknowns were settled by running them on `windows-latest` rather than
      reasoning about them: the native backend builds (gcc and clang are
      preinstalled — no MSVC setup), the artifact sits at the same relative path
      as everywhere else, and the whole suite passes. Path resolution now takes
      either separator. CI covers all three platforms.
- [x] **`yxl build` without `-o`, and without a spec (ADR-029).** A spec may
      name its own `output:` path, resolved against the spec and open to
      `${param}`; `-o` still wins, and with neither the workbook lands beside
      the spec as `<name>.xlsx`. A bare `yxl build` builds the one `*.yxl.yaml`
      in the current directory, and refuses when there are none or several.
      Kept in the spec, not a project file, so one file stays the whole story
- [x] **Publish to mooncakes, runnable with `moonx` (ADR-027).** Before the first
      `moon publish`:
      - narrow the public surface to `@cli.compile` / `@cli.extract`. First
        check against the current toolchain how a module hides a package.
      - a CI job builds `cmd/main` for `wasm` and checks that its output
        matches the native binary for `examples/`.
      - fill `repository` in `moon.mod`.
      - ~~adopt the next `mbtexcel` release, which carries the fixes for our
        upstream reports.~~ Done: `mbtexcel@0.3.0`.
      Then publish at a release tag, run the published package through
      `moonx`, and document it in both READMEs.
      **Done so far:** the first three. The compiler is under `src/internal/`,
      with the resolver types in `include` (ADR-028). CI's Linux leg builds
      `cmd/main` for `wasm`, runs it with `moonrun`, and `cmp`s each example
      against the native binary's output. `repository` is filled in, and the
      backend is `mbtexcel@0.3.0`. **Published as 0.6.0** and checked through
      the registry: `moonx t-ujiie-g/yxl/cmd/main@0.6.0` builds, checks and
      extracts, byte-identical to the native binary, with relative, parent and
      absolute paths; `moon add t-ujiie-g/yxl` with `@cli.compile` /
      `@cli.extract` works on native and wasm, and an `internal/` import is
      refused. Both READMEs document it. The steps as planned, at `v0.6.0`:
      `moon publish` from a clean checkout of the tag, then `moonx` against
      the published package, then both READMEs. `moon publish --dry-run`
      passes the server's checks for 0.6.0 (it exits non-zero after the
      server's `202 Accepted`, which is the CLI, not the package).

### Phase 9 — Richer Excel features (leverage mbtexcel, additive)
The items here are independent, so they land in whatever order pays best rather
than top to bottom, in **two slices**.

The **first slice is complete**. It deliberately began with everything that
needs *no new architecture* — no drawing parts, no binary assets, no reader
seam — which is why validation, filters, and links arrived before charts, and
why images (which forced a second resolver) came late.

The **second slice** is what used to sit here as a single unnamed line, "extras
as demand warrants". Every one of them is now named and scoped below, and all
six are wanted rather than optional.

#### First slice — landed

- [x] **Excel tables** (structured tables / ListObjects, `add_table`) —
      `tables:` over a range whose top row names the columns, with a name
      formulas can use, one of Excel's built-in styles, and its four appearance
      toggles. Everything Excel would repair or refuse is a diagnostic first: a
      duplicate or malformed name, a header cell that is empty or not text, two
      tables sharing a cell, and a table overlapping the sheet's own `filter:`.
      Deferred: a table with its header row turned off (the backend drops the
      range's first row for one, excelize parity, so a spec's `at` and the
      emitted range would disagree), totals rows, and per-column formulas
- [x] **Charts** — `charts:` anchored at a cell, with fourteen kinds (column /
      bar / line / area and their stacked forms, pie, doughnut, scatter, radar),
      one or more series whose ranges may name another sheet, a series name
      written out *or* read from a cell, a title, legend placement, a size in
      pixels, and per-axis title and bounds. Ranges are emitted the way Excel
      stores them, sheet-qualified and absolute. Deferred: 3-D variants, stock
      and bubble charts (each wants an extra range or a shape the spec cannot
      describe), combo charts, per-series colors and markers, and chart sheets
- [x] **Images** — `images:` anchored at a cell, with alt text, a scale, an
      offset in pixels, and Excel's three anchor kinds. The first thing in this
      phase that is not just schema: a spec names a file it cannot hold as text,
      so the loader gained a second resolver (`BytesResolver`) beside the one
      `$include` uses, and the CLI supplies both. Deferred: a hyperlink on an
      image, cell-embedded ("place in cell") images, and shapes
- [x] **Pivot tables** (`add_pivot_table`) — `pivots:` over a source region whose
      header row names the fields, laid out across `rows` / `columns` / `values`
      / `filters`, with all eleven of Excel's aggregations, a display name per
      field, one of its built-in styles, and either grand total. The file
      carries the definition and an empty cache marked "refresh on load", so
      Excel builds the summary on open. Two backend defects bound what was
      accepted for two releases — no `filters:` axis, and one source per
      workbook — and **`mbtexcel@0.1.10` closed both**
      ([office.mbt#264](https://github.com/moonbitlang/office.mbt/issues/264)):
      `<location>` is now derived from the axes a pivot uses, and each pivot's
      cache gets its own id. Both refusals are lifted. Deferred: number formats
      per value field, sorting and manual field order, calculated fields,
      classic layout, and a pivot sourced from an Excel table rather than a
      range
- [x] **Data validation** — drop-downs (inline choices or sourced from cells,
      including another sheet), and `whole` / `decimal` / `text_length` / `date`
      comparisons across all eight OOXML operators, with Excel's "Ignore blank",
      input prompt, and error dialog. A bound is typed (`Number` or a `DateTime`
      whose serial the emitter computes), never a bare string
- [x] **Hyperlinks** — out of the workbook (`url`) or into it (`to`), with a
      tooltip. A link decorates a cell; it never supplies its text
- [x] **Conditional formatting** — `cell` comparisons (the same eight a
      validation spells, shared code and shared vocabulary), `formula`, `text`,
      `top`/`bottom` by count or percentage, `duplicate`/`unique`, two- and
      three-stop color scales, data bars, and all twenty of Excel's icon sets.
      Looks are interned into Excel's *differential* format table, so a look ten
      rules share is stored once (ADR-004). Deferred, as demand warrants: time
      periods (`today`, `last week`), above/below average, blanks/errors, and
      per-stop control of where a scale or bar anchors its endpoints
- [x] **Comments (notes)** — the last of the range-keyed group. `comments:`
      takes the text on its own or `{ text, author }`
- [x] Auto filter (`set_auto_filter`) — `filter: <range>`. Per-column criteria
      are deferred: the backend takes them as its own little expression language
      (`x > 5`), and passing that through unvalidated would turn a typo into a
      backend `EmitError` instead of a diagnostic (ADR-006). They need a
      structured schema that compiles *to* those strings inside `emit`
- [x] **Sheet / workbook protection** — `protect:` at both levels, plus
      `protection: { locked, hidden }` in a style, without which protection
      cannot leave a form's input cells editable. Excel's own defaults apply,
      and a misspelt allowance is a diagnostic. The workbook half was refused
      from 2026-07-27, when the first Tier-3 check to reach it showed Excel
      reporting the file corrupt — the backend wrote `<workbookProtection>`
      out of CT_Workbook's element order — and **`mbtexcel@0.1.10` places it
      before `bookViews`**, so top-level `protect:` is accepted again, with an
      emit test pinning the order and the Open XML validator agreeing.
      **File encryption**
      (`write_with_password`) is *not* included: it changes the emitter's
      signature and needs the CLI to carry a secret, which wants its own
      decision — a protection password is only anti-accident, and the spec says
      so plainly
- [x] **Workbook metadata**: document properties (title, subject, author,
      keywords, description, category, status, language, version, company) and
      properties of the author's own devising under `custom:`, plus the
      calculation mode and a forced recalculation on open. A custom value keeps
      its YAML type, and a whole number reaches the file as an integer. Excel's
      *date* custom-property type is deliberately not exposed: it wants a full
      timestamp with a zone, which a bare date would have to guess at (ADR-006)
#### Second slice — the rest of what `mbtexcel` can draw

Ordered by what pays first: the two that reuse machinery already here, then the
two that write a part of their own, then the two with the largest vocabulary or
the most Excel verification to do. Each is independent of the others.

Every one of these writes a part Excel *interprets* rather than displays, so
each carries the obligation the pivot tables earned (§9): **open the output in
Excel by hand** before ticking the box, not only round-trip it.

- [x] **Shapes** (`add_shape`) — a geometry floating over the grid, optionally
      carrying text. `shapes:` anchored at a cell, with a named preset
      geometry, a pixel size, fill and line colour, plain text or a list of
      lines each in its own font (the backend writes one paragraph per run, so
      one font covers one line), alt text, and the three anchor kinds images
      spell. The backend puts `shape_type` straight into `prst=` unchecked, and
      for two releases it also **lowercased the token** — `ST_ShapeType` is
      case-sensitive, so `roundRect` reached the file as a geometry Excel does
      not recognize, and the twelve camel-case kinds were refused by name.
      **`mbtexcel@0.1.10` writes the token as given**
      ([office.mbt#293](https://github.com/moonbitlang/office.mbt/issues/293)),
      so the schema now names **35** presets — the rounded rectangle, right
      triangle, the six straight arrows and the four callouts among them — and
      the validity corpus builds one of each. Not available
      from the backend:
      an `offset` (its shape constructor takes none, unlike pictures) and a
      `scale` (dropped deliberately — a shape has no natural size to scale;
      `size` says it directly). `macro_name` stays out: `.xlsx` carries no
      macros, and VBA is a §2 non-goal
- [x] **Duration cells** — an elapsed time (`26:30:00`, "26 hours 30 minutes"),
      which Excel stores as a fraction of a day under an `[h]:mm:ss` format and
      is *not* a time of day. The smallest item here, and it landed as sized:
      a `type: duration` beside `type: date`, `H:MM[:SS]` parsed by a
      `units.Duration` (hours unbounded, minutes and seconds 0–59, no
      negatives — the 1900 system cannot display one) whose serial the loader
      computes, so the model carries an ordinary `Number` and the emitter
      never knew the feature arrived. `Workbook::set_cell_duration` exists but
      takes a `@time.Duration`; computing the serial ourselves keeps
      `moonbitlang/x/time` out of the model and matches how dates already
      work. The golden test proves the backend renders `[h]:mm:ss` past 24
      (`1.5` displays as `36:00:00`)
- [x] **Sheet backgrounds** (`set_sheet_background`) — a watermark image tiled
      behind a sheet's cells. Reused the `BytesResolver` seam and the format
      list images already brought, so it was `background: logo.png` on a sheet
      and little else, exactly as sized: one model struct, one loader function
      sharing the images' format/emptiness diagnostics, one emitter line. The
      backend validates the extension against the same thirteen formats and
      its reader hands the background back (`Worksheet::sheet_background`), so
      the round trip asserts bytes, extension, and content type. The docs say
      what surprises people: Excel shows a background on screen and never
      prints it
- [x] **Sparklines** (`add_sparkline`) — a chart inside one cell, for a row of
      figures beside it. Line, column, and win/loss; per group the cells
      plotted and where each lands (`at`/`data` for one, `cells:` for
      several), the high/low/every-point markers, manual bounds, line weight,
      and the colours those marks can show. Written as an `x14` extension, and
      the emitted extension list was verified against the part, not just
      round-tripped: the `{05C60535-…}` URI, the namespaces, win/loss spelled
      `stacked`, and the sheet-qualified `<xm:f>` are all as ECMA-376 Part 4
      §2.9 wants them. Two backend gaps bound the schema: the *first*, *last*,
      and *negative* markers are options nothing can set (refused by name with
      the reason — **re-confirmed against 0.2.0 by compiling the attempt**,
      §9), and `manualMin`/`manualMax` are written without
      `minAxisType`/`maxAxisType="custom"` — whether Excel honours them anyway
      is what the manual check watches
- [x] **Form controls** (`add_form_control`) — a button, check box, option
      button, scroll bar, spin button, group box, or label sitting over the
      grid, linked to a cell. The value a control writes into its `cell_link`
      is what makes a sheet a form, which is the same story `protection:
      { locked: false }` already tells. The largest vocabulary here, and the
      loader enforces it kind by kind: each key is admitted only where Excel's
      own "Format Control" dialog shows it, values keep to the dialog's
      0–30000, `page` is the scroll bar's alone, and a misplaced key is a
      diagnostic naming where it belongs. A `link` is a same-sheet cell — the
      backend refuses a qualified reference. Again no `macro_name`: a button
      without a macro is a caption that clicks, and assigning behavior is
      Excel's side of the contract. One reader note: a control's size lands in
      the VML anchor, which the backend does not translate back — the manual
      check is what sees it
- [x] **Slicers** (`add_slicer`) — the button panel that filters an Excel
      table, with a caption, size, and header toggle. Last on purpose, and
      landed as scoped: **table slicers only**. Pivot slicers were held until
      [office.mbt#264](https://github.com/moonbitlang/office.mbt/issues/264)
      was answered; it now is, so they are a schema decision rather than a
      blocked one — see "Not scheduled" below, and `docs/spec.md` §21. A slicer is entirely
      references, like a pivot: `table:` must name a *declared, named* table
      (found case-insensitively, workbook-wide — the panel may sit on a
      different sheet than its data) and `column:` one of that table's header
      cells, both proven by a whole-workbook check beside `check_tables`.
      The emitted parts were verified by hand: `xl/slicers/slicer1.xml`,
      `xl/slicerCaches/slicerCache1.xml` naming the table column, the
      `x14:slicerList` extension on the worksheet, the workbook-level
      `x15:slicerCaches` extension, the `Slicer_…` defined name, and both
      content-type overrides

#### Not scheduled

Sub-features the first slice consciously left out, gathered here so they are
visible rather than buried in the prose above. Each is a small addition to a
feature that already works, and any can be pulled forward on request — none is a
gate, which is why none carries a box:

- **per-column auto-filter criteria** — asked for by
  [#75](https://github.com/t-ujiie-g/yxl/issues/75), and **no longer blocked on
  the backend**: `mbtexcel@0.1.10` fixed the quoting
  ([office.mbt#477](https://github.com/moonbitlang/office.mbt/issues/477)) and
  `mbtexcel@0.3.0` added `set_auto_filter_values`, which takes a checkbox list
  of any length as data rather than as an expression
  ([office.mbt#478](https://github.com/moonbitlang/office.mbt/issues/478);
  three values with spaces in them round-trip, checked against `0.3.0`). What
  remains is ours: the schema. Its shape is settled: `filter:` grows `at:` and
  `columns:`, each
  column naming `values:` or reusing the eight comparisons `validations:` and
  `conditional:`'s `cell:` already spell, with dynamic, colour, and icon filters
  refused *by name*. **Hidden rows stay separate** — Excel does not re-apply a
  filter on open, so the rows it hid are a `rows:` fact, which the schema says
  and `extract` already recovers; deriving them from the criteria would mean
  evaluating the data (§2) and could not work for a formula cell anyway
- table totals rows, per-column formulas, and header-row-off tables — the
  backend drops the range's first row for the last of these
- chart 3-D variants, stock and bubble charts, combo charts, per-series colours
  and markers, and chart sheets
- conditional-format time periods (`today`, `last week`), above/below average,
  blanks/errors, and per-stop anchoring for colour scales and data bars
- pivot number formats per value field, sorting and manual field order,
  calculated fields, and classic layout
- a hyperlink on an image, and cell-embedded ("place in cell") images
- **pivot slicers** — the panel that filters a *pivot* rather than a table. Held
  until [office.mbt#264](https://github.com/moonbitlang/office.mbt/issues/264)
  was answered; it now is, so what remains is a schema decision — which axis a
  slicer may name, and how it refers to a pivot the spec identifies by `name:`
  or not at all. The backend's `SlicerOptions` takes the pivot's name where a
  table slicer takes the table's, so the emitter side is one branch
- ~~**form controls Excel actually draws.**~~ **Closed by `mbtexcel@0.3.0`**,
  which writes the worksheet's `<controls>` element and a `ctrlProps` part per
  control beside the legacy VML
  ([office.mbt#401](https://github.com/moonbitlang/office.mbt/issues/401));
  `controls.yxl.yaml` validates clean. One slip remains in that VML: its shape
  style is `position:absolute;73.5pt;…`, a value with no property name — the
  comment defect below, in the form-control writer (`form_control.mbt:460`).
  Excel positions a control from its anchor, so nothing shows. **Reported
  2026-10-14 as [office.mbt#649](https://github.com/moonbitlang/office.mbt/issues/649)**
- **a shape with no explicit colour is invisible**, for a neighbouring reason:
  the backend writes no `fillRef`/`lnRef` theme style reference, so a shape
  given neither `fill:` nor `line:` gets no fill and no outline rather than
  Excel's default blue. Documented in §18 and worked around in `examples/` by
  giving the bare shape a fill; the real fix is the backend writing the style
  block Excel's own "Insert Shape" writes
- file encryption (`write_with_password`) — its own decision: it changes the
  emitter's signature and needs the CLI to carry a secret

### Phase 10 — Performance & scale, and import
- [ ] Large-spec performance; streaming where `mbtexcel` supports it. **The
      baseline is measured and it is linear** (see the benchmark below): 400 and
      4 000 rows × 5 cells cost 6.35 ms and 64.1 ms, so there is no scaling
      cliff to chase and no case for streaming until a spec size appears that
      the flat cost cannot carry. What remains here is therefore *evidence
      first* — a real workbook that is too slow — not speculative optimisation
- [x] Benchmarks + regression guardrails in CI. **Benchmarks**:
      `src/cli/compile_bench_test.mbt` generates a spec of `bench_rows` × 5
      cells and times the whole pipeline plus each stage on its own
      (`moon bench src/cli --target native --release`). Baseline at 400 rows on
      an M-series laptop: 6.35 ms total = 2.54 parse + 1.26 load + 2.43 emit, so
      parse and emit are the two halves worth attacking and the loader is
      already cheap. CI runs this on Linux and puts the figures in the job
      summary, asserting nothing — a runner's absolute milliseconds are not
      comparable to a laptop's.
      **Guardrail**: `src/cli/scaling_test.mbt` is the part that gates. It
      sidesteps the tolerance-band problem by asserting a *ratio* measured
      within one run rather than a wall-clock number, so the machine's speed
      cancels and the same threshold serves all three runners: 16× the rows must
      cost at most 26× the time (linear measures 16). It rides along in
      `moon test`, costs ~0.8 s, and is **calibrated against an injected
      quadratic** rather than reasoned about — see the §11 entry for why the
      textbook figure (`factor²`) is the wrong number to threshold on
- [ ] **`yxl extract`: an existing `.xlsx` → a spec.** A **one-way, one-time
      migration aid**, not a round-trip contract (§8 Q5, Q9; ADR-017). The
      single biggest lowering of the barrier to adoption — an existing workbook
      becomes a starting spec instead of a retyping job. Shape decided in
      ADR-017: a reader seam mirroring ADR-002, the schema itself as the
      recovery boundary, and **verify-then-emit** so compression is checked
      rather than trusted. Delivered in slices, value-first:
      - [x] **A YAML writer.** `@yaml.write` is the seam's other direction:
            block style throughout, quoting only where the syntax forces it, and
            the caller's key order left alone. Its rules are pinned to what *our*
            parser does rather than to the YAML spec — it reads `yes` as text but
            `TRUE` as a boolean, `007` as the number 7, and a lone `-` not at all
            — and the round trip is asserted on the whole `examples/` corpus,
            which also re-compiles to byte-identical workbooks
      - [x] **The reader seam and slice 1**: values, formulas, rich text, styles
            interned into `defs.styles`, merges. `src/read` is the seam and
            `src/render` the inverse of `loader`; `yxl extract` joins them and
            checks its own output
      - [x] **Slice 2 — layout**: column widths, row heights, hidden, outline
            levels, freeze panes, gridlines, tab colour. (Print setup, since
            recovered with `mbtexcel@0.3.0`.) **Print setup is
            refused**, not deferred: the backend's `page_layout_with_defaults`
            aliases a module-level default instead of copying it, so reading one
            sheet's setup leaks it to every later sheet and to every later
            workbook read in the same process. Becomes a plain read the day that
            is a copy
      - [x] **Slice 3 — decoration**: comments, links, validations (including
            date bounds, which needed `@units.DateTime::from_serial` — the file
            holds a serial and the spec wants a written date), **conditional
            formats of every rule the schema names**, tables, and the auto
            filter's range
      - [x] **Slice 4 — the floating features**. Each *is* a translation as
            expected, but "typed record that maps onto the schema" turned out to
            be too generous a summary: probing the backend's actual read path —
            emit a workbook, reopen it, print what comes back — found **four
            fields the file carries and the backend's reader does not**, none of
            them visible from the `.mbti`. They are recorded under "blocked"
            below, and `extract` reports each rather than guessing:
            - [x] **Form controls** — every one of the seven kinds comes back,
                  with its caption, tick, linked cell, bounds, step, page, and
                  orientation. The `control_type` is *canonicalized* on the way
                  back (`check_box` out, `CheckBox` in), the same vocabulary
                  trap the conditional formats sprang in slice 3. The reader
                  also has to apply the schema's own per-kind rules — `checked`
                  belongs to a check box, `page` to a scroll bar — because a
                  file yxl did not write can carry them anywhere, and a spec
                  that carries one does not load
            - [x] **Slicers** — 1:1 as expected, with one thing the field list
                  did not show: placing a slicer makes the backend write a
                  workbook-wide defined name for its cache (`Slicer_Region`).
                  Recovering *that* alongside the slicer made the extracted spec
                  declare the name and then place something that declares it
                  again, which fails with `DefinedNameDuplicate`. The names come
                  off the recovered slicers' own `cache` field rather than off
                  the `Slicer_` spelling, so a numbered one is caught too
            - [x] **Sparklines** — kind, points, markers, high/low, manual
                  bounds, weight, and axis all come back, and a colour comes
                  back only where the file names one outright: an unset colour
                  is a theme reference, which is also what keeps four default
                  colours off every group
            - [x] **Shapes** — geometry, text, size, fill, outline, alt text,
                  and anchor. The `prst` caveat (§9) is confirmed from both
                  ends: `roundRect` reads back as `roundrect`, which names no
                  geometry in the table and cannot be told from any other, so
                  the shape is reported rather than guessed at
      - [x] **Slice 5a — charts.** Done, and the correction that split this item
            from the pivots below it held up: `at` and `size` come off the
            backend's typed fields and the rest is ten named paths out of a
            1–3 KB part. What the estimate left out is that there was **no XML
            reader in the tree at all** — every other thing `read` recovers
            arrives as a typed record — so the slice is a small one of those
            (`src/read/xml.mbt`) plus the mapping (`src/read/chart.mbt`) plus
            the inverse in `render`, which had no charts either.
            The reader is deliberately more than a scan, and for the reason this
            item was warned about: it **tracks nesting**, so the `<c:tx>` on a
            series' data labels is not read as the series' own name, and it
            **resolves namespace prefixes to URIs**, so a part that binds the
            chart namespace to something other than `c:` still reads. Both are
            the difference between a gap and a confident wrong answer.
            Everything the schema cannot say is refused whole and named: a
            combination chart, a grouping with no word (stacked line), a kind
            outside §12, a series holding its numbers rather than pointing at
            cells. Two smaller ones are named while the chart is kept: a title
            read from a cell, and a series name pointing at a range
      - [x] **Slice 5b — pivots.** Landed with the `mbtexcel@0.1.10` pin bump,
            exactly as this item planned: the release lifted the two loader
            refusals, and the reader was written once against the schema that
            resulted rather than twice. The estimate held — `src/read/pivot.mbt`
            is two parts read together, `location`/`worksheetSource` off
            attributes and one index lookup from `<field x>`/`<dataField fld>`
            into `<cacheFields>` for the names, with `src/render/pivot.mbt` the
            inverse. A byte-identical recompile test pins the round trip.
            Refused whole and named, the way slice 5a refuses a combination
            chart: a source that is not a region of cells (external, another
            pivot, an Excel table), a calculated field, an aggregation outside
            §14's list, a style outside the three families, and a source sheet
            the read itself skipped. Reported while the pivot is kept: a number
            format on a value field, Excel's compact layout, and field sorting —
            none of which the schema states.
            **The correlation was overstated too**, the same way the chart half
            was before anyone measured it. `table_xml` does name its fields by
            index into `cache_definition_xml`, but that is *one lookup*:
            `<field x="0"/>` and `<dataField fld="3"/>` index `<cacheFields>`,
            whose entries carry the name. Everything else is sitting in
            attributes — `source` is `<worksheetSource ref="A1:D7"
            sheet="Orders"/>` verbatim, `at` is `<location ref>`, the
            aggregation is `subtotal="sum"`, and `name`, `style` and the two
            grand-total flags are attributes of the definition. With the XML
            reader from 5a in the tree this is the same size of job as the chart
            half, or smaller.
            **What held it up** was that the loader refused the two shapes a
            real workbook's pivots most often take, and both refusals were
            workarounds for one unreleased upstream fix
            ([office.mbt#264](https://github.com/moonbitlang/office.mbt/issues/264)):
            `filters:` (page fields), and a second pivot over a *different*
            source. Building the reader then would have meant writing recovery
            for those two shapes as "reported, not recovered" and deleting that
            code weeks later — where every refusal slice 5a shipped is
            permanent, because the schema genuinely has no word for a
            combination chart. So it waited for the pin bump, and landed with it
      - [x] **CSV extraction**, which settled one-file vs. many: **many**.
            `Extracted` carries `companions` — the files the spec names, as
            bytes — and `cmd/main` writes them beside it, making the directory
            first. A sheet that is nothing but a rectangle of plain values
            becomes a `data:` entry and a CSV; anything with a style, a formula,
            a ragged edge, or too few rows stays inline, because a wrong "yes"
            silently drops formatting `data:` cannot carry where a wrong "no"
            only leaves a long block. `--flat` opts out.
            `$include` splitting is still *not* inferred: which files change
            together is the author's judgement and the workbook holds no
            evidence of it
      - [x] **Images and sheet backgrounds**, on that same companion-file
            machinery: the bytes come out unchanged and land under `assets/`,
            named for the sheet and cell they sit at, since the workbook keeps
            its pictures as numbered parts and carries no name to recover.
            A picture is written whatever `--flat` says, because it has no
            inline form to fall back to. **A picture's scale is not
            recoverable** and is reported: the file records an absolute size
            rather than a factor and the backend reports the factor back as `1`
            whatever it was, so a scaled picture cannot be told from an unscaled
            one
      - [x] **Fix: `extract` can write a conditional rule the loader refuses.**
            Found on the first run over a real workbook (§11, 2026-07-31): one
            rule came out as a range and a condition with no `style:` —
            plausibly a rule whose dxf held only what the schema cannot carry,
            such as the theme-indexed font colour the same run reported
            dropping — and the loader requires a `style` or `format` on every
            rule, so the extraction's own verify pass ended in "the extracted
            spec did not compile". The invariant is the one the verify pass
            exists to keep: render must emit only what the loader accepts. A
            rule left with nothing to apply is dropped and named as a loss,
            not written.
            **Fixed in the reader**, which now mirrors the loader's split from
            the other side: a highlighting rule recovered with no expressible
            style is dropped and named, and a style recovered on a rule that
            draws its own appearance is dropped and named while the rule is
            kept — that half only a foreign file can produce, since the
            backend writes `dxfId` on highlighting rules alone
      - [x] **Fix: a companion file's stem flattens every non-ASCII letter.**
            `file_stem` keeps `a-z0-9` and turns everything else into `_`, so
            a Japanese-named sheet's CSV lands as `data/_____.csv` — the same
            run produced `_____.csv` and `_______.csv`, distinguishable by
            underscore count alone. Collisions are already suffixed (`_2`), so
            nothing is overwritten, but nothing is legible either. Keep
            letters and digits in any script: the quoting concern that
            motivated the flattening is about spaces and punctuation, not
            scripts.
            **Fixed** exactly so: past ASCII only whitespace (U+3000 included)
            and control characters flatten, and the sheet's own name is the
            file's
      - [x] **Fix: a carriage return in cell text does not survive the round
            trip.** Found by the same workbook the moment the fix above let
            the verify pass reach its cell comparison: every multi-line cell
            rebuilt to a different value, because Excel stores Alt+Enter line
            breaks as CRLF. The YAML parser's escape table decodes `\r` as
            U+13 — decimal code points written as hex, which also bends `\v`,
            `\f`, `\e`, and `\ ` (§9) — so the CR our writer spelled `\r` came
            back as a different character. **Fixed in our writer**, the half
            that is ours (ADR-009): every control character but `\n` and `\t`
            goes out as `\u00XX`, the form the parser does read right
      - [x] **Fix: the field report, issues #45–#52.** Eight defects raised
            against v0.3.1 from converting a 32-sheet production workbook.
            Five were ours and are fixed; two were the backend's and the
            `mbtexcel@0.1.9` upgrade fixed them; one is still the backend's and
            is under "blocked" below. In the order they bite:
            - **#47, empty container elements** (`<definedNames/>`,
              `<numFmts count="0"/>`) failed the whole read, and openpyxl writes
              both — so *no* openpyxl-produced workbook could be extracted at
              all. Fixed upstream in `mbtexcel@0.1.9`, whose tag matcher handles
              a self-closing element
            - **#52, shared-formula followers.** Was on this list as blocked;
              `0.1.9` publishes `Cell.formula_shared_index`,
              `Worksheet::shared_formula_master` and
              `SharedFormulaMaster::translate_to`, so it is a read now. A master
              whose whole range follows it and wears no cell style folds back
              into the one `formulas:` entry it came from, which is the sharing
              ADR-004 asks for; where a cell in the range carries a style —
              which a `formulas:` range cannot — each follower comes back as its
              own translated `formula:` instead. Either way the calculation
              survives, where before it silently became whatever stale number
              was cached beside it
            - **#48, style-only cells.** A cell with a fill, a border or a
              format and no value vanished without even a `dropped:` line — 600k
              of them in the reported workbook, which is every merged heading's
              blank half and every ruled table edge. Both halves are fixed: the
              schema gains the style-only cell (`B3: { style: shaded }`,
              `model.Blank`), and `read` writes one. The backend has no way to
              store a *truly* blank styled cell — it writes an empty string, so
              `ISBLANK` is `FALSE` there — which `docs/spec.md` §3 says out loud
              and the "blocked" list carries
            - **#50, theme and palette colours** were reported as a loss on
              every font and fill that used one, which in a real workbook is
              most of them. `0.1.9` publishes `Workbook.theme_colors` and
              `theme_color(base, tint)`, so the theme in the file being read now
              resolves to the RGB the spec carries, tint included. Borders are
              still lost: the backend's border reader takes `rgb` alone and has
              no field for a theme index — under "blocked"
            - **#49, built-in number formats 37–40.** Ours: the table in
              `read/style.mbt` was copied from ECMA-376 §18.8.30, which prints a
              space where Office writes `_)` (MS-OI29500 §2.1.601). Excel maps a
              cell formatted `#,##0_);(#,##0)` onto id 37 and writes no `numFmt`
              element, so an extracted spec came back with a *different* format
              — the accounting alignment the `_)` exists for, gone
            - **#45, `\r` in a cell value.** The parser library decodes it as
              U+13, and 0x13 is not a character XML 1.0 can carry, so v0.3.1
              wrote a workbook Excel offered to repair. Fixed at our parse seam,
              which is where a defect of the chosen parser gets corrected
              (ADR-009): U+11, U+12 and U+13 in a parsed scalar can only have
              come from `\v`, `\f` or `\r`, since YAML forbids an unescaped C0
              control in a source document, so mapping them back is exact.
              `\e` and `\ ` are not repairable and stay in §9 — they land on `'`
              and `2`, which a scalar may perfectly well contain
            - **#46, an uncompressed package.** Every ZIP entry was STORED, so a
              13 MB workbook rebuilt to 132 MB. `emit` now hands the backend a
              `set_zip_writer` that deflates each part. Costs about as much CPU
              again as the rest of the compile — measured at ~14 MB/s of XML —
              which is the right trade against a tenfold file
      - [x] **Fix: a failed read said which case it raised, not what was
            wrong.** Asked for twice over in the field report (#55, #56) and
            true of every `yxl extract` failure since the command shipped:
            the whole message was
            `t-ujiie-g/yxl/diag.ReadError.ReadError`, so finding out *what* in
            a workbook was unreadable meant swapping parts out one at a time
            and bisecting cells. Two independent causes, both ours:
            `cli.error_message` knew four of the six subdomain errors —
            `ReadError` and `ResolveError` fell through to `Show` on the error
            itself, which renders the type's name — and the reader seam then
            built its message with that same `Show`, which on the backend's
            error gives the constructor and drops the payload. `XlsxError`
            derives `Debug` and every case keeps the useful half in a `msg~`,
            a `path~` or an `index~`, so the seam renders that instead. The
            same message now reads
            `Excel backend: InvalidXml(msg="MCE AlternateContent has no Choice
            element")`, which is what made the rest of this triage possible at
            all. `emit` had the identical defect and got the identical fix
      - [ ] **Blocked, not deferred** — these are what `extract` cannot do
            anything about on its own. All but the last two were found by
            probing the backend's read path rather than by reading its
            signatures, which is the lesson worth keeping: a typed field says
            what a value *would* be, not whether anything fills it in:
            - **A form control's size**, which the file records in the far
              corner of the control's VML anchor and
              `parse_form_controls_vml` never reads — it takes the anchor's
              first two coordinates and stops. (The shape's
              `style="width:…;height:…"` looks like the place to read it and is
              not: the backend stamps one constant string on every control.)
              Every control comes back at Excel's default size
            - ~~**The font on a line of shape text**~~ — **unblocked in
              `mbtexcel@0.3.0`**, whose drawing reader fills each run's
              font. Read, and done (2026-10-14)
            - **The sheet a sparkline plots from**: the file writes
              `'Data'!B2:E2` and `normalize_sparkline_range_ref` keeps only what
              follows the `!`. This is the worst of the four, because it is not
              a gap but a *plausible wrong answer* — a sparkline that plotted
              another sheet comes back plotting this one, and the verify pass
              compares cells, so nothing catches it. Reported whenever a
              workbook has more than one sheet; with one sheet no qualifier
              could have named anything else
            - ~~**A shape geometry whose DrawingML token carries a capital**~~
              — fixed with the lowercasing (`mbtexcel@0.1.10`); `roundRect`
              round-trips and `read/floating_test.mbt` pins it. This line had
              outlived it
            - ~~**Print setup**~~ — **unblocked in `mbtexcel@0.3.0`**, whose
              `page_layout_with_defaults` copies its default instead of
              aliasing it. Read off `Worksheet`'s own fields regardless, plus
              the sheet-scoped `_xlnm.Print_Area`; what §5 cannot say (paper
              size, first page number, centring, …) is named. Done
              (2026-10-14)
            - ~~**Shared-formula followers**~~ — **unblocked in
              `mbtexcel@0.1.9`**, which publishes `Cell.formula_shared_index`,
              `Worksheet::shared_formula_master` and
              `SharedFormulaMaster::translate_to`. Read, and done (#52 above)
            - **A column's `customWidth` flag** (#51). `<col width="9.14"
              customWidth="0"/>` is a column at its *default* width — the width
              attribute is informational (ECMA-376 §18.3.1.13) — and `parse_cols`
              never looks at the flag, so `ColDimension` has no field for it and
              `extract` writes the width as if it had been set. Rebuilding turns
              a default column into an explicit one, which stops Excel
              auto-fitting it. The heuristic that suggests itself — treat a width
              equal to the sheet's default as not custom — is refused: with no
              `defaultColWidth` in the file it depends on reconstructing Excel's
              implicit default from `baseColWidth` and the workbook font, and
              getting that wrong drops a width an author did set. Becomes a
              one-line read the day `ColDimension` carries the flag
            - **A theme or palette colour on a *border*** (#50). Fonts and fills
              resolve now; the backend's border reader takes the `rgb` attribute
              alone, and `Border` has no `color_theme` / `color_indexed` field,
              so a theme-coloured edge arrives already colourless. Not even
              reportable: a border with no colour is also what an
              Excel-default-coloured border looks like
            - **A truly blank styled cell** (#48). `set_cell_style` on a cell
              that holds nothing creates it as an empty *string*, and the writer
              has no branch that emits `<c s="…"/>` with no value, so a
              style-only cell is written as `t="s"` pointing at `""`. It draws
              correctly and `ISBLANK` disagrees. `emit_cell`'s `Blank => ()` is
              the one line that changes the day the backend can express it

### Phase 11 — Authoring ergonomics (added after the v0.1.0 review, §11)
These sharpen what §1 already claims, so they land **before the schema freeze**;
the first item changes what a spec looks like.
- [x] **Diff-stable tables.** A `cells:` mapping is keyed by A1 address, so
      inserting a row rewrites every key below it — `git diff` reports the whole
      block as changed, which undercuts the "diffable" headline. Fixed by letting
      a `data:` entry carry its rows **inline** rather than only from a file:
      `- { at: A2, values: [[APAC, 2400000], [EMEA, 1750000]] }`. The anchor
      localizes addresses to one place, and it reuses the anchored-table
      machinery `csv:`/`json:` already go through instead of inventing a second
      concept. `cells:` stays for scattered, individually-styled cells, which is
      what it is good at.
      **Shipped** as a third `data:` source (`docs/spec.md` §9, renamed from
      "External data" since a table's rows need not be external any more). The
      one-line-diff claim is measured, not assumed: inserting a region into a
      three-row block is `+6/-4` written as `cells:` and `+1/-0` written as
      `values:`. Inline fields also keep the types YAML gave them, so `"007"`
      stays text with no inference step — a `csv:` field cannot manage that,
      since CSV carries no types and every field has to be guessed at.
      Follow-on now unlocked, deliberately not taken here: **`yxl extract
      --flat` should emit `values:` rather than a `cells:` block** — the flat
      form exists to give one file and, until this landed, inline had no good
      shape to take. That is a change to `extract` with its own tests, not part
      of this schema addition.
- [x] **Filled formula columns.** A formula translated down a column — `E2` is
      `C2*D2`, `E3` is `C3*D3` — is the commonest structure in a real workbook
      and the schema cannot say it. `defs.formulas` does not: a `$ref` compiles
      to a *defined name*, so every referencing cell gets the **same** formula,
      not a translated one. So a 500-row calculation is 499 near-identical
      `cells:` lines, and inserting a row rewrites all of them — the same
      diff-stability complaint as the item above, which is why the two are
      neighbours and may share one mechanism.
      Excel has the concept natively (`<f t="shared" ref="E2:E500" si="…">`) and
      the backend both reads it (`Worksheet::formula_refs`) and writes it
      (`FormulaOpts::shared`), so the gap is ours alone: **reuse the spec
      declared once is supposed to compile to native sharing (ADR-004), and here
      the spec cannot declare it once.** Take the `at:`-anchored house shape the
      other sheet keys use:
      `formulas: [{ at: E2:E500, formula: "C2*D2" }]`.
      **Shipped** as `formulas: [{ at, formula }]` (`docs/spec.md` §3), compiling
      to a shared formula so a 500-row column stores one. Pulled ahead of
      `extract`, whose slice 1 would otherwise emit 499 lines a reader would
      delete; wanted independently, since an author writing by hand hit it too.
- [x] **Agent Skills for the extract-then-rewrite workflow.** The intended use
      of `yxl extract` on a living workbook is now measured rather than assumed
      (§11, 2026-07-31): the output is a *starting point*, and the manageable
      form is a hand-written spec — report sheets rewritten with `formulas:`
      ranges, bands, and `defs.styles`; data sheets moved to `data:` + CSV that
      the source system refreshes monthly. That rewrite is judgement-shaped
      work an AI agent does well with a checklist in hand, so ship the
      checklist as **Agent Skills**: the SKILL.md format Claude Code, Codex,
      Cursor, and OpenCode all read — the same tool-agnostic stance as
      AGENTS.md. A `skills/` directory in this repo, one skill per task,
      starting with *extract-to-spec* (run `extract`, split report sheets from
      data sheets, rewrite formula grids as `formulas:` ranges, reuse the
      interned styles, carry the reported losses into the spec as decisions).
      Distribution is solved off the shelf: any GitHub repo holding SKILL.md
      files installs with `npx skills add <owner>/yxl`
      ([vercel-labs/skills](https://github.com/vercel-labs/skills) recognizes
      the `skills/` layout), and a `.claude-plugin/marketplace.json` can make
      Claude Code's `/plugin` flow work from the same files; there is no
      `gh skill` to wait for.
      **Shipped as two skills**, after review asked for more than the
      migration: `skills/yxl-authoring/SKILL.md` is the general workflow —
      writing a spec from scratch, editing an existing one without forking its
      `defs:`, and operating it month to month (data swap, `params:`, the
      verify loop) — and `skills/extract-to-spec/SKILL.md` is the migration:
      read the loss report as a decision list, classify report against data
      sheets, restore `formulas:` ranges over the cached values extract cannot
      re-share, rename interned styles, move pasted rectangles to `data:` +
      CSV, then compile / re-extract / open in Excel; it hands over to
      yxl-authoring once verified. `.claude-plugin/marketplace.json` +
      `plugin.json` (mirroring moonbitlang/skills' manifests) serve the same
      files to Claude Code's `/plugin` flow, and the README section covers
      both install routes
- [x] **The skills carry a default architecture, and the cookbook proves it.**
      The two skills above taught the *mechanisms* — `defs:`, `$include`,
      `data:`, `formulas:` — and left the shape of a real project to taste, so
      an agent asked for "a workbook" produced one correct file per request and
      no two alike. What a maintained workbook needs is a *default*: an entry
      spec that is a table of contents, one file per sheet, styles named by
      role in one place, every rectangle of rows as `data:` (a real one
      declared a `tables:`), per-issue values in `params:` — including the file
      names data is read from — and, the item users actually ask for, **one
      master per shared list**: the store names live on one sheet, fed by one
      CSV, declared a table, and every other sheet reaches them by name
      (`INDEX(StoreMaster[store_name], …)`, a drop-down sourced `from:` its
      cells) instead of holding a copy. `yxl-authoring` now opens with that
      architecture and the rule around it — build it absent instruction, follow
      the user's shape when there is one, never restructure an existing spec as
      a side effect — and `extract-to-spec` rewrites *towards* it, with a step
      that hunts the copied lists a hand-maintained workbook accumulates.
      **`examples/workbook.yxl.yaml` is the layout entire**, so the advice is
      compiled by CI rather than asserted in prose, and `--set month=2026-08`
      is a promise in `src/examples` — the refresh claim, measured. Two
      `docs/spec.md` clarifications came out of building it: a validation's
      `from:` takes a plain range (a hand-written `$` is refused), and a `null`
      field writes no cell, so a column of them reserves the gap for a
      `formulas:` range.
- [x] **A layer for the cells that are exceptions (`overrides:`).** Raised from
      downstream (#66) and taken now because it adds a top-level key, which
      after the freeze is a breaking change to the "no unknown keys" contract
      (ADR-006) rather than an addition. The schema could already express the
      three cases — a `${quarter}` value one cell must deviate from, a refreshed
      CSV, a row of a `formulas:` range — by putting a `cells:` entry after the
      block it overrides, and that is the complaint: the entry is then
      indistinguishable from a cell the author meant, so nobody can count the
      hand-patched cells, say *why* one was patched, or find the five that share
      a shape and should have been a rule. It also made correctness depend on
      YAML key order, which any formatter may change.
      **Shipped** as a top-level `overrides:` (`docs/spec.md` §23, ADR-018):
      `at:` one sheet-qualified cell, the `cells:` grammar, and a `reason:`,
      applied after everything that writes a cell and before everything that
      reads one. Per-facet, so a new value keeps the cell's formatting; refused
      when it overrides nothing, when it names a range, when a cell has two, and
      when it lands on a formula range's anchor — where the shared formula is
      stored. Inside a range anywhere else it takes one cell out of the group
      and leaves the other 499 sharing one stored formula, which is the case
      that had no answer before. `tests/validity/override-facets.yxl.yaml` puts every
      facet through the Open XML validator.
- [x] **A JSON Schema for the spec, generated from `docs/spec.md`'s contents.**
      Publishing one lets an author write
      `# yaml-language-server: $schema=…` and get completion and validation in
      VS Code — a large ergonomic return for a small artifact. Generating it
      *from* the reference (or checking the two agree in CI) is what stops the
      pair drifting, which is the same trick `examples/` plays for the cookbook.
      **Shipped** as `docs/yxl.schema.json`, built by
      `tools/spec-schema/generate.py` (ADR-019, `docs/spec.md` §24). The
      reference supplies the *vocabulary* — every key, every enumeration, and
      the sentence an editor shows on hover, read out of its own tables, example
      blocks, and lists — and the generator supplies the *shapes* prose cannot
      state. Neither half can move alone: a key documented but unshaped, or
      shaped but undocumented, fails the generator by name, and CI fails when
      the committed JSON is not what the reference now says. A schema that
      decayed to `{}` would still pass every corpus, so `--selftest` pins
      fourteen mistakes it must refuse alongside the twenty-one specs of
      `examples/` and `tests/validity/` it must accept.
- [x] **A third state for a style attribute (`null`), and the `extract` loss it
      was hiding.** Raised from downstream (#71) and taken before the freeze for
      the same reason as `overrides:`: afterwards, teaching an error case to mean
      something is a behaviour change on specs that fail to build today rather
      than an addition. A spec could say an attribute *is* something and could
      leave it out, but leaving it out is inheritance — from `extends:`, from a
      column band, from a row band — so the cell Excel draws unfilled inside a
      filled column could not be written. Booleans escaped it (`bold: false` is a
      value); a fill, a colour, a format, an alignment, and a border edge did
      not.
      **Shipped** as `null` at every style attribute, at a whole group, and at
      the `format:` shorthand (`docs/spec.md` §6, ADR-020). The model carries the
      third state as the set of attributes a style clears, layering takes it away
      from what it is laid over, and the emitter drops the set once layering is
      done so one look still interns as one `cellXfs` id.
      The reader half is what closes #71: a `<c>` takes its whole look from its
      own style (ECMA-376 §18.3.1.13), so a written cell missing something its
      band sets now comes back with the `null` that says so — the round trip that
      silently refilled a deliberately unfilled cell, and could not report it
      because the loss happened in the reader, before the self-check's baseline.
      `tests/validity/cells.yxl.yaml` puts every spelling through the Open XML
      validator, and `examples/styling.yxl.yaml` shows the two an author reaches
      for.
- [x] **A first spec to start from (`yxl init`).** Raised from outside (#77):
      `build` and `extract` both take a spec that already exists, so the only
      way in was to know the format well enough to type it — the one thing a
      person starting out cannot do. `yxl init -o sheet.yxl.yaml` writes one.
      **Shipped** writing an *empty* sheet rather than a worked example: a
      starter carrying a heading row, sample rows and a `SUM` reads as helpful
      and is not, because the first thing the reader does is delete it. `-o` is
      required, matching `extract`; an existing file is refused unless `--force`
      says otherwise. The file names the path it was actually written to in its
      own build line, so its one instruction is a command that works as it
      stands. It belongs in the compiler rather than in an editor because a
      starter spec is a statement about the format, and the format is ours
      (ADR-011): kept anywhere else it is a second copy that drifts, and it is
      out of reach for anyone using the CLI without that editor.
- [ ] **Position-free columns (`layouts:`) and reusable column groups
      (`defs.blocks`).** Raised as an RFC (#89). A report that repeats one
      *meaning* across items — `当年 | 前年 | 前年比` for 売上, 粗利, 営業利益 —
      is written today as a band, a `formulas:` range and a `conditional:` rule
      per column, each addressed by letter, so **inserting one column rewrites
      every `at:` and every formula to its right**: the column-axis twin of the
      row problem `values:` solved above. Taken before the freeze because it
      adds a sheet key and a `defs:` namespace (the ADR-018 argument). The
      shape is ADR-023, which keeps the RFC's core and changes its surface;
      four slices, each shippable alone:
      - [x] **Slice 1 — `layouts:`.** A sheet key holding a list of anchored
            regions (`at: A1`, `rows: N`), each a sequence of named columns
            carrying their own `header`, `width`, `style`, `format`, `formula`
            and `conditional`. `{{name}}` inside a column's formula means *that
            column's cell in the same row*. The header depth follows from the
            columns, the body starts beneath it. Output equals the hand-written
            bands, cells, `formulas:` ranges and `conditional:` rules, byte for
            byte — the test is exactly that equivalence.
            **Shipped** (`docs/spec.md` §25, `examples/columns.yxl.yaml`). The
            loader's `layouts` arm goes through the conditional, formula-range
            and band loaders, so the rules those keys enforce hold here too.
            It makes the RFC's promised test two tests: loaded models equal in
            `src/loader`, and compiled bytes equal in `src/cli`.
            Decided while building it:
            - Column names are unique across the **sheet**, not only across one
              layout. That is the stricter rule, and it can be relaxed. Slice 4
              needs it to name a column from outside a layout.
            - ~~A header is a scalar, and `header_style` styles all of them.~~
              Superseded the same day, once the example was opened in Excel:
              a report's header is usually two rows with merged group labels,
              so a one-row header left layouts fit for almost nothing. A header
              is now a cell or a list of them, one per header row. A shared
              parent merges across its columns, a short column merges down,
              and `null` is a blank that merges with nothing. That last rule
              is for a header that wants blanks rather than a merge. The
              expanded cell form lays its own style over `header_style`.
              Omitting every `header` still leaves the header rows to
              `cells:`, for a header too irregular to describe.
            - A `header_style` with no header, and two layouts banding the same
              column, are both refused.
      - [x] **Slice 2 — rows from data.** `values:` / `csv:` / `json:` on a
            layout fill the columns *without* a `formula:`, in declared order,
            and `rows:` defaults to their count. Without it slice 1 keeps a
            positional coupling: inserting a derived column between two input
            columns still means rewriting every data row.
            **Shipped** (`docs/spec.md` §25). The open question below was
            decided by name. A CSV read by a layout must start with a header
            row, and each input column takes the field named like its `field:`
            (which defaults to its `name`). A JSON array of objects is matched
            the same way, by key. `values:` and JSON arrays of arrays stay
            positional, since they have no names to match. So a source system
            that reorders its export, or adds a field, breaks nothing. That is
            the same guarantee layouts give the spec's own columns. It also
            decided that `rows:`, given with a source, may reserve a body
            longer than the data, with the formulas filled beyond it, but never
            a shorter one. `field:` is refused wherever nothing would read it.
      - [x] **Slice 2b — footer rows and grouped totals (ADR-024).** Rows
            placed straight after the body, which follows the data's length.
            A row cell may be `{ total: sum }` over its column's body. A
            `by:` group repeats its rows once per value of a column, nested to
            any depth, and its totals become `SUMIFS`-family formulas over the
            enclosing groups' values. `order:` is `data`, `asc`, `desc`, or an
            explicit list. A label spells the group values as `{{branch}}`.
            Taken before blocks: the case comes from a real workbook, one that
            lists subtotals per 支店, and per 直営/FC within each 支店, beneath
            its rows. Blocks will want a per-block total too.
            **Shipped** (`docs/spec.md` §25, `examples/subtotals.yxl.yaml`) in
            `src/loader/footer.mbt`, which sits beside `layout.mbt` because the
            grouping is a pass of its own. The example reproduces that
            workbook's shape: 支店 in a fixed order, 直営/FC within each, a grand
            total, each with its own row style.
      - [x] **Slice 3 — `defs.blocks`.** A named group of columns, instanced as
            `{ block: yoy, as: sales, header: 売上 }`; the instance's `header`
            becomes the top level of each of its columns' headers, so the
            level rules above merge it over them. `{{cy}}` inside a block resolves in the
            instance, then in the layout; `{{sales.cy}}` reaches another
            instance. One level of nesting.
            **Shipped** (`docs/spec.md` §25, `src/loader/layout_block.mbt`).
            An instance expands in the loader into the layout columns it
            stands for, so everything below the column list is unchanged. A
            test asserts that two instances and their six columns written out
            load to equal models. Decided while building it:
            - `as` defaults to the block's name, so a block placed once needs
              none. Two instances with one name are refused, as is a column
              named like an instance.
            - `fields:` on an instance maps its input columns to CSV or JSON
              fields. Without it a column reads the field named like its
              qualified name (`sales.cy`), which a real export rarely has.
            - A block defines `columns` and nothing else. An instance takes no
              band keys of its own: a width that differs per instance is two
              blocks, or an override.
            - A footer names an instance's column by its qualified name, and
              `{{sales.cy}}` works there too.
      - [x] **Slice 4 — names from outside a layout.** `overrides:`, a totals
            row, a chart series, a validation. The syntax is decided in that
            slice under two constraints from ADR-023: it cannot be read as an
            A1 reference, and it has one meaning (not `{{…}}`, which already
            means "same-row cell").
            **Shipped** (ADR-025, `docs/spec.md` §25). No new syntax was
            needed. A layout's `name:` turns each column's body into a
            workbook defined name, `店舗.cy`, which any formula uses as Excel
            itself would. An override finds its cell by meaning, with
            `{ layout, column, where: { store: 渋谷 } }` matching exactly one
            data row. The totals row came earlier, as the footer (ADR-024).
      - [x] **Slice 4b — a layout's name where a range is read at load time.**
            A chart series, a validation's `from:` list, a sparkline, and
            content placed *below* a layout whose length follows its data.
            Each needs the range while the spec loads, possibly before the
            sheet holding the layout, so this needs every layout's position
            settled in a pass ahead of the sheets.
            **Shipped** (ADR-026, `docs/spec.md` §25).
            - Every range a spec reads, through `parse_sheet_range`, may name
              a layout: chart series, validation lists, sparkline data, pivot
              sources. `店舗` alone is the table, header row included, and is
              emitted as a defined name too.
            - On the layout's own sheet, a table's, a validation's and a
              conditional format's `at:` may name it, so `tables: [{ at: 店舗 }]`
              declares a layout an Excel table.
            - Every cell anchor, through `anchor_cell`, may be
              `{ below: layout, gap: n }`. That covers charts, images, shapes,
              controls, slicers, `data:` and layouts stacked under layouts.

      Open, to settle inside the slices: whether `yxl extract` should
      *propose* a block when it finds a repeated column group, which is a guess
      and so ADR-017 territory; how yxl-vscode maps "insert a column" or "edit
      one instance's cell" back — change the block, or add an override; and a
      layout also declared an Excel table when its header is one row of unique
      names. Row-axis groups and emitting `LAMBDA` are not in scope.


### v1.0 — Stability gate
- [ ] Schema freeze (breaking budget spent here). The **spec reference** it
      freezes is written: [`docs/spec.md`](./docs/spec.md)
- [x] Stand up Tier 2: an `examples/` corpus that CI compiles and asserts on
      (§5), which is also what stops the README and cookbook drifting
- [x] **The cheap half of Tier 3, automated.** A workbook that makes Excel show
      its repair dialog is the classic way an xlsx writer fails, and nothing in
      CI caught it. The judge is the **Open XML SDK's own validator**
      (`tools/openxml-validator`, driven by `.github/scripts/validate-xlsx.sh`)
      rather than the LibreOffice-headless or `openpyxl` pass this item first
      proposed: those two are *lenient readers*, and a reader that quietly
      repairs what it is given cannot report that it had to. The SDK validator
      checks every part against the ECMA-376 schema and answers with the
      element, the attribute, and the XPath. It found two defects on its first
      run, which is the argument for it — see the §11 entry
- [x] **A validity corpus covering every schema variant, not every lesson.**
      The automated half above judged only the files `examples/` happens to
      produce, and the cookbook is written for a reader: one icon set of twenty,
      four chart types of fourteen, two control kinds of seven. Three icon sets
      were writing an invalid workbook the whole time and nothing said so (§9).
      Closed by `tests/validity/` — eight specs, breadth-first, built by CI with
      the shipped binary and put through the same `validate-xlsx.sh`. The script
      now also refuses a run whose inputs share a basename, since that is what a
      waiver is keyed by and two corpora make collisions possible
- [ ] Tier-3 manual: Excel / LibreOffice / Google Sheets open cleanly *and show
      the right thing*. The half above cannot be automated away: a schema-valid
      workbook can still put the pivot in the wrong place
- [ ] Cookbook + CLI docs complete — `examples/` and `docs/spec.md` exist and CI
      keeps them honest; what remains is filling them out as Phase 9 lands
- [ ] Release policy: v1.0.0 ships when the schema + CLI are stable

---

## 7. Architecture Decision Records (ADRs)

ADRs are append-only. When a decision changes, add a new ADR and mark the old
one **Superseded** — never rewrite an accepted ADR.

### ADR-001 — Build on `bobzhang/mbtexcel` as the Excel backend
**Status:** Accepted; the module's coordinates superseded by ADR-021.
**Context:** Two credible bases exist: the official, comprehensive
`bobzhang/mbtexcel` (a Go-excelize port: read/write, styles, charts, images,
pivots, formulas, data validation) and `t-ujiie-g/moon-xlsx` (a leaner, pure,
type-safe from-scratch writer).
**Decision:** Depend on `bobzhang/mbtexcel`. A test drive confirmed it is
consumable as a library (`@bobzhang/mbtexcel/xlsx`: `Workbook::new`,
`new_sheet`, `set_cell_*`, `new_style(Style) -> Int`, `write(Workbook) ->
Bytes`), **builds and runs on native**, and produces valid files (verified with
openpyxl). Its breadth and maturity outweigh reimplementing a writer.
**Trade-offs:** heavier transitive deps (`moonbitlang/async` incl. `async/fs`,
`x/time`, `crypto`, `zip`); the package is native-oriented (`preferred_target =
native`), so wasm/js portability is not guaranteed; young semver (0.1.x) implies
API churn — **pin the version**. These are mitigated by ADR-002 (seam).

### ADR-002 — Emitter seam; the backend is swappable
**Status:** Accepted.
**Decision:** `model → bytes` goes through a single emitter interface. The
`mbtexcel` implementation lives behind it. This isolates backend API churn,
keeps the pipeline testable without the backend, and preserves the option to add
a lighter/portable backend later (e.g. `moon-xlsx` for a wasm CLI — §8 Q6).

### ADR-003 — Native CLI; core is I/O-free
**Status:** Accepted.
**Decision:** `preferred_target = native`; the CLI (`cmd/main`) is the only
place that touches the filesystem. `model` / `loader` / `resolve` / `emit`
operate on in-memory strings and bytes so they unit-test without disk I/O.

### ADR-004 — Reuse compiles to native Excel sharing
**Status:** Accepted.
**Decision:** A named-once, used-many value/formula/style compiles to Excel's
own sharing mechanisms — shared strings, defined names, shared formulas, a
single `cellXfs` id — not to duplicated cells. This makes the output both small
and genuinely single-managed.

### ADR-005 — Flexible spec layout (inline or split)
**Status:** Accepted.
**Decision:** A spec may inline data and formatting, or split them across files
via includes/references; both resolve to the same `model` before emission.

### ADR-006 — Fail-fast diagnostics
**Status:** Accepted.
**Decision:** Validate strictly. Unknown keys, dangling references, and type
mismatches are diagnostics with source spans, never silently dropped or guessed.

### ADR-007 — Track the latest MoonBit language spec
**Status:** Accepted.
**Context:** The toolchain evolves quickly; stale idioms accrue debt.
**Decision:** Prefer current idioms; migrate off deprecated syntax proactively
(AGENTS.md §8.8). Deprecated blocks live in `deprecated.mbt`.

### ADR-008 — `src/` sub-package layout
**Status:** Accepted; refined by ADR-028 (compiler packages under `internal/`).
**Decision:** Restructure the flat `moon new` scaffold into the `src/` package
map of §4 in Phase 1, so `moon.mod` uses `source = "src"` and packages have
clear, acyclic boundaries.

### ADR-009 — YAML parser: depend on `moonbit-community/yaml`, behind our seam
**Status:** Accepted. (Resolves §8 Q1.)
**Context:** Four candidates exist in the registry: `moonbit-community/yaml`
(pure MoonBit), `myfreess/yaml` (no description/repo, one release),
`ingydotnet/yamlscript` & `yamlstar` (official YAML-org loaders), and
`tonyfettes/tree_sitter_yaml` (a tree-sitter grammar). We need source positions
for diagnostics (ADR-006) and a self-contained native binary (ADR-003).
**Decision:** Depend on **`bobzhang`-style pure library `moonbit-community/yaml`**
(pin `@0.0.6`), consumed **behind the `yaml` seam package** (§4). It follows the
proven libyaml/PyYAML event architecture (`Parser` → `Event` stream with a
`MarkedEventReceiver`), exposes a `Marker { index, line, col }` so we can attach
file/line/col to every `YamlError` (ADR-006), already handles anchors/aliases
(useful for the reuse model, §8 Q3), provides a `Yaml` value tree with `ToJson`,
and depends only on `core` — **no native FFI**, so the CLI stays a clean,
portable native build (ADR-003).
**Rejected:** `ingydotnet/*` — the strongest *pedigree* (the YAML project
itself), but they bind to the native `libyamlscript` shared library, adding a
heavy, platform-specific runtime dependency at odds with ADR-003.
`tree_sitter_yaml` — a C/tree-sitter grammar producing a concrete syntax tree,
not a value loader; also native FFI. `myfreess/yaml` — too immature.
Hand-rolling a `@json` subset — deferred fallback only, loses YAML ergonomics
(comments, anchors, block scalars) and re-implements a solved problem.
**Trade-offs:** the library is young (0.0.x) and may churn — mitigated by the
`yaml` seam (§4, §8 Q1), which exposes *our* document tree + spans to the rest of
the pipeline so the dependency can be swapped or vendored without touching
`loader`/`resolve`/`emit`. For full per-node spans we drive the low-level
`MarkedEventReceiver` (events carry `Marker`s), not just `Yaml::load_from_string`
(whose value tree drops positions). The `moon add` and the seam land in Phase 2,
where the parser is first used.

### ADR-010 — Parser source spans are deferred (library seals its event API)
**Status:** Accepted. (Refines ADR-009; does not supersede it — the library is
still our parser.)
**Context:** ADR-009 chose `moonbit-community/yaml` partly for per-node source
spans via its `MarkedEventReceiver` (events carry `Marker { line, col }`).
Implementing that seam revealed the trait is a **sealed** `pub trait` (not
`pub(open)`), so a type in *our* crate cannot implement it — the event/marker
API is unusable by external callers. The high-level `Yaml::load_from_string`
value tree we can use carries **no positions**, and its `YamlError` is opaque
across the package boundary (no extractable marker).
**Decision:** For now the `yaml` seam consumes `Yaml::load_from_string` and maps
its (already text/number/bool-classified) value tree to our `Node`. `Node`
carries **no spans yet**; diagnostics from `yaml`/`loader` name the **file** but
not a line/col. This is acceptable because **rich file/line/col diagnostics are a
Phase 8 deliverable**, not a Phase 2 one.
**Consequences / path forward:** the `yaml` seam (§4) keeps the parser swappable,
so Phase 8 can obtain spans by one of: (a) upstreaming an open/marked API to
`moonbit-community/yaml`, (b) vendoring a span-capable subset parser, or (c)
switching parsers — all behind the seam, touching only `yaml` (+ the shape of
`Node`). Downstream code already flows optional spans through `@diag.Diagnostic`
(its `span` is optional), so adding spans later is additive.

### ADR-011 — `moonbitlang/x` for the CLI's filesystem, args, and exit
**Status:** Accepted.
**Context:** The `cmd/main` executable needs synchronous file read/write, the
argv, and a process exit code. `core` has none of these; `moonbitlang/async/fs`
(a transitive dep) is async and would drag an event loop into a simple batch CLI.
**Decision:** Depend on **`moonbitlang/x@0.4.47`** (the official MoonBit extended
library) and use `x/fs` (`read_file_to_string`, `write_bytes_to_file`) and
`x/sys` (`get_cli_args`, `exit`). It is used **only** in `cmd/main`, preserving
the I/O-free core (ADR-003). Its `IOError` is `pub(all)`, so the CLI pattern-matches
it for a clean "cannot read/write \<file\>: \<reason\>" message.
**Trade-offs:** `x` is a broad, still-`0.x` grab-bag, but it is first-party and
the surface we use (fs/sys) is small and stable; confined to `cmd/main`, a future
swap touches one file.

### ADR-012 — Named definitions with explicit references; resolved in the loader
**Status:** Accepted. (Resolves §8 Q3.)
**Context:** Phase 5 lets an author declare a value/formula/style once and
reference it many times, compiling to Excel's native sharing (ADR-004). ADR-009
had noted YAML anchors/aliases as a candidate, but the chosen parser
(`moonbit-community/yaml`) **resolves `*alias` into a copy of the anchored value
during load** (its loader looks the anchor up and inlines it), so by the time the
`yaml` seam hands us a `Node` there is no alias identity left — anchors give
textual reuse but cannot be compiled to *defined names* or *shared formulas*.
**Decision:** Reuse is expressed with **explicit named definitions**: a top-level
`defs:` block with `styles`, `values`, and `formulas` maps. A **style** is
referenced by bareword name where a `style:` mapping would go (`style: header`),
matching the existing scalar-shorthand idiom (`fill:` hex, `border:` name). A
**value** or **formula** is referenced by `{ $ref: name }` — an explicit marker
is needed because a bare scalar there is a literal value. The reference's kind is
fixed by position (`style:`/`value:`/`formula:`), so each kind has its own
namespace and a name may be reused across kinds. YAML anchors remain usable as
plain-text convenience but are not the sharing mechanism.
**Resolution stays in the `loader` for now:** references resolve to the same
model value/style, and the emitter's existing structural interning (ADR-004)
already collapses them to one shared string / `cellXfs` id — so the source-level
single-source-of-truth is delivered without a `resolve` package yet. Excel
*defined names* and *shared formulas* (Phase 5 item 3) will introduce `resolve`
(§4) and carry reference identity into the model. A reference to an undefined
name is a fail-fast `@diag.SchemaError` (ADR-006); cyclic references cannot occur
until definitions may reference each other.

### ADR-013 — References compile to Excel defined names; `=name` cells
**Status:** Accepted. (Extends ADR-012 for Phase 5 item 3.)
**Context:** ADR-012 made references resolve in the loader by inlining the target
value/formula. That already yields Excel-native sharing for two of the four
mechanisms — repeated string values dedupe into the **shared-strings** table
automatically, and equal styles intern to one **cellXfs** id (ADR-004). The
missing piece is a mechanism the *author* can edit centrally in Excel: a
**defined name**.
**Decision:** Every declared `defs.values`/`defs.formulas` entry is emitted as a
workbook **defined name** (`set_defined_name`), in declaration order so output is
deterministic. A value or formula **reference** compiles to the formula `=name`
(a `model.Formula` whose body is the name), caching the definition's value so the
cell displays it until Excel recomputes — editing the defined name in Excel then
updates every reference (the §1 "change once, changes everywhere" promise). A
constant's `refers_to` is its formula literal (a string is quoted with inner
quotes doubled; a number/bool/date is its literal); a named formula's is its body
verbatim. Styles are **not** defined names (Excel has no such concept beyond
`cellXfs`); style references stay interned (ADR-012).
**Trade-offs / consequences:** a referenced value cell is a *formula* (`=name`),
not a literal — the chosen behavior, since it is what makes central editing work.
A named formula's defined name inherits Excel's relative-reference semantics
(evaluated relative to the referencing cell); authors wanting position
independence use absolute refs — consistent with "yxl emits, Excel computes"
(§2). Invalid Excel names (e.g. a name shaped like a cell ref) are rejected by
the backend as an `EmitError`; loader-side name validation with better spans is a
Phase 8 refinement. **This fit in the `loader` plus one model field
(`Workbook.defined_names`), so the dedicated `resolve` package (§4) that ADR-012
anticipated is still deferred** — it will land when a pass genuinely needs its own
stage (shared-formula grouping, or cycle detection once definitions may reference
each other).

### ADR-014 — Includes are `{ $include: path }`, resolved through an injected reader
**Status:** Accepted.
**Context:** Phase 7 needs a data/format split (§8 Q4, ADR-005). The obvious
spelling is YAML's own `!include` tag — but the parser seam cannot see tags. The
library's tag-carrying API is its `MarkedEventReceiver` event stream, whose
trait is **sealed** (`pub trait`, not `pub(open)`), so we cannot implement a
receiver (this is the same wall ADR-010 hit for source spans); the value-tree API
we do use (`Yaml::load_from_string`) drops unknown tags silently. Verified: a
spec containing `A1: !include x.yaml` compiles *successfully* with the cell set
to the text `"x.yaml"`. A directive that degrades into a plain string on a typo
is exactly what ADR-006 forbids.
**Decision:** An include is a **single-key mapping** `{ $include: <path> }`,
usable wherever a node may stand (a whole document, one sheet, a `cells:` block,
a `defs.styles` map). `$` marks it as a directive rather than spec data, matching
`$ref` (ADR-012). Expansion is a pre-pass over the document tree, so every
schema reader below is unaware more than one file existed. Combining `$include`
with sibling keys is a diagnostic rather than an implied merge — that keeps merge
semantics available to define later.
**Reading the files stays outside the core (ADR-003):** `load` takes an
`IncludeResolver` — `(from, path) -> Included { name, source }` — that the CLI
supplies from the filesystem and tests supply from a map. The resolver returns
the *name* it opened, so a diagnostic inside an included file points at that
file and nested relative includes resolve against the right directory. Include
**cycles are detected during expansion** and reported with the whole chain
(`a.yaml -> b.yaml -> a.yaml`); this is the cycle detection Phase 5 deferred,
arriving with the first construct that can actually form a loop.
**Trade-offs / consequences:** `load` now raises `Error` rather than only
`@diag.SchemaError`, because a syntax error in an included file is a genuine
`@diag.YamlError` — the alternative was relabelling syntax errors as schema
errors, which would lie about the kind. The path is resolver-defined: the CLI
treats it as relative to the including file, so a spec directory can be moved
wholesale. Should the parser ever gain an open event API, `!include` could be
added as sugar over the same expansion, but `$include` stays the contract.

### ADR-015 — Parameters are `${name}`; a lone placeholder keeps its type
**Status:** Accepted.
**Context:** Phase 7 wants one spec to build many workbooks. The project's other
directives are single-key mappings (`$ref`, `$include`), but a parameter's main
use — composing a sheet name or a title out of parts (`"${quarter} ${region}"`)
— cannot be expressed by substituting a whole node.
**Decision:** A top-level `params:` block declares names with defaults;
`${name}` inside **any string** substitutes one, in mapping keys as well as
values, so a cell reference or a sheet name may be parameterized. Substitution
is a pre-pass over the document tree (after include expansion, so included files
see the parameters), leaving the schema readers below unaware parameters exist.
A default may itself use `${other}`; that makes cycles possible, and they are
detected and reported with the chain.

Two rules make the mechanism carry types rather than only text:
- A string that is **exactly one placeholder** takes the parameter's own type,
  so `B1: "${rate}"` is a number cell, not the text `0.08`.
- A `--set` value arrives from a command line as text and is read as **the
  scalar it looks like** — the same inference CSV fields get, since neither
  carries types. Without this, `--set rate=0.15` would silently turn a number
  cell into a text one.

**Trade-offs / consequences:** `$$` is a literal `$`, and a `$` beginning
neither escape is itself, which leaves Excel's absolute references (`$A$1`)
untouched. The two meanings collide only when a literal `$` *immediately*
precedes a placeholder: `$B$${n}` reads its middle `$$` as the escape, and the
author writes `$B$$${n}` instead. The substitution pass runs **even when no
parameters are declared**, so a stray `${nope}` is a diagnostic rather than a
literal reaching a cell (ADR-006) — a spec that wants a literal `${` writes
`$${`. Setting a name the spec does not declare is likewise an error, so a typo
on the command line says so instead of quietly doing nothing.

### ADR-016 — Schema diagnostics name the file and the construct, not the line
**Status:** Accepted. (Closes the deferral ADR-010 left to Phase 8; does not
supersede it — its analysis of the parser still holds.)
**Context:** ADR-010 deferred per-node source spans on the grounds that "rich
file/line/col diagnostics are a Phase 8 deliverable". Phase 8 arrived. Syntax
errors turned out to be fixable cheaply — the library's `YamlError` does carry a
marker, which is now destructured into a real `Span` and rendered with a caret.
*Schema* errors are the hard half: they would need every `Node` to remember
where it came from, and the parser exposes positions only through a **sealed**
trait, so that means vendoring the parser, upstreaming an open API, or writing
our own.
**Decision:** Do none of those. A schema diagnostic names the file and the
construct it is about — `cell 'A1'`, `column 'B'`, `sheet 'Sales'`, `parameter
'region'` — which in a structured document locates the problem about as well as
a line number would, and the messages say what was expected. The cost of the
alternatives is a parser fork to maintain, for a strictly cosmetic gain.
**Consequences:** the `yaml` seam still keeps the swap possible, and
`@diag.Diagnostic`'s span stays optional, so if this is ever revisited the change
is additive and local. One knock-on stays documented rather than fixed: an
external `data:` path resolves against the spec passed to `yxl build` rather than
the file the entry was written in (it fails loudly with the path it tried, never
silently reading the wrong file).

### ADR-017 — `yxl extract`: a reader seam, the schema as the boundary, and verified compression

**Status:** accepted 2026-07-27.
**Context:** §8 Q5 put a one-way `.xlsx` → spec conversion in scope; Q9 left the
name and the recovered scope open. Reading `.mooncakes/` settled two premises the
phase item had guessed at. The **backend is not the constraint** — `xlsx.Workbook`
exposes a getter for essentially everything yxl emits, and `get_cell_style`
returns a style *id*, so equal ids already mark the cells that share a style and
the `defs.styles` interning is handed to us. The **narrower thing is yxl's own
model**: the reader returns underline *styles*, gradient and pattern fills, and
theme/indexed/tint colours, none of which the schema has words for, and its
`CellValue` has no `Date` at all.

Beyond faithfulness there is a second demand, and it is the one that decides
whether the feature is worth having: the output must be a spec somebody would
*keep*. A conversion that spills five thousand `cells:` lines is correct and
useless. But every compression — "this rectangle is a data table", "this number
is a date" — is an **inference**, and a wrong inference produces a spec that
compiles to a different workbook.

**Decision:**
1. The command is **`yxl extract`**. `import` points the wrong way (it writes
   YAML); `decompile` overclaims equivalence; `scaffold` overclaims that the
   result is only a starting point.
2. A **reader seam mirroring ADR-002**: reading lives behind one interface, in
   one package, so `emit` stays the only *other* place that touches `@xlsx` and
   the backend stays swappable from both directions.
3. **The schema is the recovery boundary.** Recover what the schema can express;
   never guess at what it cannot; report every drop. This is a rule rather than a
   list, so it cannot drift into the round-trip §2 rules out — the schema is the
   ceiling — and it keeps working as the schema grows.
4. **Compression is verified, not trusted.** `extract` recompiles its own output
   in-process and compares against what it read; any region that does not match
   falls back to the flat form. Both halves of the pipeline are already in
   memory, so this is cheap — and it is what makes compression **the default**
   (`--flat` opts out) rather than a flag for the brave.
5. **Names come from the file wherever the file has them** — defined names,
   table names, sheet names, and header-row text are the author's own words and
   are taken verbatim. Styles are the *only* invented names, and they are
   descriptive only on strong evidence (a style used solely on a table's header
   row; a style whose only distinguishing feature is a number format), neutral
   `style_N` otherwise. A wrong descriptive name is worse than a meaningless one:
   it misleads, and because references are by name, correcting it means replacing
   every reference rather than one definition.

**Consequences:** a **YAML writer is new machinery** — `src/yaml` is parse-only —
and since readable output is the point, its quoting, block-vs-flow, and layout
rules are a deliverable rather than a detail. Extraction can never outrun the
schema, so a gap shows up as a *schema* item instead of as cleverness here: the
first one found is filled formula columns (Phase 11), without which slice 1's
output is 499 near-identical lines. Choosing CSV extraction is also choosing
multi-file output, because `data:` reads a path and has no inline form — until
Phase 11's inline `values:` lands. `$include` splitting is never inferred.

### ADR-018 — A deliberate one-off deviation is an `overrides:` entry, applied last

**Status:** accepted 2026-08-15.

**Context:** Some cells are exceptions to the rule that produced them: the value
comes from a `${quarter}` parameter and one cell must differ, or from a CSV the
source system refreshes monthly, or from a `formulas:` range where one row does
not follow. The schema could already *express* all three — sheet keys apply in
the order written, so a `cells:` entry after a `data:` block wins — and that is
exactly the problem. Once written it is indistinguishable from a cell the author
meant, so the exceptions cannot be counted, carry no reason, and cannot be
folded back into a rule when five of them turn out to share a shape. It is also
load-bearing on YAML key order, which a formatter is free to change. The
alternative, a tool recording its own edits in metadata beside the spec, gives
up the thing the format is for: ordinary reviewable YAML (#66).

**Decision:** a top-level `overrides:` sequence, each entry `at:` one
sheet-qualified cell, carrying the `cells:` grammar (§3 of `docs/spec.md`) plus
a free-text `reason:`. Four rules make it a layer rather than a second `cells:`:

- **Applied last, by construction** — after everything that writes a cell,
  before everything that reads one (a table takes its column names from the
  header an override replaced). Not by position, so reformatting cannot break
  it.
- **Per facet.** `value` and `style` are independent: a new value keeps the
  cell's formatting, a new style keeps its value. A cell's two halves come from
  different places in a spec, and overriding one must not discard the other.
- **A single cell, never a range** — a range would make this a second styling
  mechanism, and an override that covers 500 cells is not an exception.
- **It must have something to override.** No `cells:`, `data:`, or `formulas:`
  entry writing that cell is a diagnostic, not a new cell.

**Workbook-level, not per-sheet**, because the layer is the workbook's: one
countable block answers "how hand-patched is this spec?", which is the question
that motivated it, and a sheet in its own `$include`d file keeps its rules free
of its exceptions.

**Trade-offs / consequences:** the case with no other answer is a `formulas:`
range. An override inside one takes that cell out of the shared-formula group
and leaves the range whole — one stored formula, one exception — where splitting
the range costs three ranges and says nothing about why. The group's declared
`ref` then covers a cell that no longer follows it (ECMA-376 §18.3.1.40); that
package is schema-valid to the Open XML SDK validator and round-trips through
the backend's reader, but Excel itself is the Tier-3 manual judge and has not
ruled. The range's **top-left is refused**, since that is where the shared
formula is stored and replacing it would take the formula from every other row.

`extract` never writes overrides — a workbook cannot say which of its cells were
exceptions, and inventing the distinction would be a guess (ADR-017).

Deliberately not taken here: **`yxl build` reporting the count** it now has in
hand ("wrote out.xlsx — 23 overrides"). That is a change to the `@cli` seam's
return type rather than to the schema, and the schema is what the freeze is
about.

### ADR-019 — The JSON Schema is generated from `docs/spec.md`, by a build tool

**Status:** accepted 2026-08-16.

**Context:** An author writing a spec wants what an editor gives every other
config format: completion, hover text, and an underline beneath a key that does
not exist. A JSON Schema buys all three for anything speaking
`yaml-language-server`. The danger is the one every published schema has — it
becomes a fourth description of the format, beside the loader, the reference,
and the cookbook, and it is the one nobody notices going stale.

**Decision:** `docs/yxl.schema.json` is **generated**, and the reference is what
it is generated from. `tools/spec-schema/generate.py` reads `docs/spec.md` for
the *vocabulary* — every key, taken from the key tables, the YAML examples of
the sections documented by showing them, and the matrix of §4; every
enumeration, taken from the lists the page already writes out (the icon sets,
the chart types, the geometries, the numbered table styles); and the sentence
each key carries, which becomes the `description` an editor shows. The generator
supplies the *shapes* — types, nesting, required keys, the `$include`
alternative — which prose states in English and cannot state precisely.

**Neither half may move alone.** Every mapping is built against its documented
key list, and a key documented but unshaped (or shaped but undocumented) fails
the generator by name; a renamed heading or a table that stops being a key table
fails it too. CI runs `--check` (the committed JSON is what the reference now
says), `--selftest` (the schema still refuses the mistakes it exists to catch —
a schema decayed to `{}` passes every corpus otherwise), and `--validate` over
`examples/` and `tests/validity/`.

**Why a build tool and not MoonBit.** It is not product code: it ships in no
binary, links to no package, and runs when `docs/spec.md` changes. Reading
markdown, emitting JSON, and validating a corpus against a JSON Schema are one
afternoon in Python and a long one in MoonBit, and the compiler core stays
I/O-free (ADR-003) precisely by not growing a documentation reader. It joins
`tools/openxml-validator` (C#) and `.github/scripts/` as CI-side tooling; no
module dependency is added.

**Trade-offs / consequences:** the schema is a *shape* check, and the compiler
stays the authority on meaning — that a `$ref` names something declared, that
ranges do not overlap, that a `data:` entry gave exactly one source. Those need
more than one key to decide and each deserves a sentence rather than a squiggle
(ADR-006); expressing them as `oneOf` would trade good diagnostics for bad ones.
So a spec the editor accepts can still be refused, with a better message. Two
further limits, both stated in `docs/spec.md` §24: an `$include`d fragment is
not a document and is not described, and a `${parameter}` is accepted wherever a
value goes, since a placeholder is text until substitution (§7).

The generated file is committed rather than released as an asset, so
`$schema=…/main/docs/yxl.schema.json` resolves for anyone, and pointing at a
local checkout works while writing against an unreleased `yxl`.

### ADR-020 — An attribute has three states, and `null` is the third

**Status:** accepted 2026-08-20.

**Context:** A style attribute could say two things: *this value*, or nothing.
Nothing means **inherit** — from the style an inline one extends, from the
column band, from the row band over it — so there was no way to say an attribute
is *not* set where something underneath sets it. A boolean escapes this because
`false` is a value (`font: { bold: false }`); a fill, a colour, a number format,
an alignment, and a border edge have no such spelling, and the cell that Excel
draws unfilled inside a filled column could not be written down at all.

It reached further than authoring. `extract` hits the same wall from the other
side, and it hit it *silently* (#71): a `<c>` in a file takes its whole look
from its own style — `<col style>` reaches only the cells a sheet never
allocated (ECMA-376 §18.3.1.13) — so a written cell that carries no fill is
unfilled however the column is styled. The reader turned such a cell into "no
style", the renderer wrote it as a bare value, and rebuilding filled it. The
self-check could not see it: it compares the model it recovered against the
model the rebuilt spec produces, and the difference had already been dropped
before the comparison began, in the reader.

**Decision:** an attribute is in one of **three** states — set, not mentioned,
or **explicitly not set** — and the third is written `null`:

```yaml
C2: { value: 2, style: { fill: null } }
A1: { value: 1, style: { extends: header, fill: null } }
```

`null` reads the same at every leaf (`font: { color: null }`, `format: null`,
`align: { horizontal: null }`, `border: { left: null }`), at a whole group
(`font: null`), and at the `format:` shorthand beside a style. Both spellings an
author would reach for were hard errors before, so nothing that compiled changes
meaning.

The model carries the third state as a **set of attributes the style clears**
(`Style::cleared`, a bitmask newtype) rather than by making every leaf a nested
option: one field, one merge rule, and every construction site untouched. One
constructor holds the exclusivity — `Style::clearing` never clears an attribute
the same style gives a value to, which is §6's "a value beside it wins" — so
the loader, the reader, and layering cannot drift on it. `Style::over` gained
the rule that completes it — a cleared attribute wins over
the base's value and stays cleared until something sets one — and the set is
dropped at the emitter (`Style::settled`), because once layering is over "not
set" and "explicitly not set" are the same cell and must intern as one
`cellXfs` id (ADR-004).

**The reader writes it.** After the bands are read, a written cell whose own
formatting is missing something its band sets is given the `null` that says so —
only what the band would actually have passed down, so a text cell under a
numeric column band stays quiet (a format with fewer than four sections never
reaches text; ECMA-376 §18.8.31).

**Alternatives rejected.** `none` as a keyword reads better for a fill, but
`none` is also a real border style in `ST_BorderStyle`, and it would need
defining per key type rather than once. An `unset: [fill, format]` list is
harder to write by accident — the one real argument against `null`, since a key
whose value an author simply forgot parses as `null` too — but it is a second
grammar for what the first grammar nearly says, and it does not compose inside a
group the way a leaf does.

**Trade-offs / consequences:** `fill:` written with nothing after it now means
"explicitly not set" rather than failing, which is the price of the YAML
spelling; the reference says so where it introduces `null` (§6). A `rich:` run's
font refuses `null` outright — a run inherits nothing, so there would be nothing
to take away — and that is a compiler diagnostic rather than a schema rule. In a
conditional format's style, where nothing is inherited either, `null` is
accepted and means what leaving the key out means.

### ADR-021 — The backend is `moonbitlang/mbtexcel`, and the ZIP comes from `moonbit-community/flate`
**Status:** Accepted. Supersedes ADR-001's *coordinates* only; the decision to
build on this backend (ADR-001) and to keep it behind a seam (ADR-002) stand.
The version numbers below are the pin as it stood when this was written —
`moon.mod` is the pin of record, and §11 records each bump.
**Context:** The library moved from `bobzhang/mbtexcel` to
`moonbitlang/mbtexcel` — same code, same `xlsx` package, an ownership change
rather than a fork. Its `0.1.10` also dropped the vendored `zip` package in
favour of `moonbit-community/flate/zip`, which `emit` uses directly to deflate
the package the backend hands over.
**Decision:** Depend on `moonbitlang/mbtexcel@0.1.10` and, for the ZIP,
`moonbit-community/flate@0.7.3`. Pin both, as ADR-001 says.
**Trade-offs:** one more direct dependency, and the `zip` API is not identical
(`Archive::Archive()` where the vendored one had `Archive::new()`). The seam
held: the whole migration is four `moon.pkg` import lines and one constructor.

### ADR-022 — Trait-method promotion is declined, once per type, in `extends.mbt`
**Status:** Accepted. An instance of ADR-007.
**Context:** MoonBit stopped promoting a trait's methods to inherent methods
implicitly: a `derive(Eq, Debug)` on a public type now warns
(`implicit_impl_as_method`) until the package says, by name, whether
`x.equal(y)` and `x.to_repr()` are part of its surface. With 89 derived types
that is 183 warnings, and `moon check --deny-warn` is a CI gate.
**Decision:** Each package declines every promotion in an `extends.mbt`, with
`#deprecated(…, skip_current_package=true)` and `#doc(hidden)` on each
declaration. Nothing in `yxl` calls `equal`, `not_equal`, `hash`,
`hash_combine`, `to_repr`, `Emitter::emit` or `Reader::read` in method
position, and the traits are reached by operator (`==`, `!=`) or by trait
(`Emitter::emit(e, wb)`, `debug_inspect`), so promoting would widen the public
surface for callers that do not exist (§8.2).
**Trade-offs:** ~960 lines of declaration, most of them `model`'s, for
behaviour that does not change; the alternative — promoting `Eq::equal` and
`Hash::hash` the way `moonbitlang/core` does for its own users — costs *more*
lines, since the derivatives still need declining, and puts 89 `equal` methods
into the `.mbti`. Declining keeps every `.mbti` byte-identical, which is what
makes the diff of a language migration reviewable. Adding a derived type now
costs an `extends.mbt` entry; the compiler names the missing one.

### ADR-023 — Columns get names inside a `layouts:` region, and the names stay there

**Status:** accepted 2026-09-23 (design; implementation is the Phase 11 item).

**Context:** #89 asks for columns addressed by meaning rather than by letter,
and for a repeated group of columns to be written once. The motivation stands.
Every sheet key that takes a column takes a letter, and one inserted column
therefore rewrites `columns:`, `formulas:`, `conditional:`, `data:` and every
formula to its right. The RFC's surface was reviewed against the current
loader, the house schema shape, and what has to stay unambiguous after the
freeze. Six changes follow.

**Decision:**
- **A name does not go into `at:`.** The RFC let every `at:` take a name as
  well as a letter. But labels run to `XFD`, so `cy`, `py`, `tax` and `abc` are
  already columns, and every `at:` parser and schema pattern would change.
  Instead a layout column **carries its own** width, style, format, formula and
  conditional rules. Nothing outside the layout names it until slice 4.
- **`{{name}}` has one meaning: the named column's cell in the same row.** It is
  expanded only in a layout column's `formula:` and in its conditional rules'
  `formula:`. Elsewhere `{{` stays literal text, as it is today, so no existing
  spec changes meaning. Inside a formula's `"…"` string literal it is skipped.
  An unknown name is a diagnostic, never literal text (ADR-006). Scope is
  lexical: the block instance first, then the layout; `inst.col` is qualified.
  So the RFC's `実績!{{gross.ratio}}37` is refused: it would give `{{…}}` a
  second meaning, a column letter, inside the address grammar.
- **No block arguments.** The RFC's `args: [label]` / `{{label}}` shared the
  column-reference syntax, and `args: [cy]` would be ambiguous. Its only use
  was the group header, so the instance supplies that: `header: 売上`. A
  template language waits for a case that needs one.
- **An anchor and a count, not row ranges.** The RFC had `header_rows: 1-2`
  and `body: 3-200`. Here it is `at: A1`, `rows: N`: header depth follows from
  the columns (one row, or two once a block has a group header), and the body
  starts beneath it. Adding a group header then renumbers nothing, and rows
  above the anchor stay free for a title.
- **`layouts:` is a list.** `data:`, `tables:` and `formulas:` are lists of
  anchored entries, and a sheet with two regions side by side or stacked is
  ordinary. Two layouts that give one sheet column different widths or styles
  are refused.
- **The expansion happens in the loader, not a YAML→YAML pass.** A `layouts`
  arm in the sheet loader generates the entries the hand-written keys would
  make, through those keys' own validation. Then there is one validator, and a
  diagnostic names `column 'sales.ratio'` rather than a letter the author never
  wrote (ADR-016). A text-level pass would lose both.

**Not an Excel table.** A ListObject has structured references
(`[@当年]`), which are position-free natively, and ADR-004 prefers the native
mechanism. It cannot hold this shape, though: its header is one row, it has no
merged cells, and its names are unique, while `当年` repeats once per block. A
layout that fits may later *also* be declared a table.

**Trade-offs / consequences:** The output is byte-identical to the hand-written
spec, so nothing downstream of the loader changes, and equivalence with the
spec an author would write by hand is the test. `extract` keeps writing the
hand form and cannot recover a layout. A workbook does not record that its
columns were ever grouped, so proposing one is a guess (ADR-017), left open.
Rows stay positional: `overrides:` and anything else outside a layout still
counts rows. Row-axis groups are the same design turned 90°, deliberately not
taken.

### ADR-024 — A layout's footer: totals and grouped totals, as live formulas

**Status:** accepted 2026-09-23.

**Context:** Once a layout's body follows its data (slice 2 of ADR-023),
nothing below it has a fixed row, so a totals row cannot be written with
`cells:`. The case that forced the question is a real workbook. Beneath its
rows it lists a subtotal per 支店, one per 直営/FC within each 支店, and a
grand total. Excel's native answer is a pivot table (§14), which yxl already
emits, but a pivot cannot reproduce a report's own shape.

**Decision:** a layout's `footer:` is a list of entries placed right after the
body. An entry is a **row** or a **group**.
- A row is `{ row: { column: cell, … }, style: … }`. The explicit `row:`
  wrapper keeps column names out of the entry's own key space, so a column
  may be called `by` or `rows`.
- A group is `{ by: column, order: …, rows: [entries] }`. It repeats its
  entries once per value of `column`. It nests, and the author orders a
  subtotal before or after its children by where the row sits in `rows:`.
- The values come from the layout's data: the ones present within the
  enclosing groups, in `order` (`data`, `asc`, `desc`). An explicit list
  instead gives a row for every value, present or not. A value the data holds
  that the list omits is an error, because a subtotal silently dropped is the
  worst outcome a total can have.
- `{ total: sum | count | average | min | max }` is an aggregate over the
  column's body. Inside groups it becomes the `…IFS` form, with one criterion
  per enclosing group.
- The criteria are **literals** (`"=東京支店"`, with `~ * ?` escaped), not
  references to a label cell. So a label may sit in any column, in any
  wording, and the amounts stay live formulas.
- `{{name}}` keeps its one meaning: this row's `name`. Inside a formula it is
  the cell. Inside a footer row's *text* it is the value, which is known only
  for a group's column, so `"{{branch}} {{channel}} 計"` is a label, and
  `{{cy}}` in text is an error.

**Trade-offs / consequences:** The group rows are fixed when the spec
compiles. A value the data holds is covered, but a value typed into the
workbook afterwards gets no row until the next build: yxl emits, Excel
computes. `min` and `max` compile to `MINIFS`/`MAXIFS`, which need Excel 2019
and are written with the `_xlfn.` prefix, which Excel expects on a function
newer than the file format. A footer cell is not part of the column's conditional
formats, which cover the body. Subtotal rows *interleaved* with the data, as
in Excel's Subtotal command, are a different operation: they reorder and
split the body. They are not taken here.

### ADR-025 — Outside a layout, its columns are Excel defined names

**Status:** accepted 2026-09-23.

**Context:** Slice 4 of ADR-023 needed a way to name a layout's column from
outside it. The cases are a summary on another sheet, an override for one
store, and a chart series. ADR-023 constrained the syntax: it must not read as
an A1 reference, and it must have one meaning. A new yxl-only reference syntax
would have to be taught, parsed inside every formula, and resolved in load
order, since a summary sheet often comes before the data sheet it sums.

**Decision:** No new syntax.
- A layout may take a `name:`. Each of its columns' bodies then becomes a
  **workbook defined name**, `name.column`, which refers to the absolute body
  range. Formulas use it the way Excel users already do (`=SUM(店舗.cy)`).
  Excel resolves it, so load order does not matter, and Excel keeps it in step
  when rows are inserted in the workbook. This is ADR-004's rule, a thing
  named once compiles to Excel's own sharing, applied to layouts.
- An override's `at:` may instead be `{ layout, column, where | row }`. `where`
  matches the data's values in input columns and must find exactly one row.
  An ambiguous key or a vanished row is therefore a diagnostic (ADR-006), not
  a patch on the wrong store. Overrides now load after the sheets, which their
  "applied last" rule (ADR-018) already implied.
- Defined names and table names share Excel's namespace and are compared
  without case. That was never checked: two defs differing only in case
  opened as a broken workbook. It is now one check over every name.

**Trade-offs / consequences:**
- Only named layouts produce defined names, because a layout of twenty columns
  adds twenty entries to Name Manager. `name:` is the opt-in.
- A chart series, a validation list and a sparkline read their ranges while
  loading, so they cannot use the names yet (slice 4b).
- `where` sees the data only, so a formula column cannot be a key.

### ADR-026 — Layouts are placed in a pass ahead of the sheets

**Status:** accepted 2026-09-23.

**Context:** A chart series, a validation list and an anchor read their range
while the spec loads (slice 4b of ADR-023). A named layout's range depends on
its data's length, and on its footer's groups. It may also sit on a sheet that
loads after the reader: a summary sheet usually comes first. Defined names
(ADR-025) avoid the question for formulas, because Excel resolves those. These
readers cannot.

**Decision:** `load` runs `settle_layouts` right after `defs`.
- It loads every sheet's `layouts:` onto a scratch sheet and records each
  named layout's position in `defs.layouts`.
- The real pass then loads layouts again, into the real sheets, without
  recording them. `parse_sheet_range` and `anchor_cell` both consult the
  record.
- A `below:` anchor may only follow a layout declared earlier in the spec, so
  the settling pass can place the follower too, and a cycle cannot be written.

**Trade-offs / consequences:**
- Layouts are expanded twice, and a layout's CSV is read twice through the
  include resolver. Loading is linear and cheap next to emit, so the cost
  buys a single place where every reader finds every layout.
- A broken layout is now reported before the sheets' other errors.
- The model is unchanged: a resolved name is an ordinary range.

### ADR-027 — Publish to mooncakes; `moonx` runs the CLI as wasm

**Status:** accepted 2026-10-10. Resolves §8 Q6. Extends ADR-003: the native
binary stays the primary distribution, and `preferred_target` stays `native`.

**Context:** `moonx user/module/package` runs a registry package without
installing it, on the linear-memory `wasm` backend (its `native` target is
deprecated). Q6 assumed a wasm CLI would need a lighter backend behind the
ADR-002 seam. That premise no longer holds:
- `moon check` passes for `wasm`, `wasm-gc` and `js` with no change.
- `moon run src/cmd/main --target wasm -- build` produces the same bytes as
  the native binary for all 14 `examples/`.
- Under `moonx`'s default policy, `x/fs` reads and writes paths relative to
  the working directory (checked with a `.mbtx` probe; not yet with a
  published package).

**Decision:**
- Publish the `t-ujiie-g/yxl` module to mooncakes, so that
  `moonx t-ujiie-g/yxl/cmd/main build spec.yxl.yaml` works with no install, and
  `moon install` builds the same entry point.
- The supported library surface is `@cli.compile` and `@cli.extract`, which
  are I/O-free (ADR-003). The other packages are implementation, and are taken
  out of the public surface or marked unsupported before the first publish.
- CI builds `cmd/main` for `wasm` and checks that its output matches the
  native binary for `examples/`. That keeps the wasm path from drifting.
- The first publish waits for the next `mbtexcel` release, which carries
  the fixes for our upstream reports. It also waits until yxl has adopted
  that release.
- The module's version stays the one in `moon.mod`. A mooncakes publish
  happens at the same tag as the GitHub release.

**Trade-offs / consequences:**
- Publishing cannot be undone version by version: `moon deprecate` covers
  every existing version at once. A bad release is fixed by a newer one.
- Every publicly reachable package becomes something another module can
  depend on. Narrowing the surface first is what keeps internal refactors
  free of semver cost.
- `js` builds, but a browser or editor build of `compile` is a separate
  question and not part of this decision.

### ADR-028 — The compiler packages are `internal`; `include` holds the resolver types

**Status:** accepted 2026-10-11. Implements ADR-027's narrowed surface; refines
ADR-008.

**Context:** ADR-027 limits the supported surface to `@cli.compile` and
`@cli.extract`. `moon` enforces Go-style `internal/` visibility. Another module
cannot import `t-ujiie-g/yxl/internal/loader`, and the build plan refuses it by
name. A re-export does not get around this. A type that `pub using` re-exports
from an internal package can be named outside the module, but not constructed.
Both rules were checked with a two-module workspace on `moon 0.1.20260920`.
`compile`'s resolver callbacks return `Included` / `IncludedBytes`, which a
caller must construct.

**Decision:**
- `diag`, `units`, `yaml`, `model`, `loader`, `emit`, `read` and `render` move
  to `src/internal/`. Their `@alias` names do not change, so only `moon.pkg`
  import paths moved.
- `Included`, `IncludedBytes`, `IncludeResolver` and `BytesResolver` move from
  `loader` to a new public package, `include`. `loader` brings them in with a
  private `using`, so its code is unchanged.
- The public packages are `cli`, `include`, `cmd/main`, and `examples`, which
  is test-only and has no API.

**Trade-offs / consequences:**
- `@cli.parse_args` raises `@diag.CliError`, and `@cli.error_message` is the
  way an outside caller reads any error. The caller can catch these errors but
  cannot match on their variants. That is intended: the message is the
  contract.
- A caller of `compile` imports two packages, `cli` and `include`, rather than
  one.

### ADR-029 — A spec may name its output; `yxl build` may name nothing

**Status:** accepted 2026-10-12.

**Context:** every build had to repeat `-o`, though the destination is nearly
always the same per spec. A separate project file (`yxl.config.yaml` with
build targets) was considered and declined: a single self-contained spec is a
selling point, and a second file to keep in step undercuts it.

**Decision:**
- A top-level `output:` names a `.xlsx` path. It resolves against the spec's
  directory, like an include, and is substituted like any string, so
  `--set` can vary it. Any other extension is a schema error.
- Precedence: `-o`, then `output:`, then the spec's name with `.xlsx`
  (`report.yxl.yaml` → `report.xlsx`) beside it.
- `yxl build` / `yxl build --check` with no spec builds the only `*.yxl.yaml`
  (or `.yxl.yml`) in the current directory; none or several is a usage error
  (exit 2) naming what it found. The directory listing stays in `cmd/main`;
  choosing among the names is the pure `@cli.find_spec`.
- `output` rides on `@model.Workbook`, and the emitter ignores it. `@cli.build`
  returns the bytes with the resolved path; `@cli.compile` is unchanged.
- The directories an output path names are created if missing.

**Trade-offs / consequences:**
- The model carries one field that is about the build rather than the
  workbook. The alternative — a second loader entry point — would have parsed
  the spec twice or duplicated the include and parameter passes.
- `yxl extract` does not write `output:`: the default already rebuilds
  `book.yxl.yaml` to `book.xlsx`.

## 8. Open questions

- **Q1 — YAML parser.** ✅ **Decided (ADR-009), refined (ADR-010):** depend on
  `moonbit-community/yaml@0.0.6` (pure MoonBit, no native FFI), consumed behind
  the `yaml` seam. Its span-carrying event API turned out to be a sealed trait, so
  per-node spans are deferred to Phase 8 (ADR-010); the seam keeps that swap
  local. `moon add` + seam landed in Phase 2.
- **Q2 — Schema altitude.** How close should the schema mirror Excel's structure
  vs. a higher-level ergonomic DSL (e.g. `table:` shorthands)? Start close to
  Excel, add sugar later.
- **Q3 — Reference syntax.** ✅ **Decided (ADR-012):** explicit named definitions
  in a top-level `defs:` block — a bareword name for styles, `{ $ref: name }` for
  values and formulas. YAML anchors were rejected as the core mechanism because
  the parser expands aliases to copies, losing the identity needed to compile to
  defined names / shared formulas.
- **Q4 — Data/format split mechanism.** ✅ **Decided (ADR-014):**
  `{ $include: path }`, usable wherever a node may stand, resolved through an
  `IncludeResolver` the CLI injects so the core stays I/O-free (ADR-003). A YAML
  `!include` tag was ruled out on evidence — the parser's tag-carrying API is a
  sealed trait and its value tree drops tags silently, so a typo would compile to
  a plain string instead of failing (ADR-006). Because an include can replace any
  node, a `data:` / `format:` split needs no separate mechanism. External CSV/JSON
  tables remain their own Phase 7 item.
- **Q5 — Reverse import.** ✅ **Decided 2026-07-26: in scope, as a one-way
  import.** Wanted after the v0.1.0 review: an existing workbook becomes a
  starting spec instead of a retyping job, which is the biggest single drop in
  the barrier to adoption. Explicitly *not* a round-trip contract — lossy and
  irreversible is fine for a migration aid (§2, §6 Phase 10). What it recovers,
  and what it is called, are Q9.
- **Q10 — Non-ASCII on the Windows command line.** ✅ **Decided 2026-07-26:
  document it.** `moonbitlang/x/sys` reads argv as UTF-8 ("TODO: Handle other
  encodings") while Windows supplies it in the system code page, so
  `yxl build 売上\report.yaml` cannot work — the characters are already lost
  before yxl sees them. Fixing it here means our first FFI (`GetCommandLineW` +
  `CommandLineToArgvW`), Windows-only, for a problem upstream has already
  identified as theirs. Paths *inside* a spec are unaffected, which covers the
  case that matters: name the spec you type in ASCII, and everything it refers to
  can be in any script. README and `docs/spec.md` say so. Revisit if upstream
  stalls and real users hit it.

- **Q9 — What is the import command called, and how much does it recover?**
  ✅ **Decided 2026-07-27 (ADR-017), and implemented 2026-07-28: `yxl extract`,
  bounded by the schema.** `docs/spec.md` §22 is the user-facing account of what
  it recovers and what it does not.
  `import` was rejected as pointing the wrong way — what the command *writes* is
  YAML. `extract` reads from the file's side and claims neither completeness
  (`decompile`) nor scaffolding (`scaffold`).
  Scope is a **rule, not a list**: recover what the yxl schema can express, never
  guess at what it cannot, and report every drop. The rule is self-limiting — it
  cannot drift into a round-trip, because the schema is the ceiling — and it
  survives the schema growing.
  Two findings reshaped the question. **The backend is not the constraint**: the
  reader has a getter for essentially everything yxl emits, and `get_cell_style`
  returns a style *id*, so the `defs.styles` interning that looked expensive is
  the cheapest part. **The narrower thing is yxl's own model** — the reader
  returns underline styles, gradient fills, and theme/indexed/tint colours that
  the schema has no words for, and no `Date` at all (a date is a number plus a
  date number format, so `type: date` must be inferred from `num_fmt`).
- **Q7 — The parser's sealed API: vendor, replace, or live with it?**
  ✅ **Decided (ADR-016): live with it.** The YAML parser's value tree carries no
  positions and its marker-carrying event API is a sealed trait (ADR-010), so
  schema diagnostics name the file and the construct but not a line, and a
  `data:` path resolves against the root spec rather than the file it was
  written in. Vendoring or replacing the parser buys a line number for messages
  that already name what they are about; not worth it before v1.0. Revisit only
  if real use shows the file-plus-construct form is not enough.
- **Q8 — stdin/stdout.** ✅ **Decided 2026-07-26: dropped, revisit on demand.**
  `-o -` would let a workbook be piped straight to an uploader or mailer, and
  `build -` would take a generated spec from a pipe. Both were dropped because
  the workaround is one line (`yxl build s.yaml -o /tmp/r.xlsx && …`), so the
  gain is convenience, not capability. Three costs stood against it: the
  toolchain exposes **no stdin and no byte-level stdout** (only `println`), so
  either half needs `moonbitlang/async` or a native FFI stub; a spec read from a
  pipe has no directory, leaving relative `$include:` / `csv:` paths with nothing
  to resolve against (a `--base-dir` or a ban); and writing a binary to stdout
  requires first moving every message to stderr, for which there is likewise no
  API (no `eprintln`). Implement if piping actually comes up — and prefer the
  `-o -` half, which carries neither the path-resolution nor the "generate the
  spec" motivation that `params:` / `--set` / `$include` / `data:` already
  cover.

- **Q6 — Distribution.** Native binary only, or also a wasm CLI? A wasm target
  would favor a lighter backend (`moon-xlsx`) via the ADR-002 seam.
  ✅ **Decided (ADR-027):** both. The module is published to mooncakes and
  `moonx` runs the CLI as wasm. The current backend builds for wasm unchanged,
  so no lighter one is needed.

- **Q12 — Can a spec say "this one cell is an exception"?** ✅ **Decided
  (ADR-018): yes, as a top-level `overrides:` layer.** Raised from downstream by
  [yxl-vscode](https://github.com/t-ujiie-g/yxl-vscode) (#66), whose governing
  rule is that a spec it writes must build with a stock `yxl build` — so
  anything a grid editor needs and the schema cannot say has to be settled here,
  before the freeze. A grid edit on a cell fed by a parameter, a refreshed CSV,
  or a `formulas:` range maps to no change that keeps the spec's structure, and
  the three available answers (inline the parameter, split the range, stop
  reading the column from the file) each spend the compression the format exists
  for on one cell.

- **Q11 — A chart series with no `categories:`, which §12 promises and the
  backend refuses.** Found while writing the chart-extraction tests
  (§11, 2026-08-08), and it is **a spec the schema accepts and the compiler
  cannot build**:

  ```console
  $ yxl build chart.yxl.yaml -o out.xlsx
  yxl: Excel backend: InvalidSheetOperation(msg="chart series categories/values must be non-empty")
  ```

  `emit/chart.mbt` passes `""` for an absent categories range — the comment
  there says the backend "spells that as an empty string" — and
  `ChartSeries::new` raises on an empty one. So the case §12 documents ("Without
  it Excel numbers the points 1, 2, 3, …") has never worked. `extract` is
  unaffected: a file always stores a `c:cat`, so nothing recovered lands here.

  Three ways out, and it is a schema decision rather than a bug fix, which is
  why it is a question:
  1. **Make `categories` required** in the loader, refused by name with the
     reason, and say so in §12. Honest and small; costs a case Excel itself
     allows.
  2. **Emit a synthetic categories range** — the values' own range, or a count
     — so the numbering happens without the author naming it. Cheap, and a
     guess: the chart would carry a range the spec never wrote.
  3. **Upstream**: `ChartSeries::new` has no reason to refuse an empty
     `categories` when `<c:cat>` is optional in ECMA-376 §21.2.2.24. The
     smallest correct fix, and the slowest.

  Decide before the schema freeze (§6, v1.0 gate) — after it, option 1 is a
  breaking change rather than a correction. Until then §12 says the key is
  required in practice.

  **The 2026-08-09 sweep makes option 3 look better than it did.** The icon-set
  defect ([office.mbt#403](https://github.com/moonbitlang/office.mbt/issues/403), §9) is the same shape read the other way: there,
  the schema trusted the set of values the backend *accepts* and three of them
  turned out to be wrong; here, the schema is narrower than Excel because the
  backend refuses something Excel allows. Both come of treating the backend's
  acceptance as the definition of what is legal, when the definition is
  ECMA-376. That argues for fixing the boundary upstream where the boundary is
  what is wrong, rather than writing our own — and option 3 is not "the
  slowest" now that we are filing there routinely and the other four workarounds
  are already queued behind a release.

## 9. Risks

- ~~**Windows cannot build, and it is not our code.**~~ **Closed 2026-09-09**:
  `mbtexcel@0.1.10` restructured the function, the Windows runner built, tested
  and compiled every example on the first try, and the leg now **blocks** in
  both `ci.yml` and `release.yml` — README and `install.ps1` no longer call the
  platform experimental. Kept for the shape of the problem, which will recur the
  next time a dependency grows a very large function.
  `moon build --target native`
  failed on the Windows runner with MSVC's `C1026: parser stack overflow`, in
  `bobzhang/mbtexcel`'s `eval_function` — one ~4900-line function matching 365
  formula names, which lowers to an if/else chain deeper than `cl.exe`'s parser
  stack. It is a formula *evaluator* yxl never calls (§2: Excel computes), but it
  is in the same package as everything we do use, so it compiles into every
  binary that touches the backend. We had been sitting just under the limit;
  whatever landed next was going to cross it — 4 917 lines in 0.1.8, 5 054 in
  0.1.9, the margin moving the wrong way. **`mbtexcel@0.1.10` restructured it**:
  `eval_function` is now a five-arm `match` over the reference-shaped builtins
  plus two hash-map lookups (`raising_handlers()` / `plain_handlers()`), with
  the families split into
  `formula_builtins_eval_{math,text,stats,lookup,date_time,logical,info}.mbt`.
  The if/else chain C1026 was counting is gone, and MSVC agrees: every step of
  the Windows leg passed — including `Build CLI (native)` and `Test (native)`,
  the two that used to die — which is what promoted it to a blocking leg.
  Neither way round works from here. `clang-cl` is the documented substitute and
  gets past C1026, but `moon`'s Windows native path then fails to spawn its own
  toolchain (`CreateProcessW`, whatever form the name is given in). The LLVM
  target is closed off for a different reason: the backend depends on
  `moonbitlang/async`, whose `raw_fd` declares `supported_targets = "-all+native"`.
  The mitigation while it lasted was to make Windows *experimental* — its CI leg
  ran and reported without blocking, a release shipped without it, and the README
  and `install.ps1` said so. The fix was always upstream: that function wanted
  splitting.
  **Reported 2026-08-09 as [office.mbt#402](https://github.com/moonbitlang/office.mbt/issues/402)**, with the measurement, both dead
  ends above, and a suggested split along the families
  `formula_builtins_financial.mbt` and `formula_builtins_stats.mbt` already use
  — which is roughly the shape the fix took.

- ~~**The backend's auto filter has a front door too narrow for Excel's own
  files.**~~ **Closed 2026-10-14** by `mbtexcel@0.3.0`'s
  `set_auto_filter_values`, which takes the value list as data; the schema
  work is the "Not scheduled" auto-filter entry in Phase 9. Kept for the record: Criteria reach `set_auto_filter` only as an expression string, and
  two defects in that one seam put the common case out of reach. **One is now
  fixed** (see the note below); the other is not. The grammar is
  exactly three tokens, or seven with a conjunction, so a checkbox list of three
  values (`x == EMEA or x == APAC or x == AMER`) is refused with
  `InvalidAutoFilter` — while the writer emits one `<filter>` per value and the
  reader parses as many as it finds, so a five-value list reads back correctly
  and cannot be written. And the tokenizer keeps a quoted token's quote
  *characters*, so `x == "North America"` reaches the file as
  `<filter val="&quot;North America&quot;"/>` and matches nothing in Excel.
  Together they mean the schema `extract` would need in order to write back what
  it can already read is unbuildable, which is #71's asymmetry again.
  **Reported 2026-08-24 as
  [office.mbt#477](https://github.com/moonbitlang/office.mbt/issues/477)** (the
  quoting, with the emitted XML) **and
  [office.mbt#478](https://github.com/moonbitlang/office.mbt/issues/478)** (the
  two-value ceiling, asking for the structured `AutoFilterColumn` rather than a
  wider grammar, since a compiler holds the typed criterion and only formats a
  string for a parser to take apart again). Nothing is worked around here: the
  schema stays as it is until one of them lands, and `docs/spec.md` §10 says
  criteria are not expressible. Shipping the backend's subset was considered and
  rejected — a schema that refuses a value with a space in it is worse than one
  that has no criteria at all.
  **`mbtexcel@0.1.10` fixed #477**: the tokenizer no longer keeps a quoted
  token's quote characters, and an unclosed quote raises rather than silently
  mangling the value. #478 stood through `0.2.0`, and `0.3.0` answered it with
  the structured entry point the report asked for.

- **`extract`'s self-check compares cells, not their looks.** It recompiles the
  spec it wrote and asserts the *values* match, so a styling inference that goes
  wrong is reported only if it changed a value — and a loss that happens inside
  the reader is invisible to it either way, since the recovered model is the
  baseline it compares against (that is how #71 stayed silent). Widening it to
  compare the effective style of every cell needs a corpus first: the reader
  normalizes (theme colours to RGB, formats to their written codes), so an
  innocent difference would start failing extractions of real workbooks. Until
  then the honest statement is the one `docs/spec.md` §22 makes — what the
  schema cannot carry is listed, and the check is on cells.

- **Backend API churn (mbtexcel 0.1.x).** Mitigation: pin the version; the
  ADR-002 seam contains the blast radius.
- **Heavy / native-only transitive deps.** Mitigation: accept the native target
  (ADR-003); the seam keeps a lighter backend possible later.
- **YAML parser availability.** Mitigation: seam the parser; start with a minimal
  subset and widen.
- **Scope creep into a full spreadsheet library.** Mitigation: the §2 non-goals;
  lean on `mbtexcel` for Excel features rather than reimplementing them.
- **A workbook Excel refuses to open cleanly.** The classic xlsx-writer failure:
  output that our own round-trip tests read back happily, and that Excel greets
  with a repair dialog. Nothing in CI caught it — Tier 1 re-opens with the same
  library that wrote the file, which cannot disagree with itself. **Mitigated**:
  the Open XML SDK's validator now runs over every `examples/` output in CI
  (§5, and the §11 entry for what it found). What it does *not* cover is a
  workbook that is schema-valid and still wrong — the pivot in the wrong place,
  the `#SPILL!` below — so the manual Tier-3 pass stays at the v1.0 gate.
  Raised in the post-v0.1.0 review.
- ~~**A line feed in an attribute value is written raw, so a two-line table
  heading breaks the workbook.**~~ **Closed 2026-10-14** by `mbtexcel@0.3.0`,
  whose attribute escaper writes `&#10;`, `&#13;` and `&#9;`;
  `emit/table_test.mbt` pins it on the table part, since the validator cannot
  see this class. `docs/spec.md` §3's warning is gone. Kept for the record: Both attribute escapers — `xlsx/write.mbt`'s
  `LimitedXmlBuilder::write_xml_attr` and the shared `@ooxml.escape_xml_attr`,
  whose own comment calls itself *"a single implementation to keep correct"* —
  handle `& < > " '` and stop, so a tab, line feed or carriage return goes out
  unescaped. XML 1.0 §3.3.3 then has every parser replace it with a space, and
  the value read back is not the value written. A table is where that turns
  into a broken file: `<tableColumn name>` is an attribute and loses the break,
  the header *cell* keeps it (element text goes through `write_t_element`,
  which does add `xml:space="preserve"`), the two stop matching as ECMA-376
  requires, and Excel offers to repair. Elsewhere it is silent — a validation's
  `prompt:`/`error:` simply arrives with spaces where the author wrote breaks.
  **Reported 2026-09-11 as
  [office.mbt#533](https://github.com/moonbitlang/office.mbt/issues/533)**;
  ours is [#86](https://github.com/t-ujiie-g/yxl/issues/86). Both escapers
  stopped at the five through `mbtexcel@0.2.0`.

  **Not worked around**, on the same reasoning as the `<dxf>` order below: the
  fix upstream is three character references, and refusing a line break in a
  header cell would cost a documented feature (§3 tells authors to write `\n`)
  for however long that takes. `docs/spec.md` §3 warns instead.

  **And this one CI cannot see.** Unlike the `<dxf>` defect, the Open XML
  validator passes the file — `name` is a plain string in the schema, and
  "a `tableColumn`'s name matches its header cell" is a semantic rule it does
  not check. So there is no corpus row to add and no waiver to expire: the only
  automatic guard would be a check of our own that parses the emitted parts and
  compares the two. Not written, deliberately — it is one more mechanism to
  carry for a defect with a small, likely-quick fix upstream — but that is the
  reason this entry is longer than the defect deserves. If #533 goes quiet, a
  check is the answer.

- ~~**A conditional look with a number format or an alignment makes Excel throw
  away every style in the workbook.**~~ **Closed 2026-10-14** by
  `mbtexcel@0.3.0`, which writes `<dxf>` in CT_Dxf order. The waiver failed as
  designed — the run no longer made the finding it expected — and is gone from
  `known-defects.txt`, which is now empty; `conditional-dxf-order.yxl.yaml`
  stays as the corpus row that pins the order. `docs/spec.md` §10's warning is
  gone. Kept for the record: `<dxf>`'s children go out as `numFmt,
  font, fill, border, alignment`, where `CT_Dxf` (ECMA-376 §18.8.14) is the
  sequence `font, numFmt, fill, alignment, border, protection`. `styles.xml` is
  load-bearing, so Excel does not ignore the part — it calls the workbook
  damaged and repair discards the whole stylesheet, taking every ordinary
  cell's fill, border, font and number format with it. **Reported 2026-09-10 as
  [office.mbt#531](https://github.com/moonbitlang/office.mbt/issues/531)**;
  ours is [#81](https://github.com/t-ujiie-g/yxl/issues/81), which has the
  bisection.

  **Nothing is worked around here, by decision** — the combination is refused
  neither in the loader nor in `emit`, because the fix is one reordering
  upstream and a refusal would cost a real feature (a currency-formatted
  highlight) for however long that takes. `docs/spec.md` §10 warns instead, and
  names the trap: a base style carrying `align:` reaches a conditional look
  through `extends:` without the author writing the word.

  **Why the corpus did not catch it, which is the part worth keeping.** A
  `<dxf>` holding only a font and a fill comes out in a legal order by
  accident, and that is every look in `conditional.yxl.yaml`'s twenty-four
  rules. The validator sees the defect the moment a `format:` or an `align:`
  joins them — this was found by hand, not by CI, which is the icon-set lesson
  a second time: *the corpus judges only the variants something actually
  builds.* `tests/validity/conditional-dxf-order.yxl.yaml` now builds exactly
  that combination, waived by name so the waiver fails the day upstream fixes
  it.

- ~~**A comment's VML shape style carries a value with no property name.**~~
  **Closed 2026-10-14** by `mbtexcel@0.3.0`, which writes `margin-left:` and
  `margin-top:`. The form-control writer has the same slip (Phase 9's
  "Not scheduled"). Kept for the record:
  `write_comments_vml.mbt:95` writes
  `style="position:absolute;73.5pt;width:108pt;…"` — `margin-left:` and
  `margin-top:` are missing, so `73.5pt` is not a declaration at all. Excel
  positions a note from `<x:Anchor>` rather than from the VML, so Excel for
  Mac 16.x shows it correctly and offers no repair; a stricter consumer need
  not. The same file writes the header/footer image case correctly (line 873),
  so it is a slip in the comment path alone. Every comment also shares one
  fixed geometry, with only `<x:Anchor>` varying. **Reported 2026-09-10 as
  [office.mbt#532](https://github.com/moonbitlang/office.mbt/issues/532)**;
  ours is [#82](https://github.com/t-ujiie-g/yxl/issues/82).

- ~~**The backend writes a table slicer's cache extension in the wrong
  namespace.**~~ **Closed 2026-09-21** by `mbtexcel@0.2.0`, which writes
  `x:ext`; `modular.xlsx` validates clean and its waiver is gone from
  `tools/openxml-validator/known-defects.txt`. Kept for how it was found and
  how it ended. `write_slicer_cache_xml_table` made x14 the part's default
  namespace and then wrote a bare `<ext>`, which therefore landed in x14;
  `CT_SlicerCacheDefinition`'s `extLst` is typed `x:CT_ExtensionList`, so the
  child has to be `x:ext`. The `x` prefix was already declared on the root
  element, so the fix upstream was the prefix — and that is the fix that
  shipped. The pivot-backed slicer was never affected: there the part's default
  namespace *is* the main one, which is why the backend's own demos validated
  and nothing upstream showed it. Found by the validity check on its first run,
  **reported 2026-09-11 as
  [office.mbt#535](https://github.com/moonbitlang/office.mbt/issues/535)** with
  that note, and closed upstream on 2026-09-14. The waiver expiring is what
  told us: the run failed for a finding the list expected and no longer saw.
- **Three icon sets wrote a workbook Excel offers to repair, and had since
  Phase 9.** `3Stars`, `3Triangles` and `5Boxes` were added in Excel 2010 and
  live only in the `x14` extension schema; the base `ST_IconSetType`
  (ECMA-376 §18.18.42) has seventeen values and none of them. The backend
  accepts all twenty and writes the name straight into the base
  `<iconSet iconSet="…">` attribute, and `model/decorations.mbt` had copied its
  list — so the schema inherited a defect by trusting the wrong definition of
  legal. Found by building one workbook per accepted name and validating all
  twenty; the shipped corpus uses exactly one icon set, which is why nothing
  caught it (§5). **Reported as [office.mbt#403](https://github.com/moonbitlang/office.mbt/issues/403)** — the machinery is already
  there, since `write.mbt` writes the same `x14:conditionalFormattings` block
  for advanced data bars. **Fixed here** by refusing the three by name with the
  reason, as the shape presets are: `model/decorations.mbt` keeps them in
  `unwritable_icon_set_styles`, apart from the seventeen, and a test pins that
  the two lists stay disjoint and add to twenty. `extract` names them apart
  too, since "the spec does not name it" would send a reader hunting a typo.
  **Closed upstream 2026-10-08 by refusal**: `mbtexcel@0.3.0` rejects the three
  rather than writing their `x14` form, so the refusal here is now the
  backend's too and stays. The diagnostic no longer says to wait for a fix.

- **A link into a sheet whose name needs quoting went out unquoted.**
  `links: { A2: { to: "Q1 Sales!B2" } }` reached the file as
  `location="Q1 Sales!B2"`, and Excel answers a click with "Reference isn't
  valid". Every other cross-sheet reference is right — charts, validation
  sources and sparklines all emit `'Q1 Sales'!$A$2:$A$4` — because each travels
  as a parsed range; a link's target is the one that stays a `String` from the
  spec to the file, since it may equally be a defined name. The loader already
  splits and checks the sheet prefix (`location_sheet`, sharing
  `split_sheet_prefix`'s quoting rules), so the fix is to re-render rather than
  echo. **The validator cannot see this class**: `location` is a plain string
  in the schema, so the file is valid and only Excel disagrees. Ours, not the
  backend's. **Fixed** in `loader/decorations.mbt`'s `canonical_location`,
  which quotes unconditionally — the same choice `emit`'s `quoted_sheet` makes
  for every chart range, since `'Statuses'!A1` is as valid as `Statuses!A1` and
  one rule beats a second implementation of Excel's "is this a bare word" test.
  A target naming no sheet is a defined name and is untouched.

- **A split pane was dropped on the way back, and silently.** `docs/spec.md`
  §22 promises frozen *and split* panes are recovered; a split one came back
  as nothing at all, with no line in the loss report — the one thing §22's
  contract forbids. The cause is upstream: `Worksheet::get_panes`
  (`xlsx/sheet_view_ops.mbt:100`) derives `freeze` from `state == "frozen"` and
  never sets `split`, so a split pane reads back as neither, and
  `read/layout.mbt`'s `else if panes.split && …` falls through. **Fixed here**
  rather than waited on: a `<pane>` that exists and is not frozen is a split
  one, which is the same conclusion by a route the backend's reader does not
  block. Unreported; part of the reader batch above.

  **And behind it, a second defect the first one was hiding.** With the branch
  reachable at last, `split: { x: 3000 }` came back as `x: 60000`. The
  schema speaks points and OOXML stores a split position in twips
  (ECMA-376 §18.3.1.66); `emit` multiplied by twenty and `read` never divided,
  and no test could see it because the branch it lived in never ran. A freeze
  is unaffected — there `xSplit` is a count of columns, not a measurement,
  which is why the two branches convert differently. The lesson is the one §5
  now carries: a code path no corpus reaches is not tested, it is merely
  unaccused. Three round-trip tests pin it, including the sheet with no panes
  at all, since the widened branch must not invent one.

- **Backend defects that only Excel reveals.** ~~Phase 9's pivot tables shipped
  round-trip-green while Excel showed `#SPILL!`~~ — **both halves fixed in
  `mbtexcel@0.1.10`** ([office.mbt#264](https://github.com/moonbitlang/office.mbt/issues/264)):
  `<location>` is derived from the axes a pivot uses (`PivotLocation`, citing
  [MS-OI29500] on 18.10.1.49 for why the filter block sits above `ref`), and
  `Workbook::next_pivot_cache_id` hands each pivot its own id. `filters:` and a
  second source are accepted (`docs/spec.md` §14); the emit tests assert the
  `axisPage` field, the page counts, and two distinct cache ids.
  The *lesson* is what stays: nothing in the round trip could see either, since
  the backend's reader agrees with its own writer. Mitigation is the one listed
  below — automate "does Excel open it cleanly" — and, until then, **open the
  output by hand when a feature writes a part Excel interprets rather than
  displays**.
- ~~**The backend writes `<workbookProtection>` out of schema order.**~~
  **Fixed in `mbtexcel@0.1.10`**, which emits it before `bookViews`; top-level
  `protect:` is accepted again, an emit test pins the order against the part's
  text, and the Open XML validator agrees. Kept here for what it cost.
  `write_workbook_xml.mbt` emitted it after `</sheets>` and `definedNames`,
  while CT_Workbook (ECMA-376 §18.2) wants it *before* `bookViews` — and a
  schema violation in workbook.xml makes Excel report the whole file as
  corrupt, repair refused. Shipped broken in the first slice's protection and
  caught only when the second slice's manual-check obligation finally opened
  a workbook that used it: every Excel-opened example until then happened not
  to carry workbook-level `protect:`. Found by feature-bisecting the failing
  workbook with eleven probe files rather than re-reading code. Worked around
  by refusing top-level `protect:` by name with the reason; sheet protection
  and `locked: false` styles are unaffected and verified clean. The lesson
  already in this section stands sharper: *a feature is not done until Excel
  has opened it* — the round trip cannot catch what the reader tolerates.
- ~~**The backend lowercases a shape's `prst` token.**~~ **Fixed in
  `mbtexcel@0.1.10`** (#293 released): the token is written as given, the twelve
  camel-case geometries are plain schema entries, and the validity corpus builds
  one of each. The two reader defects found alongside are *not* fixed and stay
  in the reader batch below. What it was: `write.mbt` passed
  `shape_type` through `.to_lower()`, and DrawingML's `ST_ShapeType` enum is
  case-sensitive — `roundRect` written as `roundrect` is a geometry Excel does
  not recognize. Found in Phase 9's shapes by dumping the drawing part rather
  than trusting the round trip (the backend's own reader accepts what its
  writer produced, so tests stay green). Worked around by accepting only
  presets whose token has no capitals and refusing the rest by name; a test
  pinned the no-capitals invariant
  ([office.mbt#293](https://github.com/moonbitlang/office.mbt/issues/293)).

- **Seven things the backend's reader does not hand back**, each turning a
  detail of a real workbook into a line in `extract`'s loss report
  (`docs/spec.md` §22 lists the user-visible ones). A text run written with
  `xml:space="preserve"` is invisible to the shape reader (it splits on the
  literal `<a:t>`); no shape run's font (`a:rPr`) is read back; a form
  control's size is lost because only the near corner of its VML anchor is
  read; a picture's scale always comes back as `1`; a sparkline's data range
  loses its sheet qualifier, so a sparkline that plotted another sheet comes
  back plotting this one; `customWidth="0"` is not read, so a default-width
  column rebuilds as an explicitly sized one and Excel stops auto-fitting it;
  and a border's theme colour has no field to arrive in, which is
  indistinguishable from an Excel-default border and so cannot even be
  reported. None is a correctness risk in a *written* workbook — they cost
  `extract` fidelity, and they cost tests the ability to assert on what was
  written. **All unreported**; one batch, or seven small ones, after
  #401–#403.

- **A sparkline's first / last / negative markers cannot be set, and the
  `.mbti` says otherwise.** `SparklineOptions` — what `add_sparkline` takes —
  declares the three without `mut` and offers no setter, which is the half that
  was already written down. What 0.1.10 added to the picture is
  `SparklineGroupOptions`, whose generated interface shows `mut first`,
  `mut last`, `mut negative`, reachable through `Worksheet::sparkline_groups()`
  — so there looked to be a way in after all. There is not: assigning to one is
  a compile error, *"Cannot modify a read-only field: first"*, because the type
  is read-only outside its own package. **This is the second time the `.mbti`'s
  shape has suggested a mutability that the compiler refuses** — `Style` is the
  other (below) — so the rule for this backend is that a `mut` in the interface
  is not a promise, and the only way to know is to compile the assignment.
  Verified 2026-09-11 with a throwaway package rather than by reading, and
  again on 2026-09-21 against `mbtexcel@0.2.0`, which changes neither type.
  Not reported: it is one of a family of small gaps, and the upstream batch
  was kept to three.

- ~~**A style cannot carry both a number format and cell protection.**~~
  **Closed 2026-10-14** by `mbtexcel@0.3.0`'s `with_number_format` and
  `with_protection`; `emit/style.mbt` seeds from the format and layers
  protection like the rest, the refusal and `docs/spec.md` §16's note are gone,
  and `cells.yxl.yaml`'s `boxed` style carries both so the validator judges the
  pair. Kept for the record: The
  backend builds a `Style` by seeding it from one attribute and layering the
  rest with `with_*` builders, and it has `with_font`, `with_fill`,
  `with_border` and `with_alignment` but no `with_protection` and no
  `with_number_format` — so whichever of the two is not the seed cannot be
  set. Constructing the record directly is closed off as well: `Style` is a
  read-only type outside its package (*"Cannot create values of the read-only
  type"*), which was worth checking, because the shape of the `.mbti` suggests
  otherwise. The cost is a real combination — the unlocked entry cell that is
  also formatted as currency, which is what a protected form is made of.
  `emit/style.mbt` refuses it with a diagnostic that names the number format
  and says to split it into its own style, and layering (§4 of the spec's
  styling keys) makes that work in practice. **Reported 2026-09-11 as
  [office.mbt#534](https://github.com/moonbitlang/office.mbt/issues/534)**; the
  fix upstream is two builders beside the four that exist.
- **MSVC cannot compile the backend's formula evaluator.** MoonBit's native
  backend hands each test executable to the platform C compiler as one
  translation unit, and `mbtexcel`'s formula dispatch — a `match` with a
  thousand-odd arms — becomes C that MSVC refuses with `fatal error C1026:
  parser stack overflow, program too complex`. It only bites when the evaluator
  survives dead-code elimination, which happens the moment anything in a package
  reaches it, however indirectly: `Workbook::get_pictures` does, because it also
  looks for images *inside* cells. Ubuntu and macOS (clang) compile it fine, so
  this shows up as a Windows-only CI failure in a package whose own code never
  mentions formulas. Mitigation for now: prefer the backend reader that matches
  what `yxl` actually emits (`Worksheet::images()`, `Worksheet::charts()`) over
  the whole-workbook lookups. If a needed API drags the evaluator in, the escape
  hatch is `MOON_CC=clang-cl` on the Windows runner. Found in Phase 9's images.
- **The YAML parser decodes `\r` as the wrong character.** Its escape table
  writes decimal code points where hex is meant, so a double-quoted `\r` loads
  as U+13, `\v` as U+11, `\f` as U+12, `\e` as `'`, and `\ ` as `2`; `\n`,
  `\t`, and the `\xXX`/`\uXXXX` forms are decoded right
  (`moonbit-community/yaml@0.0.6`, `lexer.mbt`'s
  `resolve_flow_scalar_escape_sequence`; reported 2026-08-09 as
  [yaml.mbt#14](https://github.com/moonbit-community/yaml.mbt/issues/14), and still present on that repository's `main`). Our
  writer never emits the broken spellings — every control character but `\n`
  and `\t` goes out as `\u00XX` — so extracted specs are unaffected.
  **Three of the five are now repaired at our parse seam** (#45): U+11, U+12
  and U+13 map back to `\v`, `\f` and `\r`, which is exact rather than a guess
  because YAML forbids an unescaped C0 control in a source document, so nothing
  else can produce one. `\e` and `\ ` remain broken and unrepairable — they land
  on `'` and `2`, which any scalar may hold, so there is nothing to tell an
  escape from an ordinary character. Write `` and ` ` for those. Revisit the
  pin when upstream fixes the table.
- **A headline the schema does not earn.** §1 leads with "diffable", and a
  `cells:`-keyed spec is not, under row insertion. Mitigation: Phase 11's inline
  tables, before the schema freeze; until then §1 and the README say which half
  is delivered.

---

## 10. How to "進める" (pick the next task)

1. Read this file top-to-bottom the first time; thereafter jump to §6.
2. Find the **active phase** — the first phase with an unchecked `[ ]` box.
3. Pick the **next unchecked item** in that phase (top-to-bottom order).
4. If the item is unclear or seems to widen scope beyond the phase, **stop and
   ask** rather than silently broadening it.
5. Implement it end-to-end: code + Tier-1 tests (incl. golden/round-trip where a
   boundary is crossed).
6. Run the validation loop (AGENTS.md §4): `moon check` → `moon test` →
   `moon fmt` → `moon info` → `moon build --target native`.
7. In the **same change**: tick the box, append a §11 changelog entry, and add
   an ADR (§7) if you made an architectural decision.

## 11. Living changelog

Reverse-chronological. One entry per user-visible or structural change.

- **2026-10-15** — **0.6.1.** A patch release carrying the output-directory
  fix below. The version moves to `0.6.1` in `moon.mod` and in `yxl version`,
  and the READMEs' `moonx …@<version>` lines follow, ready for the `v0.6.1` tag
  and its mooncakes publish (ADR-027).

- **2026-10-15** — **`extract -o` and `init -o` make the output's directory.**
  `yxl extract book.xlsx -o specs/book.yxl.yaml` failed with "cannot write"
  when `specs/` did not exist yet, though the companion files beside it were
  already written into fresh directories, and `build` already made its output's.
  Every file the CLI writes now creates the directory it sits in first.

- **2026-10-14** — **Docs: yxl on mooncakes.** 0.6.0 is published (ADR-027),
  and both READMEs now say how to use it from there: `moonx
  t-ujiie-g/yxl/cmd/main@0.6.0 build …` with no install, and `moon add
  t-ujiie-g/yxl` for `@cli.compile` / `@cli.extract`. Each was run against the
  published package first. The READMEs' package table predated ADR-028 and
  read as if every package were public; it now shows the three that are and
  the `internal/` rest.

- **2026-10-14** — **0.6.0: `mbtexcel@0.3.0`, filter criteria, and the first
  mooncakes publish.** The version moves to `0.6.0` in `moon.mod` and in
  `yxl version`, ready for the `v0.6.0` tag. The release carries the backend
  bump and everything it closed (#81, #82, #86), filter criteria (#75), print
  setup and shape-text fonts in `extract`, and the comment pass. The skills
  follow: `extract-to-spec` no longer tells an agent to re-add `print:` by hand
  and keeps a filter's hidden rows under `rows:`; `yxl-authoring` lists the
  refusals as they now stand. It is also the first version published to
  mooncakes (ADR-027).

- **2026-10-14** — **`extract` recovers print setup and shape-text fonts.**
  Both were blocked on the backend and `mbtexcel@0.3.0` unblocked them, which
  re-probing the Phase 10 "Blocked" list against the new version found. A
  sheet's `print:` now comes back — area, orientation, margins, scale or fit,
  header, footer, breaks — with Excel's defaults left out and what §5 cannot say
  named; two sheets are read independently, the leak that kept it refused. Each
  line of shape text keeps its font. A third item on that list, a capitalised
  geometry, had been fixed since `0.1.10` and the list had not caught up. Still
  blocked against `0.3.0`, each re-checked: a form control's size, a
  sparkline's source sheet, a column's `customWidth` (#51), a border's theme
  colour (#50), and a truly blank styled cell (#48).

- **2026-10-14** — **Refactor: comments brought within AGENTS.md §8.6.** The
  pass after the `0.3.0` bump found no dead code, duplicate constant or
  oversized file worth splitting; what it found was 142 comment blocks over
  four lines, most of them restating `docs/spec.md` or narrating how a line came
  to be. Each is now the one or two lines the code cannot say. The two left
  describe print setup, which `0.3.0` makes readable and the next change
  replaces. Three comments were wrong rather than long: a read note still said
  the three x14 icon sets make Excel repair the file, a test said no zip reader
  was available (there is one, and the sheet-password test now asserts the hash
  in the part), and the hash it quoted was not the one written (`DBFF`, the
  legacy hash of `s3cret`, not `F533`).

- **2026-10-14** — **`mbtexcel@0.3.0`, and every upstream report closed.** The
  backend pin moves from `0.2.0` (and `flate` to the `0.9.0` it requires). What
  that changes for a spec:
  - **a style may carry a number format and cell protection together** — the
    unlocked currency cell a protected form is made of. The build no longer
    refuses it (office.mbt#534).
  - **a conditional look may have `format:` and `align:`** without Excel
    discarding the workbook's styles (#531). The last Open XML waiver is gone;
    `known-defects.txt` lists nothing.
  - **a line break in a table's header cell is safe**, and a validation's
    `prompt:`/`error:` keeps its line breaks (#533).
  - **form controls are drawn by Excel**: the backend now writes `<controls>`
    and the `ctrlProps` parts (#401).
  - a comment's VML style is well-formed (#532).
  - `3Stars`, `3Triangles` and `5Boxes` stay refused — upstream closed #403 by
    refusing them too — and the diagnostic no longer says to wait for a fix.
  - #478 gave the auto filter a structured value list, so per-column criteria
    (#75) are now a schema task rather than a backend one; not built here.

  `docs/spec.md` loses the four warnings these made necessary. Found on the
  way: the form-control VML has #532's slip (`form_control.mbt:460`), noted in
  Phase 9 and reported as office.mbt#649.

- **2026-10-12** — **CLI: `-o` is optional, and so is the spec (ADR-029).** A
  spec's new top-level `output:` key says where it builds to
  (`output: dist/report-${month}.xlsx`), resolved against the spec. Without
  either it, or `-o`, `yxl build report.yxl.yaml` writes `report.xlsx` beside
  the spec. A bare `yxl build` builds the one `*.yxl.yaml` in the current
  directory. Missing output directories are created. `yxl init`'s starter now
  says `yxl build sheet.yxl.yaml`, and `examples/workbook.yxl.yaml` names its
  file by month. Existing command lines work as before.

- **2026-10-11** — **Docs: what happens when bands on one axis overlap (#109).**
  `docs/spec.md` §4 now states the rule the compiler already followed. A later
  band's `style` / `format` replaces an earlier one's whole. `width`, `height`,
  `hidden` and `group` are replaced one key at a time. To add to an earlier
  look, name it in `defs.styles` and `extends` it. The text was silent here,
  and yxl-vscode's preview had read it the other way (yxl-vscode#208).

- **2026-10-11** — **Structure: the compiler packages are `internal`
  (ADR-028).** `diag`, `units`, `yaml`, `model`, `loader`, `emit`, `read` and
  `render` moved under `src/internal/`, so once yxl is on mooncakes no other
  module can depend on them. The resolver types `@cli.compile` takes moved from
  `loader` to a new public `include` package. Code that called
  `@cli.compile(…, resolve=…)` now builds `@include.Included` instead of
  `@loader.Included`. A new CI step compiles every example with the wasm build
  and checks that the bytes match the native binary's. `moon.mod` now names the
  repository. The CLI is unchanged.

- **2026-10-10** — **Decision: yxl will be published to mooncakes (ADR-027,
  closes Q6).** `moonx` runs registry packages as wasm. yxl already builds for
  `wasm` with its current backend, and writes the same bytes as the native
  binary for every example. So the module will be published and run that way,
  and the native binary stays the primary distribution. The work is a new
  Phase 8 item. Its first publish waits for the next `mbtexcel` release.

- **2026-09-27** — **Docs: a Japanese README.** `README.ja.md` translates
  `README.md`, and each links to the other at the top. `AGENTS.md §1` makes it
  a translation kept in step with `README.md` in the same change, not a new
  home for documentation; `docs/spec.md` stays English-only. Both READMEs
  now list the Windows x86_64 binary every release has shipped since 0.4.0.

- **2026-09-24** — **Fix: a footer's `order: asc` sorts text by code point
  (#104).** Group values were compared with `String::compare`, which puts the
  shorter string first and only then compares UTF-16 code units, so `b` came
  before `aa`, and U+10000 before U+E000. They are now compared code point by
  code point, as `docs/spec.md` always said; `desc` is the reverse. A spec
  whose `asc` groups mix lengths gets its footer rows in a different order.

- **2026-09-24** — **0.5.0: layouts, and the skills and the reference workbook
  built on them.** The version moves to `0.5.0` in `moon.mod` and in
  `yxl version`, ready for the `v0.5.0` tag.

  The release carries the #89 layout work (ADR-023–026), the `_xlfn.` fix
  (#96), and the width-0 column fix. What changes in this entry is what the
  agents are taught:
  - **`skills/yxl-authoring`**: the default architecture is now built on
    layouts. Every table is a named layout whose rows come from a CSV and
    whose derived columns are `{{name}}` formulas. Totals and subtotals are
    footers, and a column group repeated per item is a block. Shared data is a
    named master that other sheets reach by name, in formulas, drop-downs,
    chart series, `below:` anchors and overrides by `where:`. Row counts are no
    longer `params:`. `cells:` / `data:` / `formulas:` addressed by letter
    remain for what is not a table.
  - **`skills/extract-to-spec`**: rewrites *towards* layouts. Data sheets
    become named layouts fed by a CSV with a header row. A report sheet's
    formula runs become layout columns, merged header rows become header
    levels, repeated column groups become blocks, total rows become footers,
    and cross-sheet addresses become names.
  - **`examples/workbook.yxl.yaml`**, the architecture the skill tells agents
    to copy, is rewritten the same way.
    - The master is the named layout `stores`.
    - Sales reads its month's CSV into a layout that is also declared an
      Excel table, with a drop-down `from: stores.code`.
    - Summary takes only the store *key* from the master's file, looks the
      name up, and totals in a footer. Every store name therefore still
      exists once in the workbook.
    - `sales_rows` / `store_rows` are gone, and so are the over-reaching,
      blank-guarded ranges they needed. The CSVs gained the header rows a
      layout matches by.

- **2026-09-24** — **Refactor after layout names (AGENTS.md §8).** No behaviour
  changes.
  - **Duplication:**
    - `load_layout` built its placed columns and input map twice, once for
      the footer and once for the name record. It now builds them once.
    - Column names and layout names had separate copies of the rule for
      what a name may spell. They share `is_name`.
  - **Split:** `{{name}}` expansion moved to `layout_refs.mbt`. The footer's
    totals, criteria, labels and value order moved to `footer_values.mbt`,
    with the whitebox test renamed to match. This brings `layout.mbt` from 508
    to 448 lines and `footer.mbt` from 537 to 291.
  - **Comments:** the two doc comments over three lines in this series' files
    now point at ADR-026.
  - **Not done here, and worth a pass of its own:** across the whole tree, 348
    comment blocks run past the three-line rule. Most predate the rule, in
    `expect.mbt`, `decorations.mbt` and the `read` package. Rewriting them
    means deciding each one's rationale, which a mechanical refactor
    shouldn't.

- **2026-09-24** — **Fix: a newer function opened as `#NAME?` (#96).** A
  formula went to the file exactly as the spec wrote it. Excel reads a
  function added after the file format, such as `XLOOKUP`, `FILTER` or
  `MAXIFS`, only under its stored name `_xlfn.XLOOKUP` (`_xlfn._xlws.FILTER`
  for the two worksheet functions), so those cells opened as `#NAME?`.
  - `@model.to_file_formula` now adds the prefix at every place `emit` hands
    a formula to the backend: cells, formula ranges, conditional formats and
    defined names.
  - `@model.from_file_formula` takes it off wherever `read` recovers one, so
    an extracted spec reads like the formula bar.
  - Both walk the formula's function calls only. They skip string literals
    and quoted sheet names, and they rename only names on one list, kept
    once in `model`. A name already prefixed, and one Excel spells as an
    operator (`_xlfn.SINGLE` is `@`), pass through unchanged.
  - The footer's `min` / `max` now write plain `MINIFS` / `MAXIFS` and leave
    the prefix to `emit`.
  - **Not covered:** `LET` / `LAMBDA` parameter names, which the file stores
    as `_xlpm.x`. Recognizing them needs the formula's argument structure,
    not only its tokens. `docs/spec.md` §3 says so.

- **2026-09-23** — **A layout's name wherever a range or an anchor is read (#89,
  slice 4b, ADR-026).**
  - **Ranges:** a chart series (`values: 店舗.cy`), a validation list
    (`from: 店舗.store`), sparkline data and a pivot source (`source: 店舗`)
    may name a layout on any sheet, before or after the reader.
  - **Ranges on the layout's own sheet:** a table, a validation or a
    conditional format may name it as its `at:`, so a layout can be declared
    an Excel table.
  - **Anchors:** anything anchored at a cell may be `at: { below: 店舗 }`, so
    content under a layout whose length follows its data moves with it.
  - **Table name:** the layout's own name is now also a defined name, for its
    whole table.
  - **How it works:** layouts are placed in a pass ahead of the sheets.
  - **Example:** `examples/subtotals.yxl.yaml` puts a chart under the store
    list and a store drop-down on サマリ, both by name.

  This closes the layout work #89 proposed.

- **2026-09-23** — **A layout reached from outside by name (#89, slice 4,
  ADR-025).** `name: 店舗` on a layout makes each column's body a workbook
  defined name, `店舗.cy` → `'店舗別'!$D$3:$D$8`. A summary on another sheet
  writes `=SUM(店舗.cy)`, or `SUMIFS(店舗.cy,店舗.branch,"東京")`. An override
  finds its cell by meaning, `{ layout: 店舗, column: ratio, where: { store:
  渋谷 } }`, and it must match exactly one row. Overrides now load after the
  sheets, so one written above them still finds a layout. Defined names and
  table names are now checked as one case-insensitive namespace. Two defs
  that differ only in case used to produce a workbook Excel repairs.
  `examples/subtotals.yxl.yaml` gains a サマリ sheet and an override for 渋谷.
  Charts, validation lists and sparklines are left to slice 4b.

- **2026-09-23** — **`defs.blocks`: a group of columns written once (#89,
  slice 3 of ADR-023).** A block declares layout columns, and a layout places
  it with `{ block: yoy, as: sales, header: 売上 }`.
  - The instance's columns are named `sales.cy` and so on.
  - Its `header` sits above theirs and merges across them, through the header
    level rules.
  - Inside the block, `{{cy}}` means the instance's own column first and the
    layout's otherwise. `{{gross.cy}}` reaches another instance.
  - `fields:` maps an instance's input columns to a CSV or JSON source.
  - A footer totals `sales.cy` like any column.
  - The instance compiles to exactly the columns it stands for, so the
    formula, the format and the rule a report repeats per item exist once in
    the spec.

  `examples/columns.yxl.yaml` is now the RFC's own example: two instances of
  one `yoy` block and a totals row. `docs/spec.md` §6 and §25 and the JSON
  Schema are updated.

- **2026-09-23** — **Refactor after layouts (AGENTS.md §8).** No behaviour
  changes. Each lens, in order:
  - **Constants:** nothing to promote.
  - **Duplicate code:**
    - Laying one optional style over another had been written four times:
      band inheritance in `model`, cell styles in `emit`, and header and footer
      cells in `loader`. It is now `@model.Style::layer`, a new public function
      with its own test.
    - The `{{name}}` scanner had been written twice, once for formulas and once
      for footer labels. It is now `substitute_names`, which the two share.
  - **File split:** `layout.mbt` (692 lines) is split at its logical seams.
    Headers moved to `layout_header.mbt`, data rows to `layout_rows.mbt`, each
    with a matching test file. The 272-line `load_layout` became a sequence of
    named steps. `FooterScope`'s `cols` / `inputs` are renamed `col_of` /
    `input_of`, since they sat beside an array called `columns`.
  - **Comments:** three comments were trimmed back under the three-line rule,
    with pointers to `docs/spec.md` §25 in place of restated rules.
  - **Docs:** README and §4 now list `read` and `render`, which had shipped
    with `extract` but were missing from both package tables.
  - **Language:** no deprecation warnings to clear.

- **2026-09-23** — **A layout's footer: totals and subtotals per group
  (ADR-024).** `footer:` places rows straight after a layout's body, so a
  totals row follows the data wherever it ends.
  - A cell may be `{ total: sum | count | average | min | max }` over its
    column's body.
  - A `by:` group repeats its rows once per value of a column, nested to any
    depth. Its totals become `SUMIFS`-family formulas with one criterion per
    enclosing group, so 東京 直営 計 sums the rows of that branch *and* that
    channel.
  - `order:` is `data`, `asc`, `desc`, or a list. A list gives every value it
    names a row, and refuses a value the data holds but the list omits.
  - A label spells group values as `{{branch}}`. That is still `{{name}}`'s
    one meaning, "this row's name", which in text is the value.
  - A row's `style:` spans the layout's width.
  - A cell's `merge_to:` merges it across to a column on its right. So a label
    can stand alone in the first column, or be merged over the columns a
    group leaves empty.
  - `min` and `max` are written as `_xlfn.MINIFS` / `_xlfn.MAXIFS`, the names
    Excel reads.

  Raised by a real workbook that keeps its subtotals beneath its rows.
  `examples/subtotals.yxl.yaml` reproduces its shape. `docs/spec.md` §25 and
  the JSON Schema are updated.

  **Found on the way, not fixed here:** a formula a spec writes itself is
  emitted verbatim. A function newer than the file format, such as `XLOOKUP`,
  `FILTER` or `LET`, therefore reaches Excel without the `_xlfn.` prefix, and
  Excel shows `#NAME?` until the cell is re-entered.

- **2026-09-23** — **A layout reads its own rows (#89, slice 2 of ADR-023).**
  `values:`, `csv:` or `json:` on a layout fill only the columns without a
  `formula`. A derived column is skipped rather than reserved with `null`, so
  adding one touches no data row. The body is as long as the rows read, and
  every formula range and conditional format down it follows a monthly file of
  a different length. `rows:` may still reserve more. A CSV must start with a
  header row, and it is matched by name, like a JSON array of objects: each
  input column takes the field its `field:` names, or its own `name`. Source
  fields can therefore be reordered or added without an edit. `values:` and
  JSON arrays of arrays fill by position. The CSV parser now hands over each
  field's raw text before typing it, so a header row is read as written.
  `examples/columns.yxl.yaml` has lost its `data:` block and its `null`s.

- **2026-09-23** — **A layout's header may have levels.** Opened in Excel, the
  first `layouts:` example exposed the gap: a year-on-year report heads
  `当年 / 前年 / 前年比` with a merged `売上` above them, and a one-row header
  could not say so. A column's `header:` now takes a list, one cell per header
  row, and the layout's header is as deep as its deepest column. Neighbours
  holding the same cell under the same parents merge across. A column with
  fewer levels stretches its last one down, and a stretched cell never also
  merges across. A `null` is a blank that merges with nothing, so a header
  that wants blanks rather than a merge says so. Every cell of the block
  wears `header_style`, the blanks inside a merge included, so fills and
  borders run unbroken. The expanded cell form lays a column's own style over
  it. `examples/columns.yxl.yaml` now has the two-row header that prompted
  this. `docs/spec.md` §25 and the JSON Schema are updated.

- **2026-09-23** — **Fix: a column band with no `width` opened at width 0.**
  The backend writes such a `<col>` with no `width` attribute, and Excel reads
  that as zero, so a band that set only a style, a format, `hidden` or `group`
  hid its column. Found by opening `examples/columns.yxl.yaml` in Excel for
  the web: its ratio columns set a format and no width, and they vanished. No
  earlier example had hit it, because each of their bands also set a width,
  and CI's validator passes the file because the attribute is optional.
  The emitter now gives every column a band touches, and no band sizes, Excel's
  standard width as Excel stores it (`9.140625`: 8.43 characters of Calibri
  11 plus padding, ECMA-376 §18.3.1.13). Only the column appears in the file
  as custom-width. Where the workbook's default font is not Calibri, the
  standard width is a pixel or two off, and a visible column beats an exact
  one.

- **2026-09-23** — **`layouts:` — columns named, not lettered (#89, slice 1
  of ADR-023).** A new sheet key. A layout is an anchor cell, a body row count,
  and named columns that carry their own header, width, style, format,
  formula and conditional rules. Inside a column's formula, `{{name}}` is that
  column's cell in the same row. A layout compiles to the bands, header cells,
  `formulas:` ranges and `conditional:` rules one would write by hand, and the
  bytes are identical: a test in `src/cli` compares both compilations. The
  conditional-format loader now reads its rule apart from its `at:`, so a
  layout's rules go through the same checks, and they report
  `layout 'A2' column 'ratio'` rather than a letter the author never wrote.
  `docs/spec.md` §25, the JSON Schema (and a selftest pair for it), and the
  cookbook's `columns.yxl.yaml` document it.

  **Taken ahead of Phase 10.** Phase 10 is still the first phase with an open
  box, but nothing in it can be acted on now. Performance waits, by its own
  terms, for a workbook that is actually slow, and every open `extract` item
  but one is blocked upstream. That one, reading print setup through
  `Worksheet`'s fields, is independent and in no hurry.

- **2026-09-23** — **Position-free columns are planned (#89, ADR-023).** A new
  Phase 11 item, in four slices: `layouts:` (named columns that carry their
  own formatting and formulas, with `{{name}}` for the same-row cell), rows
  filled from data, `defs.blocks` for a repeated group of columns, and names
  reachable from outside a layout. The RFC's core is kept. Its surface changes
  in six places, and ADR-023 records why for each: names stay out of `at:`,
  `{{…}}` has one meaning, blocks take no arguments, a region is an anchor plus
  a count, `layouts:` is a list, and the expansion runs in the loader. No code
  yet.

- **2026-09-21** — **`mbtexcel@0.2.0`, and one waiver fewer.** The backend pin
  moves to `0.2.0` (and with it `moonbitlang/x@0.5.5` and
  `moonbit-community/flate@0.8.1`, which it requires). The `xlsx` package's
  interface is byte-identical to `0.1.10`'s, so nothing here changed shape;
  what changed is inside one part. **`office.mbt#535` is fixed**: a table
  slicer's cache now writes `x:ext` instead of a bare `<ext>` that landed in
  x14, so `examples/modular.xlsx` validates clean and its entry is gone from
  `tools/openxml-validator/known-defects.txt` — the waiver expiring is how the
  run reported it, which is the behaviour that waiver list was built for.
  `conditional-dxf-order.xlsx` (#531) is now the only one left.

  **Everything else we have open upstream was re-checked against `0.2.0` and
  still stands**: #401 (no `ctrlProps` part, so Excel draws no form control),
  #403 (three icon sets written into the base attribute), #478 (the auto-filter
  grammar still caps a checkbox list at two values), #531 (`<dxf>` child
  order), #532 (a comment's VML style with no property name) and #533
  (attribute values still escape only `& < > " '`). So are the two gaps that
  are shapes rather than bugs — `Style` has no `with_protection` or
  `with_number_format` (#534), and a sparkline's first/last/negative markers
  have no setter — both re-checked by *compiling* the attempt, since this
  backend's `.mbti` has twice suggested a mutability the compiler refuses.

- **2026-09-21** — **Trait-method promotion is declined by name (ADR-022).**
  MoonBit no longer promotes a trait's methods to inherent methods implicitly,
  so every `derive(Eq, Debug)` warned until the package said what it wanted.
  Each package gained an `extends.mbt` that declines all of them — `Eq`,
  `Hash`, `@debug.Debug`, and our own `Emitter` / `Reader` — with
  `#deprecated(…, skip_current_package=true)` and `#doc(hidden)`, because
  nothing calls any of these in method position. `moon check --deny-warn` is
  clean again and every `.mbti` is unchanged, which is the point: a language
  migration whose diff touches no interface is one a reviewer can read. Two
  smaller migrations rode along — the blackbox seam test calls
  `@read.Reader::read(reader, …)` rather than `reader.read(…)`, and
  `model_test.mbt` qualifies `@model.Font`.

- **2026-09-11** — **A newline in a table heading breaks the workbook, and CI
  structurally cannot see it.** Both attribute escapers stop at `& < > " '`, so
  a tab, line feed or carriage return reaches the file raw; XML 1.0 §3.3.3 then
  has every parser turn it into a space. `<tableColumn name>` is an attribute
  and loses the break while the header *cell* keeps it — element text goes
  through `write_t_element`, which does add `xml:space="preserve"` — so the two
  stop matching as ECMA-376 requires and Excel offers to repair. Reported as
  [office.mbt#533](https://github.com/moonbitlang/office.mbt/issues/533);
  ours is [#86](https://github.com/t-ujiie-g/yxl/issues/86). Elsewhere it is
  silent: a validation's `prompt:` or `error:` arrives with spaces where the
  author wrote breaks.

  **Not worked around**, matching the `<dxf>` decision: the fix upstream is
  three character references, and refusing a line break in a header cell would
  cost a feature §3 explicitly tells authors to use. `docs/spec.md` §3 warns,
  and names the escape hatch — a one-line heading, or `filter:` instead of
  `tables:`.

  **What is new here is that the validity gate is no help.** The `<dxf>` defect
  could be pinned with a corpus row and a waiver that expires when upstream
  fixes it; this one passes the Open XML validator outright, because `name` is
  a plain string in the schema and "a column's name matches its header cell" is
  a semantic rule the SDK does not check. So the corpus grew a rule last week
  and still would not have caught this: **there is a third class — valid XML
  that is a wrong workbook — and only a check of our own, or Excel itself,
  sees it.** §9 records what that check would be, and why it is not written yet.

  **Two older defects reported at the same time**, both long-standing §9
  entries: the table slicer's cache writing a bare `<ext>` into the x14
  namespace instead of `x:ext`
  ([office.mbt#535](https://github.com/moonbitlang/office.mbt/issues/535)), and
  `Style` having no `with_protection` or `with_number_format`, so a style
  cannot carry both a number format and cell protection
  ([office.mbt#534](https://github.com/moonbitlang/office.mbt/issues/534)) —
  which is what a protected form's currency-formatted entry cell is made of.
  Nothing changed in the code for either; they were simply never sent.

  **And one gap was re-checked rather than assumed.** 0.1.10's `.mbti` shows
  `SparklineGroupOptions` with `mut first`, `mut last`, `mut negative`, reached
  through `Worksheet::sparkline_groups()` — which read like a way to set the
  markers the schema refuses. Compiling the assignment says otherwise:
  *"Cannot modify a read-only field: first"*. The type is read-only outside its
  package, so the refusal stands. That is the **second** time this backend's
  generated interface has implied a mutability the compiler denies (`Style` is
  the other), which is worth stating as a rule: here, a `mut` in the `.mbti` is
  not a promise, and only compiling the assignment settles it.

- **2026-09-10** — **Two backend defects reported, one of which loses every
  style in the workbook.** Found by re-reading our own open issues against
  `mbtexcel@0.1.10` after the v0.4.0 release: the 0.1.10 sweep had worked from
  §9 and from the `office.mbt#` references in the source, and #81 and #82 were
  in neither, so they were missed. Both are still present in 0.1.10.

  **`<dxf>` is written out of `CT_Dxf` order** (ECMA-376 §18.8.14) — `numFmt`
  before `font`, `alignment` after `border`. A conditional look built from a
  font and a fill survives that by luck; one carrying a `format:` or an
  `align:` does not, and because `styles.xml` is load-bearing Excel calls the
  whole workbook damaged and repair **discards the stylesheet entire**, taking
  every ordinary cell's formatting with it. Reported as
  [office.mbt#531](https://github.com/moonbitlang/office.mbt/issues/531).
  **Deliberately not worked around**: the fix upstream is one reordering, and
  refusing the combination would cost a real feature — a currency-formatted
  highlight — for however long that takes. `docs/spec.md` §10 warns instead and
  names the trap, which is `extends:`: a shared base style carrying `align:`
  reaches a conditional look without the author writing the word.

  **A comment's VML shape style is missing `margin-left` / `margin-top`**, so
  it carries a bare `73.5pt` that is not a declaration
  ([office.mbt#532](https://github.com/moonbitlang/office.mbt/issues/532)).
  Excel reads a note's position from `<x:Anchor>` and is unbothered; a stricter
  consumer need not be. Nothing to work around — no spec says this differently.

  **The corpus lesson, for the second time.** The validator catches the `<dxf>`
  defect the instant it is asked to — but `conditional.yxl.yaml`'s twenty-four
  rules all use looks made of a font and a fill, so it was never asked. This is
  the icon-set blind spot again: *the corpus judges only the variants something
  actually builds.* `tests/validity/conditional-dxf-order.yxl.yaml` now builds
  the combination on purpose, waived by name in `known-defects.txt` so the
  waiver fails the day upstream fixes the order.

- **2026-09-09** — **The backend moved to `moonbitlang/mbtexcel@0.1.10`, and
  four of our refusals came off with it.** The library changed owner —
  `bobzhang/mbtexcel` → `moonbitlang/mbtexcel`, same `xlsx` package — and
  `0.1.10` closes three of the defects this project had reported and one it had
  not. ADR-021 records the coordinates; ADR-001's decision and ADR-002's seam
  are unchanged, and the seam is what made the move four `moon.pkg` lines plus
  one constructor (`@zip` now comes from `moonbit-community/flate`, whose
  `Archive::Archive()` replaces the vendored `Archive::new()`).

  **Pivots gained their fourth axis and their second source**
  ([#264](https://github.com/moonbitlang/office.mbt/issues/264)). The backend
  derives `<location>` from the axes a pivot actually uses — filter block above
  `ref`, per [MS-OI29500] on 18.10.1.49 — and allocates a cache id per pivot
  instead of stamping `1` on all of them. So `filters:` is a plain axis now,
  and two pivots may summarize two different regions. The two whole-workbook
  checks that enforced the refusals are gone; the field-existence check grew to
  cover the new axis. The cookbook's `pivots` example filters by channel, and a
  new `tests/validity/pivot-axes.yxl.yaml` builds every axis and every
  aggregation at once.

  **And with the refusals gone, `extract` recovers pivots** — Phase 10's slice
  5b, which had been waiting for exactly this release so the reader would be
  written once against the final schema rather than twice.
  `src/read/pivot.mbt` reads the definition and its cache together (the one
  names fields by index into the other), `src/render/pivot.mbt` writes them
  back, and a test asserts a workbook recompiles to *identical bytes* after a
  round trip. What the schema cannot describe is refused whole and named, as
  slice 5a's charts are: a source that is not a region of cells, a calculated
  field, an unknown aggregation, a style outside the three families. Reported
  while the pivot is kept: a number format on a value field, Excel's compact
  layout, and field sorting.

  **Twelve shape geometries came back**
  ([#293](https://github.com/moonbitlang/office.mbt/issues/293)). `prst` keeps
  its capitals, so `rounded_rectangle`, `right_triangle`, the six straight
  arrows and the four callouts are ordinary entries in the preset table rather
  than named refusals — 35 where there were 23. `unwritable_shape_presets` and
  the no-capitals invariant test are deleted; `extract` no longer has to explain
  why a `roundrect` could not be identified.

  **Workbook-level `protect:` works again.** `<workbookProtection>` is written
  before `bookViews`, where CT_Workbook (ECMA-376 §18.2) wants it, so the file
  Excel used to call corrupt now opens. The loader's refusal is replaced by the
  `load_workbook_protection` it displaced, an emit test asserts the element
  order against `xl/workbook.xml` itself rather than through the round trip
  (which never disagreed), and the `interactive` example locks its structure
  again.

  **The auto filter is half unblocked, so it stays blocked.**
  [#477](https://github.com/moonbitlang/office.mbt/issues/477) is fixed — a
  quoted criterion no longer reaches the file wrapped in its own quote
  characters — but
  [#478](https://github.com/moonbitlang/office.mbt/issues/478) is not, and a
  checkbox list still caps at two values. Shipping the comparison half alone is
  exactly the backend subset §9 rejected once already, so `filter:` keeps its
  range and nothing else.

  **And Windows stopped being experimental** — the oldest entry in §9, open
  since Phase 9. `eval_function` — the ~5 000-line chain MSVC answered with
  `C1026` — is now a five-arm match plus two hash-map lookups, its families
  split across seven files ([#402](https://github.com/moonbitlang/office.mbt/issues/402)).
  The runner built the CLI, ran the tests, and compiled every example and the
  whole validity corpus, so `continue-on-error` is gone from both `ci.yml` and
  `release.yml`: Windows blocks a merge and a release like the other two, and
  `install.ps1` no longer has to apologise for a missing binary. The one
  Windows limitation left is the unrelated one — a non-ASCII *command line*,
  which is `moonbitlang/x`'s to fix.

  Verified beyond `moon test`: every example and the validity corpus were built
  with the release binary and run through the Open XML SDK validator — 22
  workbooks, all valid, the one standing waiver (`modular.xlsx`'s table-slicer
  namespace) still firing, which is how we know it has not silently changed.

- **2026-08-29** — **A sheet named `It's data` survives `extract` again.** The
  reader parsed a sheet-qualified range by stripping every `'` and `$` and
  splitting on the first `!`, so `'It''s data'!A1:A3` came back sourcing a sheet
  named `Its data` — one no workbook declared. The loader's half had implemented
  Excel's quoting properly all along (a `''` inside the quotes is one
  apostrophe), so the two directions of the round trip disagreed, and only the
  direction that reads a real workbook was wrong. Found reviewing the two for
  duplication (AGENTS.md §8.2); the self-check caught the result at run time and
  said "please report this", which is how it was meant to behave, but the spec
  it wrote still had to be fixed by hand.

  The rule now lives once, in `@model.split_sheet_prefix`, with the loader
  wrapping it to raise its diagnostic and the reader to fall back to `None`.
  `$` is still dropped, but only from the range — a sheet may legally be named
  with one. Affected validation list sources and chart series.

- **2026-08-29** — **`yxl init`: there is now a way to get a *first* spec.**
  `build` and `extract` both take a spec that already exists, so the way in was
  to know the format well enough to type it — the one thing a person starting
  out cannot do (#77). `yxl init -o sheet.yxl.yaml` writes one.

  What it writes is the part worth recording, because the obvious answer is the
  wrong one: **an empty sheet, not a worked example.** A starter carrying a
  heading row, sample rows and a `SUM` reads as helpful and is not — the reader
  deletes it before they can begin, and the first thing they do is fight the
  example. What they want is Excel's first minute: one sheet, and a grid to type
  into. So the file is five lines, three of them comment, and `docs/spec.md` §1
  now shows the same smallest document.

  The build line inside it names the path `init` was actually given — write
  `report.yxl.yaml` and it says `yxl build report.yxl.yaml -o report.xlsx` —
  because a starter whose one instruction is about some other file is a starter
  that lies on its first line. `-o` is required rather than defaulting to
  stdout, matching `extract`: every `yxl:` message goes to stdout for want of an
  `eprintln`, so `yxl init > spec.yaml` would capture a diagnostic into the file
  on any failure. An existing file is refused (exit 1) unless `--force` says
  otherwise; overwriting a spec someone has worked on is the worst thing this
  command could do, and it cannot tell that file from one of its own.

  It sits in the compiler rather than in an editor because a starter spec is a
  statement about the **format**, and the format is ours (ADR-011) — kept
  anywhere else it is a second copy that drifts, and out of reach for anyone
  using the CLI without that editor. `yxl-vscode`'s *New Spec* becomes: ask
  where, run `yxl init -o <there>`, open it.

- **2026-08-29** — **Toolchain catch-up: a constructor is named for its type.**
  MoonBit's current idiom is that a static method carrying its own type's name
  *is* that type's constructor, callable as `T(…)`; core has moved its own
  (`StringBuilder()`, `Bench()`, `Test()`, `Ref()`) and deprecates the `new`
  spelling behind `#alias(new, deprecated=…)`. The twenty-one
  `StringBuilder::new()` calls were the half the compiler flagged; the other
  half is ours, and yxl's twelve `T::new` follow — `@units.CellRef(col~, row~)`,
  `@model.Workbook()`, `@diag.Diagnostic(message)`. Only the *primary*
  constructor takes the type's name: `Span::point`, `Style::empty`, and
  `CellRef::parse_a1` say something `Span(…)` cannot, and keep their names.
  `@xlsx.*::new` is left alone — mbtexcel 0.1.9 has not moved and its surface is
  pinned (ADR-001). Seven of the twelve are public, so the `.mbti` records the
  rename; no behaviour change (ADR-007, §8.8).

  Landed underneath it, on its own commit so the next one is a revertable diff:
  **a whole-tree reflow.** The formatter in moon 0.1.20260827 — the current
  published release, which CI installs unpinned — writes a trailing comma into
  single-line struct literals (`{ col, row, }`) and collapses `T::{  }` to
  `T::{ }`. `moon fmt --check` was already failing on `main` before any of this;
  seventy-nine files now say what the formatter says.

- **2026-08-20** — **A style attribute can now say it is *not* set.** Leaving a
  key out has always meant "inherit" — from the style an inline one extends,
  from the column band, from the row band over it — which left no way to write
  the cell Excel draws unfilled inside a filled column. `null` is that third
  state, at every attribute (`fill: null`, `font: { color: null }`,
  `format: null`, `align: { horizontal: null }`, `border: { left: null }`), at a
  whole group (`font: null`), and at the `format:` shorthand beside a style. A
  boolean never needed it: `bold: false` is a value. Both spellings an author
  would have reached for were errors before, so no spec that compiled changes
  meaning (`docs/spec.md` §6, ADR-020).

  The half that was actually losing data is `extract` (#71). A `<c>` element
  takes its whole look from its own style — `<col style>` reaches only the cells
  a sheet never allocated (ECMA-376 §18.3.1.13) — so a written cell carrying no
  fill is unfilled however the column is styled. That cell came back as "no
  style", was written as a bare value, and rebuilt **filled**, under a line
  saying everything else rebuilt as read. The self-check could not have caught
  it: it compares the recovered model against the rebuilt one, and the
  difference was gone before the comparison, in the reader. The reader now gives
  such a cell the `null` that says so — only for what the band would actually
  have passed down, so a text cell under a numeric column band stays quiet.

- **2026-08-20** — **Toolchain catch-up: `moonbitlang/x@0.4.50`, and two
  implicit trait promotions made explicit.** The August toolchain refuses
  `lexscan` on a `StringView`, which `moonbitlang/x@0.4.48`'s `time` package
  still writes, so `moon check` failed on a dependency nothing in `yxl` imports
  — `x/fs` and `x/sys` are all we use, but the module is built whole. `0.4.50`
  fixes it upstream. The same compiler now deprecates calling a trait method
  through the implementing type (`MbtexcelReader::new().read(…)`), so
  `from_bytes` and `to_bytes` name the trait — `Reader::read`, `Emitter::emit`
  — which is what the seam meant anyway (ADR-002, ADR-017). No behaviour
  change; without it CI cannot go green (§8.8).

- **2026-08-16** — **A JSON Schema, generated from the reference.**
  `docs/yxl.schema.json` ships beside `docs/spec.md`, and a spec that names it —
  `# yaml-language-server: $schema=…`, or `yaml.schemas` for a whole project —
  gets completion on every key, the reference's own sentence on hover, and an
  underline beneath a key that does not exist. VS Code, Neovim, and Helix all
  read the modeline; §24 of the reference is the setup.

  It is **generated from the page it documents** (ADR-019), which is the half
  that matters six months from now. `tools/spec-schema/generate.py` reads
  `docs/spec.md` for the vocabulary — the keys out of its tables, examples, and
  §4's matrix; the enumerations out of the lists it already writes (seventeen
  icon sets, fourteen chart types, twenty-three geometries, sixty numbered table
  styles); the hover text out of its Notes columns — and supplies only the
  shapes prose cannot state. Neither half can drift: a key documented but
  unshaped, or shaped but undocumented, fails the generator by name, and CI
  fails when the committed JSON is not what the reference now says.

  A schema that quietly decayed into `{}` would pass every corpus in the repo,
  so acceptance is not the only test: `--selftest` pins fourteen mistakes it
  must refuse — a misspelt sheet key, `5Boxes`, `TableStyleMedium99`, text where
  a width goes, an override naming no sheet — beside the twenty-one specs of
  `examples/` and `tests/validity/` it must accept. What it does *not* judge is
  stated rather than left to be discovered: cross-key rules and dangling
  references stay with the compiler, where a diagnostic can be a sentence
  (ADR-006), so a spec the editor likes can still be refused.

- **2026-08-15** — **The cookbook covers `overrides:` too.** §23 shipped as the
  only section of `docs/spec.md` with no worked example behind it (#68), which
  left a reader who wanted to write down a one-off exception with a reference
  and nothing to copy. `examples/overrides.yxl.yaml` is a sheet that is
  otherwise entirely rules — six rows from one `data:` block, one revenue
  formula filled down the column — with three cells that are not: a row of the
  filled range billed at half rate, a value the source export got wrong, and a
  style alone marking the patched row. What the file is really demonstrating is
  the `reason:` on each, since that is the half no other spelling of the same
  bytes can carry. It joins the promise table and both writer round-trips, so
  the example is asserted on cells and survives being written back out and
  re-extracted like the rest of the corpus.

- **2026-08-15** — **`overrides:`, a layer for the cells that are exceptions.**
  A spec could always *express* a one-off deviation — sheet keys apply in the
  order written, so a `cells:` entry after a `data:` block wins — and that was
  the problem: once written it looked exactly like a cell somebody meant. So the
  hand-patched cells could not be counted, could not say why they were patched,
  and could not be folded back into a rule when several turned out to share a
  shape; and the spec's correctness rested on YAML key order, which any
  formatter is free to change.

  A top-level `overrides:` says it instead (`docs/spec.md` §23): `at:` one
  sheet-qualified cell, the same grammar a `cells:` entry uses, and a `reason:`
  for whoever reads the spec six months later. It is applied after everything
  that writes a cell and before everything that reads one, so an override is
  never at the mercy of where it sits in the file, and a table still takes its
  column names from the header the override replaced. The facets are
  independent: a new value keeps the cell's formatting, a new style keeps its
  value.

  The case that had no answer before is a `formulas:` range. An override inside
  one takes that single cell out of the shared-formula group and leaves the
  range whole — 500 rows, one stored formula, one exception — where the previous
  advice, splitting the range around it, cost three ranges, re-anchored two of
  them, and recorded nothing about why. The range's top-left is refused, since
  that is where the shared formula lives (ECMA-376 §18.3.1.40).

  It is also refused when it overrides nothing, when its `at:` names a range,
  and when one cell is given two — the rules that keep the block a countable
  list of exceptions rather than a second `cells:`. Raised from downstream by
  yxl-vscode (#66) and taken now because a top-level key added after the freeze
  is a breaking change; `tests/validity/override-facets.yxl.yaml` puts every facet
  through the Open XML SDK validator.

- **2026-08-14** — **A default architecture for a maintained workbook, in the
  skills and in the cookbook.** The skills knew every mechanism and prescribed
  no shape, so "build me a workbook" produced a different project each time and
  none of them survived a second month. `yxl-authoring` now leads with the
  layout to build absent instruction — entry spec as table of contents, a file
  per sheet, the look named by role in one file, rows as `data:` and real
  tables as `tables:`, a derived column as one `formulas:` range, per-issue
  values (**including the CSV file names**) as `params:` — together with the
  rule that governs it: the user's own shape wins when they state one, and an
  existing spec is never reorganized as a side effect of an unrelated edit.

  **The part that answers the recurring question — the same store list pasted
  into five sheets — is "one master, reached by reference".** The list lives on
  one sheet, fed by one CSV, declared an Excel table; other sheets look it up
  by table name and source their drop-downs `from:` its cells, so a rename is
  one CSV edit and the drop-down cannot offer a value the master lacks. Where a
  sheet must physically print the rows, the same CSV is named from each `data:`
  entry — one source in git, and Excel interns the strings anyway.

  `examples/workbook.yxl.yaml` **is** that layout (three sheets across seven
  files, a hidden master, a month-parameterized CSV path), so CI compiles the
  advice instead of the README describing it; `src/examples` promises its cells
  both at `2026-07` and under `--set month=2026-08`, which is the refresh claim
  measured rather than asserted. Building it turned up two gaps in
  `docs/spec.md`, both now written: a validation's `from:` must be a plain
  range — yxl adds the `$` and the quoting, and refuses a hand-written one —
  and a `null` data field writes no cell, so a column of `null`s inside a block
  of rows reserves that column for a `formulas:` range.

- **2026-08-10** — **The default is now no comment, and the source was cut to
  match.** `AGENTS.md §8.6` used to say "delete comments the code already says"
  and leave the rest to taste, which in practice meant every file opened with a
  prose tour of its subject and every test annotated its own assertions. Both
  are documentation: the tour belongs in `docs/spec.md` or an ADR, and a test's
  name already says what it asserts. Written in two places, the copy in the
  source is the one that rots.

  §8.6 is now a rule with an order to it — delete what the code says, delete
  documentation that wandered into the source, delete narration of the past,
  keep the constraint the code *cannot* show — and says explicitly that tests
  get the same treatment. 132 file-header essays and ~180 restating test
  comments went, about half the non-doc comment lines in `src/` (1 749 → 869).

  **Struct fields lost their `///` too, and that one is not a matter of taste:
  a field doc never reaches the generated `.mbti`.** Only item-level docs do, so
  a field doc is commentary wearing documentation's syntax, and the "public APIs
  keep their `///`" exemption does not cover it. Worse, it was applied to some
  fields and not others — `Chart` annotated five of eight — which leaves a
  reader unable to scan the type at all: eight lines of fields read as eighteen.
  What a field needs said now lives in the **type's own doc**, once
  (`model/decorations.mbt`'s `SheetRange` was already the pattern), and
  conventions true of the whole type — "`None` keeps Excel's default", "in
  declaration order" — are stated there once instead of per field. `Sheet` alone
  had "…, in declaration order" thirteen times. 164 field-doc lines → 0.
  `enum` variants keep 19 of 37, the ones naming an OOXML term (`cellIs`,
  `top10`) or Excel's own dialog wording, which a one-word constructor cannot.

  Nothing that carries a constraint was lost: the backend's quirks, the
  ECMA-376 citations, the bug numbers behind a non-obvious line, the ordering
  the emitter depends on. `moon info` reports **no `.mbti` diff at all**, which
  is the proof that the public API surface is untouched, and all 679 tests pass.

- **2026-08-09** — **The validity gate stops depending on what the cookbook
  happened to need.** `tests/validity/` is a second corpus with a different
  job: eight specs that reach for every variant the schema offers — all
  fourteen chart types, all twenty-three geometries, all seven control kinds,
  every validation comparison, the workbook keys no example sets — and are not
  meant to be read. CI builds them with the shipped binary alongside
  `examples/` and puts both through the same validator, eighteen workbooks in
  all. They live outside `src/examples` deliberately: that package asserts on
  cells and enforces a coverage check both ways, and this corpus wants neither.

  `validate-xlsx.sh` also grew a guard, because two corpora make a collision
  possible that one could not: a waiver names a workbook by basename, so two
  inputs sharing one are indistinguishable and a waiver for `examples/shapes`
  would silently cover `tests/validity/shapes`. The script refuses such a run
  outright rather than picking a winner, and the two colliding corpus specs
  were renamed to `chart-types` and `shape-geometries`. Naming the hazard was
  worth more than the rename: it is the same shape as the defects above — a
  thing that looks checked and is not.

- **2026-08-09** — **The three bugs the sweep found are fixed, and a fourth
  that was hiding behind one of them.**

  **An icon set the backend cannot write is refused by name.** `3Stars`,
  `3Triangles` and `5Boxes` are Excel-2010 names living only in an extension
  schema, and they were going into the base attribute, whose enumeration has
  seventeen values and none of them — so the workbook did not open cleanly.
  They now fail as a diagnostic that says which enumeration and why, the way
  the camel-case shape presets do, and `docs/spec.md` §10 lists the seventeen
  outright rather than trailing off in a "…". `extract` names the three apart
  from an unknown one, because "the spec does not name it" would send a reader
  hunting for a typo that is not there.

  **A link into a sheet whose name needs quoting is quoted.** `to: "Q1 Sales!B2"`
  reached the file verbatim, and Excel answers a click on that with "Reference
  isn't valid". Every other cross-sheet reference was already right because
  every other one travels as a parsed range; a link's target is the one thing
  that stays text from the spec to the file, since it may equally be a defined
  name. Quoting is now unconditional, matching what `emit` already does for
  every chart range — one rule instead of a second implementation of Excel's
  "is this a bare word" test. A `to:` naming no sheet is left alone.

  **A split pane comes back.** `docs/spec.md` §22 promised frozen *and* split
  panes were recovered and the split half never was — it vanished with no line
  in the loss report, which is the one thing §22's contract forbids.

  **And behind that one, the fourth.** With the branch reachable for the first
  time, `split: { x: 3000 }` came back as `60000`: the schema speaks points,
  OOXML stores twips, `emit` multiplied by twenty and `read` never divided.
  Nothing could have caught it, because the code that would have been wrong
  never ran. That is the sharper form of the lesson §5 now carries — an
  unreached path is not tested, only unaccused — and it is the second time in
  two days that fixing a reporting gap immediately exposed a real defect
  underneath it.

- **2026-08-09** — **A defect sweep, four upstream reports, and three bugs of
  our own.** The release we are waiting on left a gap with nothing to build in
  it, so the gap was spent looking for what is already broken rather than
  adding to it. The method was the one that worked in the last two slices:
  write specs that exercise every *variant* the schema offers — seven of them,
  covering all fourteen chart types, all twenty icon sets, all twenty-three
  shape geometries, all seven control kinds, all twenty-four conditional rules,
  every validation comparison, and the keys no example uses (`split`,
  `date1904`, `default_font`, error cells, `very_hidden`) — compile them, put
  them through the Open XML validator, and extract them back.

  **Three shipped bugs, all ours to fix** (§9 has each in full). Three icon
  sets — `3Stars`, `3Triangles`, `5Boxes` — write a workbook Excel offers to
  repair, because they are Excel-2010 names that exist only in the `x14`
  schema and were going into the base attribute. A `links:` entry pointing at
  a sheet whose name needs quoting went out unquoted, so the link is dead;
  every other cross-sheet reference is right, because every other one travels
  as a parsed range instead of a `String`. And a split pane was dropped by
  `extract` **silently**, which `docs/spec.md` §22 explicitly promises never
  happens.

  **The sweep also says what is sound**, which is worth recording because it
  is most of it: all fourteen chart types round-trip, all twenty-four
  conditional rules round-trip, comments carry their VML, sheet-name quoting is
  correct everywhere except the link above, and four of the seven probes
  validated clean on the first run.

  **Four upstream reports**, the first this project has filed in a batch:
  [office.mbt#401](https://github.com/moonbitlang/office.mbt/issues/401) (form controls have no modern half, so Excel draws
  nothing), [#402](https://github.com/moonbitlang/office.mbt/issues/402) (`eval_function` is 5,055 lines and MSVC will not compile
  it, which is what keeps Windows experimental), [#403](https://github.com/moonbitlang/office.mbt/issues/403) (the icon sets
  above), and [yaml.mbt#14](https://github.com/moonbit-community/yaml.mbt/issues/14) (five flow-scalar escapes decode to the wrong
  character, decimal code points written as hex). Three more batches are
  named and unfiled in §9: the slicer namespace, the seven reader gaps, and
  the missing `Style` builders.

  **And a lesson about the validity gate itself** (§5, and a new v1.0 item in
  §6). It judges `examples/` output, and `examples/` is written to *teach* —
  one icon set of twenty, four chart types of fourteen. Everything the
  cookbook never reaches for has been shipping unvalidated, which is exactly
  where the icon sets were hiding. The corpus that closes that is a different
  corpus: breadth-first, one spec per feature with every variant side by side,
  never shown to a reader.

- **2026-08-08** — **`docs/spec.md` stops promising a chart series can omit
  `categories:`.** It cannot: the Excel backend refuses to build a series with
  an empty categories range, so the case §12 documented — "Without it Excel
  numbers the points 1, 2, 3, …" — has never compiled. §12 now says the key is
  required in practice, prints the error, and points at the open question; its
  own example had a series without one and now has it. Recorded rather than
  fixed because the fix is a schema decision (§8 Q11: make the key required,
  synthesize a range, or take it upstream) and it belongs before the freeze.
  Nothing about `extract` is affected — a file always stores a `c:cat`.

- **2026-08-08** — **`yxl extract` brings charts back.** The last thing on a
  sheet that came back as a line in the loss report rather than as a block in
  the spec — kind, series, title, legend, size, axes. What the roadmap's own
  estimate missed is that a chart is the only thing in a workbook the backend
  hands over as *raw text*, and there was no XML reader anywhere in the tree to
  meet it with: everything else `read` recovers arrives as a typed record.

  So the slice is three parts. `src/read/xml.mbt` is a small reader — a
  tokenizer and a tree, no dependency — and it is deliberately more than a scan
  over the interesting bits. It **tracks nesting**, because a series' name and
  its data labels' are both `<c:tx>` and only depth tells them apart; and it
  **resolves namespace prefixes to URIs**, because a chart part may bind the
  chart namespace to any prefix it likes and matching on the literal `c:` would
  read the files yxl wrote and miss the ones Excel wrote. Both are the
  difference between a gap, which shows up in the loss report, and a confident
  wrong answer, which does not. `src/read/chart.mbt` maps the ten paths onto
  `model.Chart`, and `src/render/chart.mbt` is the inverse — `render` had no
  charts either, since nothing had ever produced one.

  The discipline is slice 4's, and it decided every hard case. A chart that
  says something the schema cannot is refused **whole** and named: a
  combination chart, a stacked line (the schema has `line` and no variant of
  it, and "line" would plot the same numbers a different way), a kind outside
  §12, a series holding its numbers rather than pointing at cells. Two smaller
  losses are named while the chart is kept: a title read from a cell, and a
  series name pointing at a range rather than a cell. The axes are matched to
  their chart group **by the ids the group lists**, not by the order they sit
  in the plot area, because a chart with a secondary axis has four of them.

  `examples/charts.yxl.yaml` now extracts back to itself, down to the sheet
  qualification the file adds — `Figures!B2:B4` for what the spec wrote as
  `B2:B4`, which is what the file stores and what compiles to the same bytes.

- **2026-08-07** — **CI now asks whether Excel would open the workbook, and the
  first answer was no.** Every check up to here re-opened our own bytes with the
  library that wrote them, so the one failure mode that matters most to a user
  — the repair dialog — was invisible to all of it. The judge is now the **Open
  XML SDK's validator** (`tools/openxml-validator`, two source files, driven by
  `.github/scripts/validate-xlsx.sh`), run over every compiled `examples/`
  output on the Linux leg. It was chosen over the LibreOffice-headless or
  `openpyxl` pass the roadmap first sketched because those are lenient readers,
  and a reader that silently repairs cannot report that it had to; the SDK
  validator names the element, the attribute, and the XPath.

  **It failed two of the ten examples immediately**, and one was ours.
  `<tabColor rgb="1F77B4"/>`: SpreadsheetML colour attributes are
  `ST_UnsignedIntHex` — four bytes, `AARRGGBB` (ECMA-376 §18.18.86) — and the
  backend writes this one through verbatim where it widens the style colours
  itself. So **every workbook with a coloured sheet tab was a workbook Excel
  offered to repair**, and had been since tab colours shipped.

  Pulling on it found the whole class, by handing an `AARRGGBB` to each of the
  ten places a spec can name a colour and validating what came out. Excel has
  **two** colour attribute types and the backend's APIs disagree about which
  one they take: the style colours widen 6 to 8 and pass 8 through, sparklines
  do the same, `tabColor` takes 8 only, a shape is DrawingML and takes 6 only
  (`ST_HexColorRGB`, §20.1.10.42 — `FF1F77B4` is one byte too long), and the
  conditional-format colours take 6 and prepend the alpha themselves, so an 8
  arrives as ten hex digits. Three of the five were wrong. `@units.Color` now
  renders both forms by name — `argb` and `rgb`, with `hex` left for showing a
  colour back to a human — and each emit site asks for the one its part of the
  schema takes. Tests assert on the bytes in the part rather than on the round
  trip, because the backend's reader normalizes a colour on the way in and
  would have read every one of these back as correct.

  The other direction got the matching rule, which the fix above forced and
  which turned out to be overdue. `read_color` now drops a fully opaque alpha,
  so a colour recovered from a file is spelled the way a spec spells one. It
  had not been: a theme-resolved font colour came back `FF4F81BD` and a
  sparkline's `FF1F77B4`, while a literal one came back `4F81BD` — the same
  colour, two spellings, depending on which backend reader happened to trim the
  byte. `docs/spec.md` documents `RRGGBB` throughout, so that is what `extract`
  writes now. A partial alpha is kept; that one is a choice.

  The second failure is the backend's and is waived by name
  (`known-defects.txt`, §9): a table-backed slicer's cache writes its `ext` in
  the x14 namespace where the schema types it `x:ext`. The waiver is scoped to
  one file and one finding, and the check fails if it ever stops happening, so
  it cannot outlive its reason.

- **2026-08-05** — **A failed read now says what was wrong with the file.**
  Every `yxl extract` failure since the command shipped printed
  `t-ujiie-g/yxl/diag.ReadError.ReadError` and nothing else, so working out
  which part of a workbook was unreadable meant swapping parts out one at a
  time. Two causes, both ours and both one-liners once found.
  `cli.error_message` matched four of the six subdomain errors — `ReadError`
  and `ResolveError` fell through to `Show` on the error, which renders the
  type's name — and the reader seam built its own message with that same
  `Show`, which on the backend's error gives the constructor and throws the
  payload away. `XlsxError` derives `Debug` and keeps the useful half in a
  `msg~`, a `path~` or an `index~`, so that is what the message is built from
  now: `Excel backend: InvalidXml(msg="MCE AlternateContent has no Choice
  element")`. `emit` had the identical defect and got the identical fix.

  Worth recording *why* this came first: it was the tool needed to triage the
  three bugs reported against v0.3.2, and with it in hand two of them stopped
  reproducing. A faithful reconstruction of an Excel-written `workbook.xml` —
  `mc:AlternateContent` wrapping an `x15ac:absPath`, `xr:revisionPtr`, the
  `mc:Ignorable` list, a `mc:Fallback` — reads clean. What does *not* read is
  MCE that is genuinely malformed: `Requires="x15"` where `xmlns:x15` was
  never declared, an `mc:Ignorable` naming an undeclared prefix, an
  `AlternateContent` with no `Choice`. The reported minimal repro is the first
  of those. That leaves a real question upstream — **`extract` reads foreign
  files, so ADR-006's fail-fast is the wrong stance there**: MCE exists so a
  consumer can ignore what it does not understand, and rejecting a whole
  workbook over a stale prefix in an `Ignorable` list is not what Excel itself
  does. Pending the real file, since a reconstruction that passes is not
  evidence about one that fails.

- **2026-08-05** — **Refactor after the field report, and it found two more
  colours going missing.** The §8 lenses over the whole tree; §8.1 was clean and
  §8.5 had already been done with the fixes, so the findings were these.

  **§8.2, one concept in one place.** #50 taught `read` to resolve a theme or
  palette index against the workbook's own theme — and taught it in `read_font`
  and `read_fill` only. Two more readers take the same `@xlsx` fields and were
  reading the literal `rgb` alone: **a rich-text run's font**, so a theme colour
  survived on a plain cell and vanished on the mixed-font cell beside it, and **a
  sheet's tab colour**, which the colour picker offers from the theme row first
  and which was being dropped without even a note. Both now go through
  `resolve_color`. A sparkline's is the one that stays literal, and now says why
  in the code rather than only here: Excel hands every group four theme-referenced
  colours nobody asked for, so resolving them would write four `RRGGBB` values
  onto every group to restate the default.

  **§8.1, the newtype should own its spelling.** `resolve_color` was slicing
  `00RRGGBB` down to `RRGGBB` with string arithmetic, in the package furthest
  from the type that exists to keep colours out of bare `String` (§7). That is
  `@units.Color::rgb()` now.

  **§8.3, a boundary the other packages already draw.** `read/cells.mbt` had
  grown a second job — walking the grid, *and* recovering shared formulas — where
  `emit` and `loader` each keep that work in their own `formulas.mbt`. Split to
  `read/formulas.mbt`, which also let the `@xlsx.Worksheet?` be guarded once at
  the top of the scan instead of threaded as an `Option` through four signatures.

  **§8.8, a set is a `Set`.** Eight places built one out of `Map[K, Unit]` and
  wrote `m[k] = ()` / `m.get(k) is Some(_)`. `moonbitlang/core/set` is what the
  standard library reaches for now, and `Notes::drop`'s test-then-insert collapses
  into one `add_and_check`. `render.DataTable.covered` is public, so the `.mbti`
  moves with it — the only public change besides `Color::rgb`.

  **§8.4** added the two tests the fixes had left uncovered: a theme index past
  the end of the theme (the hyperlink pair) reports rather than guesses, and
  `render` writes a style-only cell as `{ style: … }`, which is the spelling the
  loader has to read back.

- **2026-08-05** — **The field report: issues #45–#52 from a production
  workbook.** Eight defects raised against v0.3.1 by converting a 32-sheet
  monthly report, and the first of them meant *no openpyxl-produced workbook
  could be extracted at all*. Seven are closed, one is blocked upstream.

  **Two were the backend's and the `mbtexcel@0.1.9` upgrade closed them.**
  `<definedNames/>` and `<numFmts count="0"/>` — both of which openpyxl always
  writes — failed the whole read on a tag matcher that could not see a
  self-closing element (#47). And shared-formula followers, on this roadmap as
  blocked since slice 1, are a read now: `0.1.9` publishes
  `Cell.formula_shared_index`, `Worksheet::shared_formula_master` and
  `SharedFormulaMaster::translate_to` (#52). A master whose whole range follows
  it folds back into the single `formulas:` entry it came from; where a cell in
  the range wears a style — which a `formulas:` range cannot carry — each
  follower comes back as its own translated `formula:`. Before this, 105k cells
  of the reported workbook turned into stale cached numbers that would never
  recalculate: a silent correctness bug, which is the worst kind this direction
  can have.

  **Two were ours, and both changed what a spec can say.** A cell with a style
  and no value — 600k of them in that workbook, every merged heading's blank
  half and every ruled table edge — was dropped without even a `dropped:` line,
  and the schema had no way to write one back. Both halves are fixed:
  `B3: { style: shaded }` is now a cell, and `extract` writes it (#48). And
  theme and palette colours, which in a real workbook are *most* colours, were
  reported as a loss on every font and fill; the theme is in the file being
  read, so they resolve to RGB now, tint included (#50).

  **Three were ours and quieter.** The built-in number formats 37–40 were
  copied from ECMA-376 §18.8.30, which prints a space where Office writes `_)`
  (MS-OI29500 §2.1.601) — so an accounting column extracted to a format that
  no longer lines its digits up (#49). A `\r` in a cell value became U+13,
  which XML 1.0 cannot carry, so `yxl build` wrote a workbook Excel offered to
  repair; repaired at the parse seam, where a defect of the chosen parser
  belongs (ADR-009, §9) (#45). And every ZIP entry was STORED, so a 13 MB
  workbook rebuilt to 132 MB; `emit` now deflates the package through
  `set_zip_writer` (#46). Compression costs about as much CPU again as the rest
  of the compile — the scaling guardrail's ratio moved from 16.2 to 18.6
  against a ceiling of 26 — which is the right trade against a tenfold file.

  **One is blocked upstream** (#51): `customWidth="0"` marks a column at its
  *default* width, and `parse_cols` never reads the flag, so `ColDimension`
  cannot carry it and `extract` writes the width as though it had been set.
  The heuristic that suggests itself is refused rather than shipped — see the
  Phase 10 blocked list for why.

  Two dependency bumps came with this. `moonbitlang/x@0.4.48` is not optional:
  `@sys.get_cli_args` compiled to the `$moonbit.get_cli_args` intrinsic, which
  the 2026-08 toolchain dropped, so `moon build --target native` failed to link
  and **CI had been broken since the toolchain moved**. `main` now calls
  `@env.args()` from core directly. `mbtexcel@0.1.9` is the fix for #47 and
  #52 above; the three defects §11's 2026-07-29 entry checked it against still
  reproduce unchanged.

- **2026-07-31** — **Agent Skills: `yxl-authoring` and `extract-to-spec`.**
  Working with yxl is now packaged for AI agents as two checklists:
  `yxl-authoring` for writing a spec from scratch, editing an existing one,
  and operating it month to month, and `extract-to-spec` for the
  migration workflow the measured run below settled on. Installable into any
  SKILL.md-reading agent with `npx skills add t-ujiie-g/yxl`, and into Claude
  Code via this repository's new `.claude-plugin/` marketplace manifests.
  README gains the install section — and its first mention of `yxl extract`
  at all, which was a gap.

- **2026-07-31** — **The real workbook now extracts and verifies clean.** The
  three defects the measured run below surfaced are fixed, each at the layer
  that owns it. The reader now mirrors the loader's conditional-format split,
  so a rule recovered with nothing the schema can apply is dropped and named
  instead of written into a spec that does not compile. `file_stem` flattens
  only spaces and punctuation, so a Japanese-named sheet's CSV is named by the
  sheet (`data/売上.csv`), not by underscore count. And the YAML writer spells
  every control character but `\n` and `\t` as `\u00XX`, stepping around the
  parser's mis-decoded escape letters (§9) — which is what made every
  multi-line cell (CRLF, Excel's Alt+Enter) rebuild to a different value once
  the first fix let the verify pass reach the comparison. `yxl extract` on the
  32-sheet workbook now ends with "everything else rebuilds the workbook as
  read".

- **2026-07-31** — **First `extract` of a real workbook, measured.** A 32-sheet,
  1.6M-cell monthly report (12 MB, ~315k formulas, 198 conditional blocks)
  extracted in ~15 s to a 59 MB / 2M-line spec plus three CSVs. Two readings.
  The shape of use is confirmed: only three all-plain sheets crossed the
  deliberately narrow CSV bar, shared-formula followers came back as cached
  values (the known blocked item), and so the output is a starting point to
  rewrite, not a spec to keep — which is what §22 promises, and now what the
  new Phase 11 skills item builds on. And the run surfaced two defects, both
  now Phase 10 items: a conditional rule extracted without a `style:` fails
  the self-check compile, and `file_stem` flattens non-ASCII sheet names to
  bare underscores (`data/_____.csv` beside `data/_______.csv`).

- **2026-07-29** — **Docs: six straight arrows, not eight.** `docs/spec.md` §18
  and `ROADMAP.md` §9 both described the shape presets the backend cannot write
  as "the eight straight arrows"; `model.unwritable_shape_presets` lists six
  (`rightArrow`, `leftArrow`, `upArrow`, `downArrow`, `leftRightArrow`,
  `upDownArrow`), twelve tokens in all. Caught while fact-checking an upstream
  bug report against the table rather than against the prose.

  Also checked `mbtexcel@0.1.9` against the defects we work around: the
  workbook-protection element order, the aliased page-layout and page-margin
  defaults, and the lowercased shape `prst` all reproduce unchanged, and
  `eval_function` — the Windows `C1026` blocker — has grown from 4 917 lines to
  5 054.

- **2026-07-29** — **Four silent failures in `yxl extract`, and a diagnostic
  that named nothing.** Found by enumeration rather than by reading code: every
  `model` field against every stage, then every backend `Worksheet` and
  `Workbook` field against what `read` ever names.

  **Three features vanished without a word.** Charts, pivot tables, and print
  setup are all documented in `docs/spec.md` §22 as not recovered — and the
  command said nothing about any of them, then printed "everything else rebuilds
  the workbook as read". The cause is structural and worth naming: every other
  loss is reported as a *side effect of translating something*, so a feature no
  code touches at all reports nothing, because there is no code to report from.
  `src/read/unsupported.mbt` is the fix, and it is the only file here whose job
  is to run when nothing else did.

  **A chart sheet failed the whole extraction.** `get_sheet_list` names one
  alongside the worksheets while every per-sheet accessor answers
  `SheetNotFound`, so a workbook with a chart moved to its own tab — a
  right-click away in Excel — died with `Excel backend:
  bobzhang/mbtexcel/xlsx.XlsxError.SheetNotFound` and nothing to act on. Now
  skipped and named. Which kind it is comes off `chart_sheet(name)` rather than
  off the assumption that a non-worksheet must be a chart sheet: Excel also has
  dialog and macro sheets, and calling one of those a chart would be a confident
  wrong answer where "not a grid" is a true one.

  **An invalid sheet name leaked the backend's error.** `name: "Q3/Q4"` compiled
  to `Excel backend: …XlsxError.InvalidSheetName` — which sheet? which rule? The
  loader now checks Excel's own list (1–31 *characters*, none of `: \ / ? * [ ]`,
  no leading or trailing apostrophe, not `History`) and names both. `sheets: []`
  said "every sheet is hidden", which is a different mistake with no sheet to
  unhide; it now says so.

  Refactoring lenses §8.1, §8.2 and §8.8 came back clean — no magic numbers left
  unnamed, no dead code, no TODOs, no deprecation warnings, and nothing over 500
  lines outside tests. The previous two passes did their job; this one was
  almost entirely defects.

- **2026-07-29** — **A `data:` entry can carry its rows inline**, not only read
  them from a file: `- { at: A2, values: [[APAC, 2400000], [EMEA, 1750000]] }`
  (`docs/spec.md` §9).

  This is the diff-stability fix. A `cells:` mapping is keyed by address, so
  inserting a row rewrites every key below it. Measured on a three-row block:
  inserting one region is **`+6/-4` as `cells:` and `+1/-0` as `values:`**.

  It also turns out to be the *better-typed* form, which was not the reason for
  adding it. A `values:` field keeps the type YAML gave it, so `"007"` is text
  and `007` is the number 7 — while a `csv:` field arrives as text and has to be
  guessed at, because CSV carries no types. So inline rows are not merely CSV
  written in place; they say more.

  §9 is renamed from "External data" to "Tabular data", since a table's rows are
  no longer necessarily external.

- **2026-07-29** — **Two things `yxl build` never drew, found by opening the
  files rather than diffing them.** Both predate the extract work and neither is
  a spec problem; both are now documented where an author would hit them.

  **A form control does not appear in Excel.** The backend writes the legacy
  half — the VML shape and its `x:ClientData` — and not the worksheet's
  `<controls>` element or the per-control `ctrlProps` parts current Excel needs.
  The control is in the file and its linked cell holds the value; Excel draws
  nothing. `docs/spec.md` §20 now warns, and §9 tracks the fix as upstream.

  **A shape with no `fill:` and no `line:` is invisible.** The backend writes no
  `fillRef`/`lnRef` theme style reference, so "unset keeps the theme default" —
  which §18 and `examples/shapes.yxl.yaml` both claimed — was simply false: an
  unset fill is *no fill*, and Excel draws a selectable nothing. The example's
  bare star now carries a fill and says why.

  The lesson is the same one the slice-4 probe taught at the other end of the
  file: a field that reaches the bytes has not necessarily reached the *reader*
  — and bytes that are well-formed have not necessarily reached the *screen*.
  Neither round-trip tests nor `moon test` can see this; only opening the file
  can.

- **2026-07-29** — **`yxl extract` recovers the four things that float over a
  sheet**: shapes, sparklines, form controls, and slicers (`docs/spec.md`
  §18–§21). A workbook using them now extracts to a spec that rebuilds it, and
  extracting that spec's own output twice gives byte-identical text.

  The plan called these "typed records that map onto the schema almost field for
  field", which was true of the *signatures* and not of the reader behind them.
  Probing it — emit a workbook with everything set, reopen it, print what comes
  back — found four fields the file carries and the backend does not hand back,
  none of them visible from the `.mbti`: a form control's size, the font on a
  line of shape text, a shape geometry spelled with a capital, and **the sheet a
  sparkline plots from**. Each is now reported on the way out.

  The sparkline one is the reason the probe was worth doing. The other three are
  gaps; that one is a *plausible wrong answer* — the reader strips everything
  before the `!`, so a sparkline that plotted `Data!B2:E2` comes back plotting
  this sheet's `B2:E2`, and the verify pass compares cells, so nothing would have
  caught it. It is reported whenever a workbook has more than one sheet, and not
  when it has one, where no qualifier could have named anything else.

  Two things had to be *unlearned* rather than translated. A slicer makes the
  backend write a defined name for its own cache, and recovering that name
  alongside the slicer made the spec declare it and then place something that
  declares it again — `DefinedNameDuplicate`, a spec that would not compile. And
  the loader refuses a key that does not belong to its subject's kind (`checked`
  on a button, `markers` on a column sparkline), which a file yxl did not write
  can carry anywhere, so the reader applies those rules too: a spec that says
  less beats a spec that will not load.

  Both "not recovered" notes were reworded after the first end-to-end run showed
  them firing on shapes and controls that had never been given a font or a size.
  Nothing was lost there, and the seam cannot tell the two cases apart — so the
  notes now say that plainly instead of claiming a loss that may not have
  happened.

- **2026-07-29** — **A refactoring pass over the companion-file work.** No
  behaviour change: 577 tests pass, the `.mbti` is untouched, and an extracted
  workbook produces the same files at the same paths.

  The find was **one idea living in three places**. Naming a file beside the
  spec — flatten the sheet's name, number it if it is taken — had a copy in
  `tables.mbt` and another in `pictures.mbt`, and the pictures copy carried a
  comment saying it was "the same flattening the CSV tables use". Saying so is
  not sharing it, and the copies had already drifted in two ways: one recorded a
  claimed name and the other left that to its caller, and one stripped an
  extension by slicing four characters off the end, which is why it could not be
  reused for `.png` at all. `companions.mbt` is now the single place, and
  claiming a name records it in the same breath — the recording is what makes the
  *next* call correct, so leaving it to a caller is what let them drift.

  A comment written during that work also claimed the two kinds share one
  namespace and would otherwise overwrite each other. They do not: a table is a
  `data/…` and a picture an `assets/…`, so they cannot collide. The comment now
  says what is true and why one pool each is enough.

  `@cli.directory_of` had been made public for the executable to make a
  companion's directory, and had no test — the one thing standing between an
  extracted spec and a file written somewhere it will not be found again. It has
  one now, mixed separators included.

- **2026-07-29** — **Windows is experimental**, because it cannot build at all.
  Reordering CI to build before testing turned "some tests fail on Windows" into
  the fact that mattered: `moon build --target native --release` fails there,
  with MSVC's `C1026: parser stack overflow` on `bobzhang/mbtexcel`'s
  `eval_function` — one ~4900-line function matching 365 formula names. It is a
  formula *evaluator* yxl never calls, and it is in a dependency, so neither the
  cause nor the cure is here. We had been just under the limit; the pictures
  commit crossed it, and whatever came next would have.

  Two escapes were tried and both are closed. `clang-cl` is what `moon`'s own
  message asks for and it does get past C1026, but the Windows native path then
  fails to spawn its toolchain — bare name, absolute path, with and without
  `.exe`, and the archiver named too. And the LLVM target cannot be reached at
  all: the backend depends on `moonbitlang/async`, whose `raw_fd` declares
  `supported_targets = "-all+native"`. That is a fact worth having written down,
  since "try LLVM" is the obvious next idea.

  So Windows **runs and reports but does not block**, a tagged release ships
  Linux and macOS without waiting on it, and the README, `install.ps1`, and §9
  all say why rather than leaving a 404 to be puzzled over. Leaving the leg red
  and required would only teach everyone to ignore a red tick; dropping it would
  hide a platform going quietly stale. The real fix is upstream, and this is
  revisited before v1.0 either way — "experimental" is not a state to freeze a
  release policy around.

- **2026-07-28** — **`extract` writes the files its spec names**, which settles
  the one-file-or-many question the phase item left open: **many**. `Extracted`
  grew a `companions` list — a path and its bytes — and `cmd/main` writes each
  beside the spec, making the directory first. Bytes rather than text for both
  kinds, because a picture cannot be a `String` and one sort of companion is
  easier to write out than two.

  A sheet that is *nothing but* a rectangle of plain values becomes a `data:`
  entry and a CSV. Nothing in the model remembers that a region came from a data
  block — `data:` rows become ordinary cells at load time — so this is a
  decision, not a recovery, and the rule is deliberately narrow: a styled cell, a
  formula, a ragged edge, or fewer than eight rows keeps the whole sheet inline.
  A wrong *yes* would silently drop formatting `data:` cannot carry; a wrong *no*
  only leaves a long block, so the bias is towards leaving it alone. `--flat`
  turns it off.

  The verify pass had to grow with it: the spec now *names a file*, so the check
  resolves companions from memory before recompiling. Without that the rebuilt
  workbook would be empty and every cell reported missing — so an empty warning
  list is now the CSV being checked, not skipped. A 48-row sheet extracted this
  way went from 197 `cells:` lines to a five-line spec, and rebuilt to all 196
  cells identical, types included: `007` came back text, `42` a number, `true` a
  boolean, which is the CSV quoting rule read backwards from the loader's.

  **Images and sheet backgrounds landed on the same machinery**, in the same
  change. Their bytes come out unchanged — checked byte for byte against the
  source file *and* against the rebuilt workbook's own media part — and land
  under `assets/`, named for the sheet and cell they sit at, since a workbook
  keeps its pictures as numbered parts and carries no name to recover.

  One loss came out of that, and it is worth naming because nothing would have
  caught it: **a picture's scale does not survive**. The file records how big to
  draw it in absolute units rather than as a factor, and the backend reports the
  factor back as `1` whatever it was, so a scaled picture cannot be told from an
  unscaled one. `layout`'s logo came back at twice its intended size and the
  verify pass said nothing, because verify compares *cells*. Recovering the
  factor would mean reading each image format's own header for its pixel
  dimensions; until then it is reported on the way out, which is the difference
  between a known limit and a silent one.

- **2026-07-28** — **What `extract` still misses, sorted by what it would take.**
  Documentation only. The nine unrecovered sheet features had been listed as one
  undifferentiated pile of "future work", which is the same flattening that had
  conditional formats written off — so each was checked against `.mooncakes/`
  and they turn out to be three different jobs.

  **Typed already, so a translation** (slice 4): form controls, slicers,
  sparklines, and shapes. `get_form_controls` hands back `{ cell, control_type,
  text, checked, cell_link, width, height, current_val, min_val, max_val,
  inc_change, page_change, horizontally }` — `model.FormControl` almost field for
  field — and the other three are the same shape. Shapes carry one caveat already
  on the books: the backend lowercases the `prst` token (§9), so a geometry whose
  name has a capital reads back as one the schema does not name.

  **A reader of its own** (slice 5): charts and pivots. The backend types only
  the anchor and the geometry and hands the thing itself over as a raw part —
  `Chart { reference, xml, … }`, `PivotTable { table_xml,
  cache_definition_xml, … }` — so recovering the model's chart or pivot means
  parsing DrawingML and the pivot cache. Worth doing when a real workbook makes
  the gap hurt, not before.

  **Waiting on a decision already open**: images and sheet backgrounds. Their
  bytes come back whole, so nothing is missing; what stops them is that writing a
  picture out is a *second file*, which is the same one-file-or-many question CSV
  extraction asks. The two should be answered together.

  **Blocked rather than deferred**: print setup (the backend aliases a
  module-level default instead of copying it, so one sheet's setup leaks to every
  later one) and shared-formula followers (`formula_refs` lists the masters;
  nothing public says which cells follow one, though the backend's own `Cell`
  carries a `formula_shared_index`). `docs/spec.md` §22 now says which is which
  in the same three groups.

- **2026-07-28** — **A refactoring pass over the tree `extract` grew into.** Two
  new packages and a schema key had landed in quick succession, so this is the
  tidy-up: no behaviour changes, 550 tests still pass, and the only `.mbti` diff
  is the four helpers below.

  The largest find was **a seven-field `Font` literal written out in full in
  nine files, seventeen times**, usually to set one attribute — `model` had
  `Style::empty()` but no `Font::empty()`, so there was nothing else to write.
  Adding it (with `is_empty`, which `read` had a local copy of) turned six lines
  of `None` into the one attribute that was the point.

  **A1 range parsing had drifted to three places.** `CellRange` lives in `model`,
  so parsing one now does too — `CellRange::parse`, plus a `parse_loose` for the
  bare `A1` Excel writes when a region is one cell. The loader keeps its own
  corner-by-corner parse *and now says why*: it names which corner is wrong,
  which is the whole of what a diagnostic is for (ADR-006).

  `model.mbt` had reached 540 lines across two unrelated halves and is now split
  at the boundary that was already there: the cell-level vocabulary in
  `model.mbt`, the `Sheet`/`Workbook` containers and their methods in
  `workbook.mbt`. The `Sheet` struct moved to sit with its own methods rather
  than three files away from them.

  Also: the exact-integer bound in `render` was a bare `9.007199254740992e15`
  with no name and a different definition from `emit`'s, and is now
  `EXACT_INTEGER_LIMIT` with the reason (past 2^53 a `Double` cannot tell one
  integer from the next); the `LAST_SHEET_VIEW` that `emit` and `read` must agree
  on now says in both places that it is shared and why there is no third home for
  it; one comment restated the line under it and one referred to a ROADMAP
  section rather than naming the thing (§8.6).

  **`read`'s seam had no test through its trait**, only through the `from_bytes`
  shorthand — where `emit`'s has had one all along. ADR-002's claim is that the
  backend sits behind an interface, and exercising only the convenience wrapper
  left the interface itself unproven. There is one now, plus tests for the four
  new public helpers.

- **2026-07-28** — **`yxl extract` works**: an existing workbook becomes a
  starting spec. Two new packages complete the pipeline in the other direction —
  `src/read` is the reader seam (the mirror of `emit`, and now the only other
  place the backend is touched, ADR-002) and `src/render` the inverse of
  `loader`. `cli.extract` joins them, and `cmd/main` gains the subcommand.
  Recovered: values, formulas, mixed-font rich text, styles **interned** into
  `defs.styles`, merges, column and row bands with adjacent equal ones collapsed
  back into one entry, panes, gridlines, tab colours, sheet order and visibility,
  the active tab, and the date system. `docs/spec.md` §22 says what is not, and
  why.

  **The verify pass earned its place immediately.** ADR-017 asked `extract` to
  compile its own output and compare; the first run on `examples/quickstart`
  reported that the spec would not compile at all, and the second and third runs
  found two more. Every one was a real defect that reading the output would not
  have caught as quickly: `format:` written *inside* an inline style, where the
  schema puts it beside one; and `format:` inside a `defs.styles` entry, which
  the schema did not accept **at all**.

  That second one turned out to be a **schema gap rather than an extract bug**,
  and the same shape as the formula column: `model.Style` has a number format,
  `defs.styles` could not set one, so a "percent" style could be declared
  nowhere and had to be repeated at every cell wearing it — exactly the copying
  ADR-004 exists to stop. `format` is now a style attribute (`docs/spec.md` §6),
  which an author wanted independently.

  Three things the output made obvious once it existed, none of which a test
  would have failed on: every piece of text came back as a one-run `rich:` block,
  because the backend answers `get_cell_rich_text` for *any* shared string;
  every sheet grew a `print:` block of Excel's own defaults; and a hidden column
  past the last populated cell was lost, because the bands were read by scanning
  the used rectangle rather than by reading the `<col>` records. Fixed, and the
  last of those is now a test — a scan would pass every other assertion.

  **Print setup is refused rather than deferred.** The backend's
  `page_layout_with_defaults` (and its margins twin) does `let merged =
  default_page_layout` — an alias of a module-level mutable struct, not a copy —
  then writes the sheet's own values into it. Reading one sheet's setup therefore
  leaks it to every later sheet, and to every later workbook read in the same
  process. A spec built on that would claim the wrong orientation, so it is a
  named refusal with the reason, as top-level `protect:` is.

  **Slice 3 landed in the same change**: notes, hyperlinks, validations, tables,
  and the auto filter's range. The validations needed a new
  `@units.DateTime::from_serial` — a `date` rule's bounds are serials in the file
  and written dates in the spec — which is the inverse of `to_serial` and is
  tested against it by round trip rather than against a table of pairs, since a
  table would test both functions against the same misunderstanding. The verify
  pass caught two more guesses here too: `list_from:` and `length:`, neither of
  which the schema spells that way.

  **Conditional formats came back in after being written off.** The first read of
  the backend's surface said they were out of reach — it hands every rule back as
  one flat options record, a `format_type` and whichever of thirty fields that
  kind happens to use, rather than as the rules the schema names. That is an
  accurate description and the wrong conclusion: it is the *same* record the
  emitter writes, so the information is symmetric and the work is a translation
  after all. Probing it settled the question in one run — all eight kinds came
  back with their criteria, values, colours, icon style, `dxf` id, and stop flag
  intact. Sixteen rule variants are now asserted to round-trip.

  **An audit found what the slices had missed.** Being wrong once about
  conditional formats was reason to check the rest the same way — not by reading
  the notes, but by listing every field of `model.Workbook` and `model.Sheet` and
  grepping which setters `read` actually calls. Seven were never populated and
  none were documented as missing: the default font, the named definitions
  (`defs.values` / `defs.formulas` — an ADR-013 feature, so a real gap), the
  document properties, the calculation settings, sheet protection, the
  very-hidden state, and the sheet background. All but the background are
  recovered now; the background writes a second file, which is the
  one-file-or-many decision this command has not taken yet.

  Two more Excel-writes-it-anyway values had to be suppressed on the way, the
  same shape as the default page setup: a workbook-wide name is scoped
  `Workbook` rather than to nothing, so the first cut dropped every one of them
  as sheet-scoped; and the backend stamps `mbtexcel` as the creator of every file
  it writes, which would have put an author on every extracted spec who wrote
  none of it.

  Verified on the corpus: all ten examples extract, and the eight that need no
  external files are asserted to extract with an empty self-check *and* to
  compile again on their own.

- **2026-07-28** — **A YAML writer**, the first slice of `extract` (ADR-017) and
  the one everything else waits on: `src/yaml` could only ever read, so nothing
  in the toolchain could turn a tree back into text. `@yaml.write` is the seam's
  other direction — block style throughout, quoting only where the syntax forces
  it, and the caller's key order left alone, because the point of the feature is
  a spec somebody would *keep*, and a writer that quoted everything would satisfy
  a round-trip test while producing something nobody would read.

  Its rules are pinned to **what our parser actually does**, probed rather than
  taken from the YAML spec, and the differences matter: it reads `yes` and `on`
  as text but `TRUE` as a boolean, `007` and `1_000` and `0x10` as numbers, and a
  lone `-` not at all. So the quoting test is conservative at the first character
  — anything that is not a letter, `_`, `$` or `/` is quoted — which covers every
  indicator and every way a number can start in one rule instead of a list that
  would drift from the parser. A whole float also has to be written `1.0`, since
  `Double`'s own rendering gives `1`, which would come back an *integer*.

  Proven twice over. The round trip `parse(write(n)) == n` runs on hand-built
  nodes for the awkward cases, and on **every spec in `examples/`**, which comes
  back as the identical tree — and, for the specs that need no external files,
  re-compiles to **byte-identical workbooks**. That second check is a rehearsal
  of the verify-then-emit ADR-017 asks of `extract` itself.

  Two limits are recorded rather than fixed. The parser refuses an empty mapping
  key in both directions, so there is no such `Node` to write — `write` is the
  inverse of `parse` for every node `parse` can *produce*, which is the contract,
  and a test names the gap so a parser swap (ADR-010) notices it lifting. And
  **comments do not survive**, because they are not in the tree: `write` is for
  text yxl generates, not for reformatting a spec somebody wrote by hand. It is
  no loss for `extract`, whose input is a workbook and so has no comments to
  carry, but it is the reason this is not also a formatter.

- **2026-07-28** — **Filled formula ranges**, the schema gap `extract`'s scoping
  found, pulled ahead of it. `formulas: [{ at: D2:D500, formula: "B2*C2" }]`
  writes a calculation column once: the formula is written as it applies at the
  range's top-left cell, and every other cell gets it with its relative
  references shifted. Before this, the commonest structure in a real workbook
  was 499 near-identical `cells:` lines that a single row insert rewrote — and
  `defs.formulas` was no help, since a `$ref` compiles to a defined name and
  would give every row the *first* row's answer. That confusion is now a named
  refusal rather than a wrong number.

  It compiles to Excel's **own shared formula** (ECMA-376 §18.3.1.40), so a
  500-row column stores one formula and Excel does the shifting on open — which
  is what ADR-004 asks of anything the spec declares once, and is checked rather
  than asserted: the round-trip test reads back three calculating cells and one
  stored formula. Verified outside our own backend too, with `openpyxl`, which
  reports `D3` as `=B3*C3` from a file that never stored that text.

  Two deliberate refusals keep it honest. A range may not overlap another range
  or any cell `cells:`/`data:` writes — a cell holds one formula, and a
  precedence rule would hide the clash; refusing can be relaxed later, whereas a
  precedence rule could not be tightened. And an absurd `A1:A99999999` is
  refused by a ceiling of one full column, because a cell reference is not
  bounded to Excel's grid where it is parsed and the emitter would otherwise
  build cells until the machine gave up. Formatting stays out, as it does for
  `data:` — a `columns:` band is where it belongs, and it reaches every cell the
  range fills, followers included. That last part needed doing on purpose: the
  backend creates those cells, so they are in no `cells` array for the emitter to
  walk with the rest, and a calculation column formatted only on its first row
  would be worse than one formatted on none.

- **2026-07-28** — **Fixed: a `columns:` / `rows:` band's styling never reached
  the cells the spec wrote.** Found by opening a workbook in Excel while
  checking something else, and it had been shipping since Phase 4: bands set the
  style on the column and row *records* only, and `<col style>` governs just the
  cells a sheet has not allocated (ECMA-376 §18.3.1.13) — a `<c>` with no `s`
  attribute is style 0, not "inherit from the column". So a band's `format:`
  reached exactly the cells nobody wrote. `examples/layout.yxl.yaml` shipped
  showing `2400000` where it meant `2,400,000`.

  It made a liar of the arrangement §1 is proudest of. `docs/spec.md` §9 tells
  an author that formatting is *not* part of a `data:` block and to style the
  region with a band — which is what keeps data and formatting separable
  (ADR-005), and which did nothing at all. The emitter now resolves each cell's
  **effective style** — column band, then row band, then the cell's own, layered
  per attribute as `extends` is — and writes it on the cell. Band styles stay on
  the column and row records too, so the empty cells around the written ones
  still match. The style cache already interns equal styles, so the file gains
  ids only for combinations that genuinely differ (ADR-004).

  One exception fell out, and it is **Excel's rule rather than ours**: an
  inherited number format does not apply to a text cell, because a format is
  `positive;negative;zero;text` and a code with fewer than four sections says
  nothing about text. Following Excel here is also what keeps pivots working —
  the backend renders such a cell by applying the *numeric* section to the text,
  so a `Revenue` header under a `#,##0` column read back as the literal `#,##0`,
  and a pivot finds its fields by reading that header. The pivots example failed
  exactly that way before the exception went in. A `format:` written on the cell
  itself is untouched: a request, not an inheritance.

- **2026-07-27** — **`yxl extract` designed and scoped (ADR-017)**; no code yet.
  Reading the backend settled two premises Phase 10 had guessed at. The reader
  is **not** the constraint — there is a getter for essentially everything yxl
  emits, and `get_cell_style` returns a style *id*, so the `defs.styles`
  interning the phase item hedged about ("if cheap") is the cheapest part of the
  job. The constraint is **yxl's own model**, which is narrower than what the
  reader returns: underline styles, gradient fills, theme/indexed/tint colours,
  and no `Date` at all. So the boundary is the schema itself — recover what it
  expresses, never guess, report every drop — which is self-limiting and cannot
  become the round-trip §2 rules out.

  The design question that mattered more than the name was **how compressed the
  output can be**, since a conversion that spills five thousand `cells:` lines is
  correct and useless. The answer rests on what survives compilation: **sharing
  survives, names do not.** Which cells wear the same style is in the file; what
  the author *called* it is gone. So defined names, table names, sheet names, and
  header text are taken verbatim, styles are the only invented names, and they
  get a descriptive name only on strong evidence — a wrong `header` misleads
  where a neutral `style_4` merely says nothing, and renaming means replacing
  every reference, not one definition. Compression is **verified rather than
  trusted**: `extract` recompiles its own output in-process and falls back to the
  flat form for any region that does not match, which is what lets it compress by
  default.

  One gap surfaced and became a **Phase 11 item in its own right**: a formula
  translated down a column (`E2 = C2*D2`, `E3 = C3*D3`) is the commonest
  structure in a real workbook, Excel stores it natively as a shared formula, the
  backend both reads and writes it — and the schema cannot say it, because a
  `defs.formulas` `$ref` compiles to a defined name and gives every cell the
  *same* formula. It is wanted independently of `extract`: an author writing by
  hand hits it today, and it is the same diff-stability complaint as inline
  tables. It lands before the freeze.

- **2026-07-27** — **A performance guardrail that CI can actually enforce**,
  closing the second Phase 10 item. The obstacle was never writing a benchmark
  — one has existed since the entry below — but that a hosted runner is several
  times slower than a laptop and varies run to run, so no wall-clock threshold
  is both safe and useful. The way out is to assert a **ratio measured inside a
  single run**: compile one spec at 400 and at 6 400 rows and compare. Whatever
  slows the large spec slows the small one too, so the machine cancels, one
  number serves all three runners, and what is being guarded is the *shape* of
  the curve rather than a point on it. A constant-factor slowdown deliberately
  does not trip it; `moon bench` reports that, and CI now puts those figures in
  the job summary without asserting on them.

  **The number was calibrated, and calibrating it is what saved the check from
  being decorative.** The obvious reasoning — quadratic costs `factor²`, so a
  ceiling anywhere below that catches it — is wrong here, because the
  pipeline's linear work still dominates at these sizes and dilutes the
  quadratic term. Injecting a real regression (a linear scan of the sheet's
  cells inside the per-cell loop) moved the ratio at a factor of 8 from 7.0 to
  only **15.0** — so the ceiling of 20 that the textbook argument suggests would
  have let a genuinely quadratic loader through, and this entry would have
  claimed a guardrail that guarded nothing. Widening the factor to 16 separates
  the two properly: **16.0 clean against 42.6 with the scan**, with the ceiling
  at 26 sitting a little over 1.6× from each. The healthy pipeline measures
  15.8–16.2 across runs, i.e. almost exactly linear.

- **2026-07-27** — **A refactoring pass over the whole tree**, whose one
  user-visible half is that **"you wrote something that is not one of these" now
  reads the same wherever it is raised**. Five spellings of that diagnostic had
  accumulated — two near-identical helpers (`named`, `spelled`) plus three
  hand-rolled copies — differing in whether the context came first, whether the
  last item got an "or", and whether the list was introduced by "one of". All
  five are one `named`/`unknown_value` pair now, wording the mistake the way the
  unknown-*key* diagnostics always have: `unknown <what> '<x>' in <context>
  (expected 'a', 'b', or 'c')`. A single `or_list` renders every such list, so
  the Oxford "or" can no longer be present in one message and absent in the next.
  Eight test expectations moved with it; `docs/spec.md` quotes none of them.

  The structural half is that the loader's **shared vocabulary is now where
  `expect.mbt` says it is**: `named` lived in `chart.mbt` though six files used
  it, and `parse_range` in `loader.mbt` though seven did. Beside them are three
  new helpers for shapes that were being written out longhand — `anchor_cell` /
  `anchor_range` / `required_at` collapse the twelve verbatim copies of the
  required-`at:` preamble, and `header_row` / `header_names` the three copies of
  "read the column names off the top row" that tables, pivots, and slicers each
  kept. The slicer's copy had drifted: it counted an empty string as a column
  name, where the other two required non-empty text; it now agrees, and the
  order the names are listed back in is column order by construction rather than
  by luck. `.mbti` is unchanged throughout — every one of these is private.

  Also: `Bytes?`-returning `compile` shared by the CLI's `build` and `check`;
  two doc comments that had gone stale (the model's cell value claimed formulas
  and error literals were still to come, with both in the enum below it; the
  YAML seam claimed diagnostics carried no line/col, when a *syntax* error does
  — ADR-010 defers per-*node* spans, which is now what it says); and
  `is_absolute` no longer materializes a whole path's characters to look at
  three of them. No dead code, no file over 500 lines wanting a split, and no
  deprecation warnings were found — `moon check --deny-warn` was already clean.

- **2026-07-27** — **A compile benchmark**, opening the performance phase.
  `moon bench src/cli --target native --release` times a generated spec through
  the whole pipeline and through parse, load, and emit separately, so a
  regression names a stage instead of only a total. The spec is generated rather
  than read from `examples/`, which keeps the size a knob and the benchmark
  I/O-free like the pipeline it measures. The first finding is that **cost is
  linear**: 2 000 cells take 6.35 ms and 20 000 take 64.1 ms, a 10.1× for a 10×,
  so the phase's first item needs a workbook that is actually too slow before it
  needs streaming. Roughly 40 % of the time is the YAML parse and 38 % the emit.
  No CI threshold yet — that is the guardrail half of the item.

- **2026-07-27** — **Slicers**, closing Phase 9's second slice. `slicers:`
  floats the button panel that filters an Excel table: `table:` names a
  *declared, named* table — found case-insensitively and workbook-wide, so
  the panel may sit on a dashboard sheet while the data lives elsewhere —
  and `column:` one of its header cells, with a caption, a pixel size, and
  the header toggle. A slicer is entirely references, like a pivot, so both
  are proven by a whole-workbook check beside `check_tables`: a table nobody
  declares (or one the spec left unnamed) and a column the table lacks are
  each diagnostics naming what exists.

  The several linked parts the roadmap warned about were verified against
  the package by hand: `xl/slicers/slicer1.xml`, the cache with its
  `x15:tableSlicerCache` pointing at the right table column, the worksheet's
  `x14:slicerList` extension, the workbook's `x15:slicerCaches` extension,
  the `Slicer_…` defined name, and both content-type overrides. **Pivot
  slicers stay out**, as scoped: they touch the cache machinery
  [office.mbt#264](https://github.com/moonbitlang/office.mbt/issues/264)
  showed to be fragile, and `docs/spec.md` §21 says so. The modular example
  gained a region slicer over its CSV-fed table.

- **2026-07-27** — **Workbook-level `protect:` refused: it corrupted every
  file that used it.** The form-controls manual check opened the interactive
  example and Excel reported it *corrupt, repair refused* — the first
  Tier-3 look at a workbook carrying top-level `protect:`, which shipped in
  the first slice before the open-it-in-Excel obligation existed. Eleven
  probe workbooks bisected the failure feature by feature: controls,
  comments, links, validations, the filter, and sheet protection each opened
  clean alone and in pairs; the one that failed held workbook protection.
  The part told the rest: the backend writes `<workbookProtection>` after
  `</sheets>` and `definedNames`, and CT_Workbook wants it before
  `bookViews` (ECMA-376 §18.2) — a schema violation in the workbook's
  load-bearing part, which is why Excel refuses even to repair.

  So top-level `protect:` is now a **named refusal with the reason** (the
  pivots-`filters:` arrangement), documented in `docs/spec.md` §16; sheet
  protection and `locked: false` styles are untouched and verified clean.
  The interactive example drops its `structure: true`. Recorded in §9
  pending an upstream report.

- **2026-07-27** — **Form controls.** `controls:` puts a button, check box,
  option button, scroll bar, spin button, group box, or label over the grid.
  A control writes into its **linked cell** — a boolean, an option index, or
  a number — which is what formulas react to, and the other half of the story
  sheet protection tells: lock the sheet, unlock the entry cells, and let the
  controls drive the rest.

  The largest vocabulary of the slice, enforced kind by kind rather than
  pooled: `text` belongs to the captioned kinds, `checked` to the two
  tickable ones, `link` to the four that write a value, `min`/`max`/`step`/
  `value` to the two ranged ones, `page` to the scroll bar alone (a spin
  button has no trough), `horizontal` to both sliders — and a misplaced key
  is a diagnostic naming where it belongs, which is exactly what Excel's own
  "Format Control" dialog expresses by greying things out. Values keep to the
  dialog's 0–30000; `min` above `max` is refused; a `link` is a same-sheet
  cell because the backend validates it as a bare reference.

  No `macro:`, deliberately: an `.xlsx` carries no macros (§2), so a button
  without one is a caption that clicks — the spec says so rather than
  accepting a key that does nothing. The interactive example gained a
  "Rush order" check box and a priority spin button beside its form rows.
  One trap the manual check caught: on a protected sheet Excel writes a
  control's value into its `link` like any other edit, so a locked target
  makes the control refuse — the linked cell needs `locked: false` exactly
  as a typed-into cell does, and `docs/spec.md` §20 now says so.

- **2026-07-27** — **Sparklines.** `sparklines:` puts a chart inside a cell —
  a line, a column per point, or win/loss — for the row of figures beside it.
  Each entry is a *group*, Excel's own unit of styling and scaling: one
  sparkline via `at`/`data`, or several via `cells:`, sharing the kind, the
  high/low/every-point markers, manual whole-number bounds, the line weight,
  and the colours those marks can show. `data` may name another declared
  sheet; naming an undeclared one is the same diagnostic a chart gets.

  The x14 extension the backend writes was **verified against the part**, per
  this item's standing instruction, not just round-tripped: the
  `{05C60535-1F16-4fd2-B633-F4F36F0B64E0}` URI, both namespaces, win/loss
  spelled `stacked`, and `<xm:f>'Data'!B2:E2</xm:f>` sheet-qualified and
  quoted as Excel writes it.

  Three backend gaps surfaced, the first two bounding the schema: the
  *first point*, *last point*, and *negative points* markers are carried as
  options nothing can set, so `first:`/`last:`/`negative:` are refused by
  name with the reason; the reader strips the sheet prefix off a read-back
  `range_ref` (tests note it; the file is right); and `manualMin`/`manualMax`
  are written without the `minAxisType`/`maxAxisType="custom"` attributes
  ECMA-376 wants beside them — whether Excel honours the bounds anyway is
  exactly what the manual check watches, and `min:`/`max:` become a refusal
  if it does not.

- **2026-07-27** — **Sheet backgrounds.** `background: assets/logo.png` on a
  sheet tiles the image behind its cells, like a watermark. The smallest kind
  of feature this project gets to land: the `BytesResolver` seam, the format
  list, and the extension/emptiness diagnostics all arrived with images, so
  this is one model struct, one loader function, and one emitter line. The
  backend reader hands the background straight back, so the round trip asserts
  bytes, extension, and content type; the layout example gained the watermark,
  and the docs say the thing that surprises people — Excel shows a background
  on screen and **never prints it**.

- **2026-07-27** — **Duration cells.** `type: duration` beside `type: date`:
  `H:MM` or `H:MM:SS`, hours unbounded — `26:30:00` is twenty-six and a half
  hours, an *elapsed* time, not a clock time. A new `units.Duration` parses it
  and renders the serial (its length as a fraction of a day, independent of
  the date system, so the loader computes it and the model carries a plain
  `Number`); without a `format:` the cell wears `[h]:mm:ss`, whose `[h]` keeps
  counting past 24 instead of rolling into days. Negative durations are not
  part of the syntax: Excel's 1900 date system cannot display one. The golden
  test shows the whole trip — `36:00:00` compiles to `1.5` and formats back as
  `36:00:00` — and the quickstart example gained an "Hours logged" row.

- **2026-07-27** — **Shapes**, opening Phase 9's second slice. `shapes:` floats
  a preset geometry over the grid at a cell, with a pixel `size`, a `fill` and
  a `line` colour (the line optionally with a width in points), `alt` text,
  and the three anchor kinds images spell. `text:` is a plain string or a list
  of lines — a *list gives one font per line*, because the backend writes each
  run as its own paragraph, and saying so in the schema is honester than
  calling it rich text.

  **The geometry table is the story.** The roadmap had anticipated validating
  `kind` against DrawingML's `ST_ShapeType` because the backend writes the
  token unchecked; dumping the emitted drawing part showed it also *lowercases*
  it, and the enum is case-sensitive — `roundRect` reaches the file as
  `roundrect`, a geometry Excel does not recognize. So the accepted table
  holds only presets whose token has no capitals — rectangle, ellipse, the
  polygons through decagon, star_5, plus, chevron, cube, can, donut, frame,
  heart, moon, sun, cloud, pie, line — a test pins the no-capitals invariant,
  and the camel-case kinds an author will reach for (the rounded rectangle,
  right triangle, eight arrows, four callouts) are refused **by name** with
  the reason, exactly as a pivot's `filters:` is. Recorded pending an
  upstream report, with two
  reader defects found alongside (§9): a run written `xml:space="preserve"`
  never reads back, and no run's font does.

  Also not in the schema, by the backend's shape: an `offset` (the shape
  constructor takes none, unlike pictures) and a `scale` (dropped on purpose —
  a shape has no natural size to scale; `size` says it directly).

  Verified in the package: `prstGeom prst="cloud"`, the solid fill, the line
  colour and width in EMU, and one `<a:p>` per text line. New
  `examples/shapes.yxl.yaml`: a cloud stamp over a report, a chevron with a
  bold headline over a plain line, a star pinned with `positioning: fixed`.
  **Awaiting the second slice's standing obligation — open the output in
  Excel by hand — before this is called fully done.**

- **2026-07-27** — **A full pass of the AGENTS.md §8 refactoring checklist**,
  behavior-preserving throughout. What it found, by lens:

  *Duplication and dead code.* The range formatter existed three times and the
  sheet lookup three times; both are now model methods (`CellRange::to_a1`,
  `Workbook::sheet`). Charts and images each validated pixels with their own
  near-copy — one `expect_pixels` (with a per-caller floor) serves both, and
  the two selector parsers share one `parse_selector`. The executable's three
  file-read diagnostics converged on one wording, and the examples corpus now
  resolves included paths with the real `@cli.resolve_path` instead of a
  POSIX-only shadow copy — so the corpus exercises the rule the CLI ships.
  `is_table_style` / `is_pivot_style` had no callers anywhere and are gone,
  as is a smoke test the golden round-trip superseded.

  *Constants.* The Excel limits that lived in the loader (drawing pixels,
  image scale, defined-name length) moved to `model` beside the seven already
  there. The protect allowances became an array serving both the lookup and
  the diagnostic (the pattern the chart/pivot/image tables set), as did the
  alignment and border-style spellings — the match arm and its "expected …"
  list can no longer drift apart. `params`/`defs` keys and the `type: date`
  default formats got named constants.

  *File boundaries.* Three splits, each along a line a sibling file had
  already drawn: `model/page.mbt` (print setup, matching `loader/page.mbt`
  and `emit/page.mbt`), `emit/conditional.mbt` (matching the loader's split,
  and giving `conditional_test.mbt` a source file), and `loader/sheet.mbt`
  (per-sheet loading and presentation, giving `sheet_test.mbt` a source file
  and leaving `loader.mbt` the document entry point).

  *Docs.* The README's pivots-example row claimed a `filters` axis the loader
  refuses by name; corrected, along with the pivot changelog lead below. The
  status blurb now names the remaining features instead of "Phase 9's second
  slice", and `yxl version` joined the command list.

  What the survey found and this pass did **not** do, recorded so it is a
  decision rather than an omission: unifying the ~60 hand-written
  "missing required key" / "unknown key … (expected …)" messages behind two
  `expect.mbt` helpers (a coherent follow-up of its own), per-source test
  files for `model/style.mbt`, `emit/style.mbt`, and `cli/path.mbt`, and a CI
  step exercising the executable's failure exit codes.

- **2026-07-27** — **Phase 9 reorganized into two slices.** The phase ended
  with one unnamed line — "further additive extras as demand warrants:
  sparklines, shapes, form controls, slicers, sheet backgrounds, duration
  cells" — which is a backlog wearing a checkbox: nothing in it could ever be
  said to be done, and a reader could not tell whether any of it was wanted.

  All six are wanted. They are now named and scoped items in a **second slice**,
  ordered by what pays first: shapes and duration cells (which reuse the image
  and cell-type machinery already here), sheet backgrounds (which reuses the
  bytes seam), sparklines, form controls, and slicers last — the only one that
  depends on another feature of ours (`tables:`) and the one whose pivot variant
  touches the cache machinery #264 showed to be fragile.

  Each item now carries what was learned looking the API up rather than a name
  alone: that `shape_type` reaches `prst=` unvalidated, so an unknown preset
  makes Excel repair the file; that a duration is a fraction of a day under an
  `[h]:mm:ss` format and can be computed the way dates already are, keeping
  `moonbitlang/x/time` out of the model; that sparklines are an `x14` extension
  Excel is strict about. And each carries the obligation the pivots earned —
  **open the result in Excel by hand**, because every one of them writes a part
  Excel interprets rather than displays.

  Separately, the sub-features the first slice consciously skipped — filter
  criteria, totals rows, 3-D charts, pivot number formats, and the rest — moved
  out of the prose into a **Not scheduled** list with no boxes, so they are
  visible without pretending to be planned.

- **2026-07-27** — **Pivot tables**, the last of Phase 9's Excel features.
  `pivots:` names a source region, groups it down `rows` and along `columns`,
  and aggregates in `values` — with all eleven of Excel's aggregations, a
  display name per field, its built-in styles, and either grand total. (A
  `filters` axis is refused for the backend defect described below.)

  **A pivot is entirely references**, like a chart: the fields are named after
  the *columns of the source's header row*, never after letters or indices. So
  the checks that matter need the finished workbook, and run beside the table
  ones: the source sheet must be declared, the region must have a header row
  with rows beneath it and more than one column, its top row must name every
  column as text, every field laid out must be one of those names — and the
  pivot may not be drawn over the cells it summarizes. The backend answers each
  of these with `InvalidPivotTable`, which says nothing about where in the spec
  the mistake was written; the diagnostic here lists the fields the source
  actually has.

  **Two ranges, two spellings.** A chart's ranges are quoted and absolute
  (`'Orders'!$A$1:$D$7`), because Excel resolves them as formulas. A pivot's are
  *split* by the backend on the first `!` and each half written into the XML as
  it stands, so quoting the sheet would name a sheet that does not exist —
  `InvalidSheetName`, found by trying it. They go bare (`Orders!A1:D7`). That
  split is also why a sheet whose name holds a `!` — which Excel permits — is
  refused for a pivot: nothing downstream could tell the name from the
  separator.

  **The file carries no summary.** The cache is written empty and marked
  `refreshOnLoad="1"`, so the numbers appear when Excel opens the workbook. That
  is the same arrangement as a formula, which `yxl` also emits without
  evaluating (§2), and it is what keeps a pivot honest when its source changes.

  **Opening the result in Excel is what found the rest of this entry**, and is
  the argument for the "does Excel open it cleanly" gate in §9: the round-trip
  tests were green while one of the two pivots in the example showed `#SPILL!`.

  Two defects in `bobzhang/mbtexcel`, both reported upstream as
  [office.mbt#264](https://github.com/moonbitlang/office.mbt/issues/264):

  1. **`<location>` is written with fixed attributes** — `firstHeaderRow="1"
     firstDataRow="2" firstDataCol="1"`, and no `rowPageCount` /
     `colPageCount`. Those values describe a pivot of rows and values only, so
     a pivot with a **page (filter) field** contradicts its own declared
     location and Excel cannot place it. A six-layout workbook opened in Excel
     narrowed it precisely: rows, columns, columns-without-rows, and two row
     fields are all correct; both layouts with a filter show `#SPILL!`.
  2. **Every pivot is written with `cacheId="1"`** while `xl/workbook.xml`
     numbers the caches 1, 2, … Excel resolves a pivot's cache by that number,
     so a second pivot over a *different* source silently summarizes the
     first's data. No error anywhere: the workbook opens, the pivot draws, the
     numbers are wrong.

  So the schema accepts only what survives: `filters:` is refused **by name**,
  with the reason, rather than left to look like an unknown key — and every
  pivot in a workbook must share one source, which keeps the `cacheId`
  collision harmless. Both restrictions are documented in `docs/spec.md` §14 as
  backend limits, not as design.

  Verified in the package: `pivotTable1.xml` with `axisRow` / `axisCol` fields
  resolved to their source positions, `<dataField fld="3" name="Total revenue"
  subtotal="sum"/>`, `colGrandTotals="0"` where asked, the style info, and
  `pivotCacheDefinition1.xml` pointing at `<worksheetSource ref="A1:D7"
  sheet="Orders"/>` (ECMA-376 §18.10). New `examples/pivots.yxl.yaml`: two
  summaries of one order log.

- **2026-07-27** — **Images**, and the seam they needed. `images:` anchors a
  picture at a cell, with alt text, a scale, an offset in pixels, and Excel's
  three anchor kinds — spelled the way its "Size and Properties" pane spells
  them (`move`, `move_and_size`, `fixed`) rather than as OOXML's `oneCell` /
  `twoCell` / `absolute`.

  **This is the first spec key that names a file the loader cannot read as
  text**, and the include resolver has no bytes to give. Rather than widen
  `Included` — which would make every text resolver invent bytes it does not
  have — the loader gained a second seam of the same shape: `BytesResolver`,
  defaulting to one that refuses with a diagnostic. The CLI supplies both, and
  the core still opens nothing itself (ADR-003). Tests pass an in-memory map,
  so nothing but the `examples` package touches the disk.

  The bytes travel into the model and then into the package, so a compiled
  workbook carries the picture and no longer needs the file. The format comes
  from the file's extension, because that is what Excel decodes by — it never
  inspects the bytes — so an unknown extension, a name with no extension, and
  an empty file are each diagnostics rather than a workbook Excel opens with a
  broken-image box.

  One backend detail worth recording: `add_picture_from_bytes` wants the format
  as a *suffix*, dot included (`.png`). Passing `png` fails with
  `UnsupportedFeature`, which says nothing about the dot; the model keeps the
  bare extension and the emitter adds the dot at the seam.

  **And one Windows-only CI failure worth the note in §9.** Reading the result
  back with `Workbook::get_pictures` turned the Windows build red with
  `fatal error C1026: parser stack overflow` — pointing at `mbtexcel`'s formula
  builtins, which nothing here calls. That reader also looks for images *inside*
  cells, which reaches the formula evaluator, which stops it being dead-code
  eliminated, whose thousand-armed dispatch MSVC then cannot parse. Confirmed by
  the symbol tables: the two failing test binaries carried `FormulaParser`, the
  passing ones did not. Reading `Worksheet::images()` instead — the drawing
  pictures, which is what `yxl` emits — is both the fix and the better-matched
  API, and matches how the chart tests already read charts.

  Verified in the package: `xl/media/image1.png` byte-identical to the source,
  a `twoCellAnchor` for `move_and_size` at col 3 / row 1 (D2) with `colOff`
  38100 EMU = 4 px, the alt text in `descr`, and the drawn size — 16 px scaled
  ×2 = 304800 EMU — worked out by the backend from the image's own dimensions.
  `examples/layout.yxl.yaml` now carries a logo (`examples/assets/logo.png`).

- **2026-07-27** — **Charts.** `charts:` anchors a chart at a cell: fourteen
  kinds, one or more series, a title, legend placement, a size in pixels, and
  per-axis title and bounds. A chart holds no values — every part of it points
  at a range — so it needed no new cell machinery, only references written the
  way Excel writes them.

  **Which is the thing worth getting right.** A chart lives in a part of its
  own (`xl/charts/chart1.xml`), where a bare `B2:B4` names nothing, so every
  range is emitted sheet-qualified and absolute: `'Figures'!$B$2:$B$4`. The
  quoting helper `absolute_range` already did for print areas came out as
  `quoted_sheet` and is now shared. A series may name another sheet, and that
  sheet must be declared — checked beside the other cross-sheet references,
  because a chart plotting a sheet that does not exist opens as an empty frame
  saying nothing about why.

  **A series name is either a literal or a cell**, and the backend tells them
  apart by looking for `!`. So `name: Data!B1` would silently become a lookup
  the author never asked for — it is refused, pointing at `name_from`, which is
  the key that *does* read a name from a cell (usually the column header, so
  renaming the header renames the series).

  Fourteen kinds rather than the backend's fifty-odd: the 3-D variants, stock
  charts, and bubble charts each want either an extra range per series or a
  shape the spec has no way to describe, and half-supporting them would be
  worse than not listing them. The spelling table doubles as the diagnostic's
  list of what *is* accepted, so the two cannot drift.

  Verified by reading `chart1.xml` and `drawing1.xml` out of the file: the two
  series with their `strRef` / `v` names, both axis titles, `<c:min val="0"/>`,
  `<c:legendPos val="b"/>`, and the anchor at `E2` with `cx="4953000"` —
  520 px at 9525 EMU each (ECMA-376 §20.1.2.1).

  New `examples/charts.yxl.yaml`, since the feature earns a cookbook page of
  its own: a column chart of two series named from their headers, a pie, and a
  bar chart on a second sheet plotting the first.

- **2026-07-27** — **Excel tables.** `tables:` declares a region to *be* a table
  (a ListObject) rather than merely look like one: filter buttons, banded
  shading, a name formulas can use (`=SUM(Revenue[Revenue])`), and a range Excel
  extends as rows are typed beneath it. The cells stay ordinary cells — a table
  says what a region *is*, so it composes with `cells:` and with `data:`, and
  the `modular` example now declares the region its CSV fills a table.

  **The backend derives the column names from the header cells already in the
  sheet**, which fixes the emission order (tables after cells) and, more
  importantly, means an empty header cell makes it *invent* `Column1` and write
  that into the sheet — a spec's grid, edited by the compiler. So the loader
  checks the header row instead: every column named, as text, no two the same
  (Excel compares them ignoring case). A number is not a column name; quote it.

  Four more checks that Excel would otherwise answer with a repair — silent
  about what it dropped: a name that breaks Excel's defined-name rules, a name
  another table already uses (workbook-wide, ignoring case), two tables sharing
  a cell, and a table overlapping the sheet's own `filter:`. They need the
  finished workbook, so they run beside the cross-sheet reference check rather
  than while the entry is read.

  **A one-row range is refused rather than grown.** The backend extends
  `A1:C1` to `A1:C2`, matching Excel's own dialog; in a compiler that means
  covering a cell the spec never mentioned, so it is a diagnostic. For the same
  reason `header: false` is deferred: the backend *drops* the first row of a
  headerless table's range (excelize parity, verified in its own parity tests),
  so `at` and the emitted range would disagree.

  Style names are checked against Excel's built-in set (`TableStyleLight1`–
  `21`, `Medium1`–`28`, `Dark1`–`11`) because Excel falls back to its default
  for a name it does not know, which would read as a backend bug rather than a
  typo.

  Renamed `loader/table.mbt` to `data.mbt` (and `load_table` to
  `load_data_entry`): it loads `data:`, and leaving "table" on it next to the
  new `tables:` would have been a trap.

  The emitted part was read out of the file by hand, not only round-tripped:
  `<table ref="A1:C3">` with a `tableColumn` per header, `<tableStyleInfo>`
  carrying the four toggles, the sheet's `<tableParts>`, and the relationship
  and content-type entries that make Excel see it (ECMA-376 §18.5).

- **2026-07-26** — **Protection**, at three levels: the workbook's structure,
  a sheet's cells, and — the one that makes the other two useful — a style's
  `protection: { locked, hidden }`. Excel locks every cell by default, so
  protecting a sheet freezes all of it; unlocking a style is the only way to
  build a form somebody can fill in, and shipping sheet protection without it
  would have been shipping half a feature.

  A sheet's `allow:` grants what a reader may still do, and anything unnamed
  keeps Excel's default — selection allowed, everything else blocked, exactly
  what its own dialog opens with. **The polarity was worth checking rather than
  assuming**: OOXML's `sheetProtection` attributes mark what is *locked*, while
  the backend's options (and ours) name what is *allowed*, and getting that
  backwards would have produced workbooks that quietly permitted everything.
  Verified in the emitted XML.

  **`with_protection` does not exist in the backend.** It builds a style by
  seeding from one attribute and layering the rest with `with_*` builders, and
  protection has only a from-scratch constructor — so a style cannot carry both
  a number format and cell protection. Rather than drop one silently, the
  emitter refuses, names the number format so the author can find the style,
  and the spec documents the limit. It is the first place `emit` raises a
  diagnostic of its own rather than wrapping the backend's, so the seam's error
  wrapping now passes ours through untouched.

  **File encryption is deliberately excluded.** `write_with_password` changes
  the emitter's signature and needs the CLI to carry a secret; that wants its
  own decision. A *protection* password is only anti-accident — Excel stores a
  hash, removes it for anyone who asks, and the file's contents are readable
  regardless — so the spec says so outright and points at `${…}` + `--set`
  rather than inviting a password into a version-controlled file.

  One testing limit recorded: the backend's reader does not surface a sheet
  password's hash, so the round-trip test asserts only that adding one disturbs
  nothing else. That the hash reaches the file was read out of the XML by hand;
  asserting it would need a zip reader the test packages do not have.

- **2026-07-26** — **Refactoring pass over the whole tree.** No behaviour
  changed; 257 tests pass.

  **Split at a boundary, not at a line count.** `loader/decorations.mbt` had
  reached 525 lines holding four unrelated schemas: `filter:` (7 lines),
  `links:` (60), `comments:` (37), and `validations:` (~290). Validations are as
  substantial as conditional formats, which already had a file, so
  `validation.mbt` came out at the same boundary and the tests followed. What
  stays behind is what the two big files *share* — the comparison vocabulary and
  sheet-qualified range parsing — which the header now says outright.

  **Tests moved to sit beside what they test:** the `comments:` tests had been
  written into `properties_test.mbt` and now live in `decorations_test.mbt`,
  where the loading does.

  **Three untested error paths covered:** an unterminated quoted sheet name
  (`'Lists!A1:A3`), a list source that is a cell rather than a range, and a
  conditional bound given a sequence.

  **Excel's rank caps** (1000 by count, 100 by percent) became named constants
  in `model`, where every other Excel limit already lives.

  Two documentation drifts fixed: the README's example table had not caught up
  with the notes and document properties added to `interactive` and `layout`,
  and `load_filter`'s doc comment described a duplicate `filter:` key
  "replacing" the first — which cannot happen, since the YAML parser rejects a
  repeated mapping key before the loader sees it.

  One finding deliberately **not** acted on: the sheet and top-level key lists
  are written twice, as `match` arms and again in the unknown-key diagnostic.
  Extracting a constant would have matched the `VALIDATION_RULES` pattern
  without matching its reason — those lists appear in *two* diagnostics, so
  naming them removes a copy, while these appear in one, so naming them removes
  nothing and still leaves two places to edit.

- **2026-07-26** — **Notes, document properties, and calculation settings** —
  the rest of Phase 9's schema-only features, leaving only the ones that need
  new machinery (tables, charts, images, pivots, protection).

  **`comments:`** completes the range-keyed group: text on its own, or
  `{ text, author }`. A note decorates a cell exactly as a link does — the value
  shown is still the cell's own. Leaving the author out does *not* omit it: the
  file always carries one, so Excel writes a generic name. The model says so
  and a test pins it, because the tempting doc comment ("`None` leaves it out")
  would have been wrong.

  **`properties:`** is what the file says about itself — title, subject, author,
  keywords, description, category, status, language, version, company, and any
  number of `custom:` names of the author's own devising. An unset key is left
  out of the file rather than written empty, so a reader can tell "not said"
  from "said to be blank". A custom value keeps its YAML type, and a whole
  number reaches the file as `<vt:i4>4210</vt:i4>` rather than a float. Excel's
  *date* custom-property type is deliberately not exposed: it wants a full
  timestamp with a zone, and a bare `2026-01-01` would have to be guessed at
  (ADR-006) — write it as text until that is settled.

  **`calc:`** takes `mode` (automatic / automatic_no_tables / manual) and
  `on_load`. It only tells Excel what to do on open; `yxl` still never evaluates
  a formula.

  One deletion worth recording: the loader had grown a duplicate-name check for
  custom properties that **could never fire** — the YAML parser rejects a
  repeated mapping key first, and with a line and column this layer cannot
  supply (ADR-016). Removed, and the test rewritten to assert the guarantee
  where it is actually enforced.

- **2026-07-26** — **Conditional formatting.** A sheet's `conditional:` takes
  rules that decide formatting from the *value* rather than the address: `cell`
  comparisons, a `formula`, `text` matches, `top`/`bottom` by count or
  percentage, `duplicate`/`unique`, color scales, data bars, and icon sets.
  Rules apply in the order written, which is Excel's priority order, and
  `stop_if_true` cuts the rest off.

  **A `cell` rule spells its comparison exactly as a validation does** — the
  same `at_least`, the same `between` — because it is the same code. Generalizing
  `load_comparison` over *how a bound is read* was all it took: a validation's
  declared kind fixes what its bounds may be, while a conditional format infers
  (a number, a date if the text parses as one, otherwise text). One vocabulary,
  learned once.

  **Looks are interned into Excel's `dxfs` table**, which is separate from the
  `cellXfs` ids cells wear — an id from one means nothing in the other, so it
  gets its own cache. Reuse still holds inside it: ten rules sharing a look
  store it once (ADR-004), which a test pins.

  Two rules of the schema are worth stating, both about the same distinction.
  The seven rules that *highlight* **require** a `style` — one without a look
  would match cells and then change nothing. The three that *draw* — a scale, a
  bar, an icon set — **refuse** one, having nothing to apply it to.

  Icon-set names are Excel's own (`3TrafficLights1`, `5Rating`), listed in
  `model` so an unknown one fails as a diagnostic rather than as a backend error
  from deep inside the emitter (ADR-006). A test emits all twenty, so the copied
  list cannot drift from what the backend accepts.

- **2026-07-26** — **Validation, filters, and links** — the first Phase 9 slice.
  A sheet gains three keys that *decorate* cells rather than fill them:
  `validations:`, `filter:`, and `links:`. Grouped because they share that
  property and need no new architecture — no drawing part, no binary asset, no
  reader seam — which is why they landed before charts.

  **`validations:`** covers drop-downs, whose choices are either written inline
  or sourced from cells (`list: { from: "Statuses!A1:A3" }`, another sheet
  included), and `whole` / `decimal` / `text_length` / `date` comparisons across
  all eight OOXML operators, each with Excel's "Ignore blank", input prompt, and
  error dialog. A bound is typed — a number, or a `DateTime` whose serial the
  *emitter* computes, since the epoch is a workbook property the loader does not
  know. Integral bounds are written as integers, so the stored formula reads
  `46023` and not `46023.0`.

  **`filter:`** is `set_auto_filter` over a range. **`links:`** takes a bare URL
  for the common case, or `{ to: … }` for an in-workbook target — never inferred
  from shape, because `Summary!A1` and a URL are both just text. A link supplies
  no display text: Excel shows the cell's own value, so recording one would
  store a string nobody sees.

  Two diagnostics are worth naming, both for failures Excel reports as *nothing
  at all*. A **sheet named** by `to:` or by a list's `from:` must be declared —
  otherwise the drop-down comes up empty and the link goes nowhere, and the
  workbook looks fine until someone clicks. That check runs after the whole spec
  is loaded, since a lookup sheet is routinely the last tab. And an **inline
  list over Excel's 255-character limit** is refused with its actual length and
  a pointer to `from:`, rather than being handed to the backend, which would
  fail far from the spec that caused it.

- **2026-07-26** — **The release path is rehearsed on every pull request.**
  Tagging v0.1.1 failed on Windows: packaging ended in `shasum -a 256`, and Git
  Bash ships GNU `sha256sum`, not Perl's `shasum` — while macOS ships `shasum`
  and no `sha256sum`. Nothing here was subtle; the flaw was that **the Package
  step ran only on a tag push**, so its first execution on Windows was the one
  that had to work. The whole build was green and the release still produced
  nothing.

  Packaging now lives in `.github/scripts/package.sh`, which **CI runs on all
  three platforms on every PR** — it builds the archive, unpacks it, and runs
  the binary out of it, so the layout `install.sh` / `install.ps1` expect is
  checked too. The script tries `sha256sum` then `shasum`, and writes the
  `.sha256` line itself (`<hash> *<file>`) rather than passing through whichever
  tool it found, so the format is identical everywhere and binary-mode
  verification is right on Windows as well.

  The wider lesson, worth stating once: **a code path that only executes during
  a release is untested code, and CI's green tick says nothing about it.**

- **2026-07-26** — **Windows support.** The post-v0.1.0 review's top gap, closed.
  Feasibility was answered by *running* it on `windows-latest` rather than
  reasoning about it: the native backend builds (gcc and clang are preinstalled,
  so no MSVC setup), the binary lands at the same relative path as on Unix, the
  whole suite passes there unchanged, and it writes a real `.xlsx`. Releases now
  ship a `.zip` with `yxl.exe`, `install.ps1` gives Windows the same one-line
  install, and CI runs the full matrix on three platforms.

  **Path separators** were the one real bug: `resolve_path` split on `/` alone,
  so a spec named the way Windows tab-completion writes it —
  `yxl build examples\modular.yxl.yaml` — resolved its includes against the
  working directory instead of the spec's folder. Both separators now split, and
  `C:\…`, `C:/…` and UNC `\\server\share` count as absolute. The rule moved
  from the executable into `cli`: string work, not I/O, so it deserves tests that
  run on every platform (ADR-003 keeps the reading in `cmd/main`).

  **On the yen sign**, since Japanese Windows is a first-class case: that console
  *draws* U+005C as `¥` — the same character in a different glyph — so handling
  `\` handles it. A genuine yen sign (U+00A5, or full-width U+FFE5) is
  deliberately **not** a separator: Windows treats it as an ordinary filename
  character, so splitting there would break a legal name like `見積¥1000.yaml`
  while rescuing nothing. The hazard that does bite is **CP932 dame-moji** — 表,
  ソ, 十 carry 0x5C as their *second byte*, and a byte-wise split would cut such
  a name in half; MoonBit strings are sequences of characters, so this splitting
  cannot. Pinned by unit tests and by a Windows CI job that builds a spec
  referring to `表ソ十\見積¥1000\`.

  **One limitation stands, and it is upstream** (§8 Q10): non-ASCII cannot cross
  the *command line* on Windows. `moonbitlang/x/sys` reads argv as UTF-8 ("TODO:
  Handle other encodings") while Windows hands it over in the system code page,
  so `yxl build 売上\report.yaml` fails — as `???` on an English-locale machine,
  as unusable CP932 bytes on a Japanese one. Paths *inside* a spec are
  unaffected, being read as UTF-8 through `@fs`. Documented rather than worked
  around.

- **2026-07-26** — **Post-v0.1.0 review absorbed into the roadmap.** An outside
  review of the shipped v0.1.0 raised five points; this records where each landed.

  *Accepted and scheduled:* a `cells:`-keyed spec is **not** diffable under row
  insertion, which contradicts §1's headline — §1 now says which half is
  delivered and **Phase 11** adds inline `data:` tables; the **one-way
  direction** is now stated in §1 and §2 rather than left to be discovered, with
  a one-time import separated from continuous round-tripping; and **import** was
  promoted from a post-v1 stretch to a wanted Phase 10 item (§8 Q5), with its
  name and scope left open as §8 Q9.

  *Recorded, not scheduled:* no Windows binary (added to Phase 8 with its
  unknowns spelled out — since **closed**, see above); a **JSON Schema**
  generated from `docs/spec.md` (Phase 11); and the fact that **nothing checks
  Excel opens the output without a repair dialog** — Tier 1 re-opens files with
  the same library that wrote them, which cannot disagree with itself (§9 risks,
  and named in the v1.0 gate).

- **2026-07-26** — **v0.1.0 released; CI trigger and action runtimes tidied.**
  The first release published from a `v0.1.0` tag: Linux x86_64 and macOS arm64
  tarballs with checksums, and the one-line installer live. Two things the run
  surfaced. **CI no longer runs on push to `main`** — with `main` reachable only
  through a PR, and `strict` requiring that PR to be current first, the
  post-merge run re-tested a tree the PR had already passed; tags stay covered by
  `release.yml`, which tests again before packaging. **Node 20 deprecation
  warnings gone**: `actions/upload-artifact@v4` and `download-artifact@v4` still
  target Node 20 and were being forced onto Node 24, so they move to `@v7` and
  `@v8`, whose `action.yml` files declare `using: node24` — the inputs in use
  were checked against those tags rather than assumed. `actions/checkout@v5`
  already runs on Node 24 and was left alone.

- **2026-07-26** — **Release runners: no Intel macOS build.** Caught before the
  first release rather than by it: the matrix named `macos-13`, GitHub's free
  Intel runner, which is **retired** — that job would have failed, and because
  the publish step waits on the whole matrix, the release would have produced
  nothing. Its successors (`macos-15-intel`, `-large`) are larger runners whose
  billing could not be confirmed, so rather than quietly commit the repository to
  possibly-paid minutes, the Intel target was dropped. `install.sh` no longer
  claims a `macos-x86_64` build exists — it sends those users to the source
  instructions instead of a binary that could not run anyway.

- **2026-07-26** — **Release automation, the spec reference, and a PR-only
  `main`.** Three pieces of project plumbing, none of them compiler changes:

  - **`docs/spec.md`** — the exhaustive reference for `*.yxl.yaml`: every
    top-level key, sheet key, cell form, style attribute, band, print setting,
    include, data table, and parameter rule, plus the diagnostics and exit
    codes. Written *against the code*, then checked by compiling a spec that
    exercises nearly every key in it, and by confirming that each construct the
    page calls an error really is one. Doing that caught one mistake: the page
    listed seven Excel error literals where the loader accepts ten. This is the
    "documented spec reference" the v1.0 gate asks for.
  - **`.github/workflows/release.yml`** — a **tag push** (`v*`) builds and
    publishes; merging a PR only runs CI, because a release should be a
    deliberate act. The first job refuses to build unless the tag, `moon.mod`,
    and the version `yxl version` reports agree, so a mistagged release stops
    before it produces binaries. Then Linux x86_64 and macOS arm64 tarballs
    (binary + README + LICENSE + examples, each with a `.sha256`), tested before
    packaging, published with `gh` — no third-party action in the release path.
  - **`main` is PR-only.** Recorded in `AGENTS.md §4` with the branch/PR/tag
    commands, and in the README. `AGENTS.md §1`'s "no separate docs" rule now
    names its three user-facing exceptions (README, `docs/spec.md`, `examples/`)
    so the spec reference is not mistaken for the planning doc the rule forbids.

  - **`install.sh`** — a one-line install, the shape a MoonBit CLI user expects
    (`bit`'s installer was the reference): detect OS/arch, resolve the latest
    release (or `YXL_VERSION`), download, **verify the SHA-256**, and place the
    binary in `YXL_INSTALL_DIR` (default `~/.local/bin`), warning if that is not
    on `PATH`. It then *runs* `yxl version`, which catches a binary that matches
    the platform's name but not the machine. Two deliberate departures from the
    reference: a checksum failure **aborts** rather than warning, and there is no
    "use the arm64 build on Intel macOS" fallback — that cannot work. (A real
    `macos-x86_64` target was added and then removed before any release; see the
    entry above.) Verified against
    a local stand-in release: a good install, a tampered tarball (refused), a
    missing version, and an unsupported platform.

  README's install section now leads with the one-liner and keeps building from
  source as the second path.

- **2026-07-26** — **Verification tier 2 stood up: the `examples/` corpus.** §5
  had promised this tier since Phase 0 and nothing had ever enforced it. There
  are now five worked examples, each one a cookbook page whose comments explain
  the feature it demonstrates: `quickstart` (cell kinds, formats, a formula),
  `styling` (declare-once styles, `extends`, a defined name, rich text),
  `layout` (merges, freeze, sized and grouped bands, sheet visibility, print
  setup), `modular` (`$include` plus CSV and JSON `data:` tables across a
  sub-directory), and `parameters` (`params:` with `${}` and `--set`).

  They are tested twice. A new `src/examples` package — no product code, just
  the test — compiles each spec in-process, re-opens the workbook, and asserts
  named cells hold what the example claims, with a **coverage check both ways**:
  an example with no assertions fails, and so does an assertion naming a spec
  that no longer exists. CI then runs the **shipped binary** over the same
  corpus, which is what exercises the on-disk include/data path resolution and
  the exit codes a user sees.

  Verified by breaking things on purpose: changing a cell's value, adding an
  example with no assertions, and putting an unknown key in an included file
  each fail the suite. The test lives in its own package because the toolchain
  now warns that main packages will stop generating blackbox tests, and because
  neither the I/O-free `cli` (ADR-003) nor the executable entry point should
  host a test that reads files. 136 tests green.

- **2026-07-26** — **Phase 8 complete: three scope decisions, and a roadmap
  tidy.** What was left of the phase is settled by decision rather than code:

  - **`--watch` — out of scope** (§2 non-goals). A build is a single command, and
    anyone wanting rebuilds on change already has `make`, `entr`, or an editor
    task. The call stands on its own; it happens to also sidestep needing a timer.
  - **Carets for *schema* errors — out of scope** (**ADR-016**), closing the
    deferral ADR-010 left to this phase. They would need every `Node` to remember
    its origin, which means vendoring or replacing the parser; the messages
    already name the file *and* the construct (`cell 'A1'`, `column 'B'`), which
    locates the problem about as well in a structured document. The `yaml` seam
    keeps the swap possible if that ever proves wrong.
  - **stdin/stdout — dropped, revisit on demand** (§8 Q8). The workaround is one
    line, so the gain is convenience rather than capability, and the toolchain
    exposes no stdin, no byte-level stdout, and no stderr — so it would have cost
    a large dependency or an FFI stub, plus a rule for what relative paths in a
    piped spec resolve against.

  A consequence worth recording: because there is no stderr API either, `yxl`
  prints diagnostics to **stdout**. `main.mbt` used to call that "a later
  refinement"; it is now noted as blocked, with the reason, rather than implying
  someone forgot.

  Tidied while there: §4 stopped listing a `resolve` package that was never built
  (ADR-012/013 folded it into `loader` and `emit`) and stopped advertising
  `--watch`; §3's I/O principle now describes the resolver the CLI actually
  injects; and §5 admits **Tier 2 does not exist** — there is no `examples/`
  directory, so nothing enforces it — with standing it up named explicitly in the
  v1.0 gate, since that is also what would keep the README from drifting.

- **2026-07-26** — **Phase 8: diagnostics with position, `--check`, `--version`,
  help, install docs.** YAML syntax errors used to read `invalid YAML:
  moonbit-community/yaml.YamlError.YamlError` — the library's error was rendered
  through `Show`, which yields a type name. It is a `pub suberror` carrying
  `mark` (line/col) and `info`, so it is now destructured into a real
  `@diag.Span`, and the span machinery built back in Phase 1 finally carries
  something. `Diagnostic::render_in(source)` adds the quoted line and a caret:

  ```text
  report.yaml:5:8: invalid YAML: while parsing a block mapping, ...
    |
  5 |      B1: y
    |        ^
  ```

  The CLI gained `--check` (run the whole pipeline, write nothing — so a spec
  that passes is one `build` accepts), `version`/`--version`/`-V`,
  `help`/`--help`/`-h`, and a bare `yxl` now prints usage instead of erroring.
  `--check` with `-o` is a usage error rather than a silently unwritten file.
  Exit codes 0/1/2 are documented in both the usage text and the README, and
  verified end-to-end. The README gained install steps whose paths were checked
  against a real `--release` build, and CI now fails if `moon.mod`'s version and
  the constant `yxl version` prints ever drift.

  **Two sub-items are blocked, not skipped.** `--watch` and stdin/stdout need a
  sleep/timer and a stdin that the dependency set does not have: `--watch` would
  busy-spin at full CPU, and nothing can read a spec from a pipe. Both want
  `moonbitlang/async` (a large dependency, so an ADR) or a native FFI stub.
  Likewise, carets for *schema* errors need per-node provenance the sealed
  parser API withholds. Recorded together as **§8 Q7**. 134 tests green.

- **2026-07-26** — **Refactor pass (whole tree), after Phase 7.** No behaviour
  change; 129 tests green throughout. The only public-API delta is three
  functions *leaving* the surface.

  *Duplication.* The scalar inference that reads untyped text as a number or a
  boolean existed twice — in `csv_field` and in `infer_scalar` — which the
  parameterization entry above had already claimed was shared. It is now, and the
  claim is corrected. `expect_scalar` and `scalar_value` also spelled the same
  refusal separately; the message is now one `not_scalar`. The three cycle
  detectors (includes, style inheritance, parameters) each built their diagnostic
  by hand; they now share `cycle_error`.

  *Helpers in the wrong home.* `render_cycle` lived in `include.mbt` but served
  all three cycle checks, and `infer_scalar` lived in `params.mbt` but is a CSV
  concern too. Both moved: cycle detection to its own `cycle.mbt`, the scalar
  helpers to `expect.mbt`, the file that already holds the vocabulary every
  loader shares.

  *Consistency.* `$include` and `params` were named constants while `$ref` — the
  oldest of the three directives — was an inline literal; it is now `REF_KEY`.

  *Public surface.* `Font::over`, `Alignment::over`, and `Borders::over` were
  public but called only by `Style::over`; they are private now, so the model
  exposes merging whole styles rather than their pieces.

  *File splitting.* `loader_test.mbt` had grown to 1699 lines and 61 tests
  covering ten source files. Split along those files — `cell_test`, `style_test`,
  `axis_test`, `sheet_test`, `page_test`, `include_test`, `table_test`,
  `params_test` — leaving `loader_test.mbt` (104 lines) for the shared helpers
  and document-level structure.

  *Docs.* README had gone stale again: it still said modular specs were "ahead"
  and showed none of Phase 7. Rewritten, and its example **verified by compiling
  it twice** — once plain, once with `--set` — so the `$include`/`data:`/`params:`
  /`extends:` it now advertises are all exercised.

- **2026-07-26** — **Phase 7 complete: parameterization (`params:` / `--set`).**
  A spec declares parameters with defaults and substitutes them as `${name}` in
  any string — values *and* mapping keys, so a sheet name, a cell reference, or a
  title composed of parts can all be parameterized. `yxl build … --set
  region=EMEA` overrides a default without editing the spec: one spec, many
  workbooks (ADR-015). A default may use another parameter, so **parameter cycles
  are detected** and reported with the chain, alongside the style-inheritance
  cycles.

  Two rules keep types intact: a string that is exactly one placeholder takes the
  parameter's own type (`B1: "${rate}"` is a number cell), and a `--set` value is
  read as the scalar it looks like — the same inference CSV fields get. Without
  the second, `--set rate=0.15` would quietly turn a number cell into text.
  *(This entry originally claimed the two inferences were already one shared
  `infer_scalar`; they were not — the edit had silently missed. The refactor
  below actually makes it so.)*

  A bug caught while testing: the pass originally short-circuited when a spec
  declared no parameters, which let a stray `${nope}` survive as a literal into a
  cell. It now always runs, so an unbacked placeholder is a diagnostic (ADR-006).

  `Command::Build` gained `params`, and `compile`/`load` an optional `params~`.
  129 tests green. **Phase 7 is complete.**

- **2026-07-26** — **Phase 7: style inheritance (`extends:`).** A style
  definition may extend another — `header: { extends: base, font: { bold: true } }`
  — and so may an inline `style:` mapping. The merge reaches *inside* a font, an
  alignment, and the four border edges, so a child that sets `bold` keeps the
  base's face and size; a fill and a number format are single values with no
  parts to blend, so setting either replaces it. Added `@model.Style::over` (plus
  `Font`/`Alignment`/`Borders::over`), which is the whole public-API delta.

  This is the roadmap's "first construct that can let one definition reference
  another", so **cyclic-reference detection lands for definitions** as promised
  in Phase 5: `a -> b -> a` (and a style extending itself) is reported with the
  chain. Resolution is depth-first and memoized, which also buys *forward
  references* — a style may extend one declared later, so the block reads in
  whatever order suits the author — while the walk keeps declaration order so a
  broken spec reports the same error every time. 122 tests green.

- **2026-07-26** — **Phase 7: external data sources (CSV / JSON).** A sheet
  takes a `data:` list, each entry anchoring a table at a cell:
  `- { at: A2, csv: data/sales.csv }`. Fields become **ordinary cells at load
  time** — the model stays the final grid, and nothing below the loader knows the
  values came from elsewhere. Formatting is deliberately *not* duplicated in the
  data block: a region is styled with the `columns:` / `rows:` bands that already
  exist, which is what makes data and formatting separable (ADR-005). Where a
  `data:` table and `cells:` overlap, the later key wins.

  CSV is parsed in-tree (RFC 4180: quoted fields, doubled inner quotes, embedded
  commas and newlines, CRLF, ragged rows) rather than by a new dependency. Since
  CSV carries no types, a bare field is read as a number or boolean when it looks
  like one and text otherwise, while a **quoted** field stays text — the same
  quoting rule YAML scalars already follow here, so `"007"` survives as text. An
  empty field writes no cell at all rather than empty text.

  JSON uses `moonbitlang/core`'s parser. An array of arrays maps straight to
  rows; an array of objects **requires `columns:`** naming the fields to take and
  their order, because JSON object key order is not dependable and silently
  deriving a layout from it would break determinism (§3.1). `null` is a blank
  cell; a missing named field is a diagnostic rather than a guessed blank.

  **Known limitation, documented in `table.mbt`:** a data path resolves against
  the spec passed to `yxl build`, not against the file the `data:` entry was
  written in — includes expand into one tree before any key is read, and the tree
  carries no per-node provenance. A data path inside an included file in another
  directory therefore fails to resolve, loudly and with the path it tried, never
  silently reading the wrong file. Per-node provenance (the same missing piece as
  source spans, ADR-010) would make both rules agree; noted on the Phase 8
  diagnostics item. 118 tests green.

- **2026-07-26** — **Phase 7 started: includes / data–format separation.** A
  spec may now be split across files: `{ $include: path }` stands wherever a node
  may — a whole document, one sheet, a `cells:` block, a `defs.styles` map — and
  expands before any schema key is read, so every loader below is unaware more
  than one file existed (ADR-005, ADR-014).

  The obvious spelling, YAML's `!include` tag, turned out to be **impossible**:
  the parser's tag-carrying API is a sealed trait (the wall ADR-010 already hit)
  and the value tree drops unknown tags *silently* — verified, a spec with
  `A1: !include x.yaml` compiled successfully with the cell set to the text
  `"x.yaml"`. A directive that degrades to a string on a typo is what ADR-006
  exists to prevent, so `$include` (a directive marker matching `$ref`) is the
  contract. §6 and §8 Q4 are corrected accordingly.

  Reading files stays out of the core (ADR-003): `load` takes an
  `IncludeResolver` that the CLI backs with the filesystem — resolving each path
  relative to the file containing it, so a spec directory moves wholesale — and
  tests back with a map, needing no fixtures. Include **cycles are detected and
  reported with the whole chain**; this is the cycle detection deferred from
  Phase 5, arriving with the first construct that can form a loop. Combining
  `$include` with sibling keys is a diagnostic, not an implied merge.

  Public API: `load` and `compile` gained an optional `resolve~`, and `load` now
  raises `Error` rather than only `@diag.SchemaError` — a syntax error inside an
  included file is a real `@diag.YamlError`, and relabelling it would lie about
  the kind. 113 tests green.

- **2026-07-25** — **Refactor pass (whole tree), after Phase 6.** No behaviour
  change: the same spec compiles to *byte-identical* `.xlsx` output and the
  `.mbti` files show **no public-API diff**. 107 tests green (81 → 107 over the
  phase, +7 in this pass).

  *Duplication.* Four sites hand-rolled "parse an A1 reference or raise a
  diagnostic"; they now share `expect_cell_ref`. The scalar expectations were
  split across two files (`expect_map`/`string`/`seq`/`bool` in `loader.mbt`,
  `expect_number`/`expect_color` in `style.mbt`) — all of them, plus
  `schema_error` and `find_key`, now live in one `loader/expect.mbt`, the
  vocabulary every loader file shares.

  *File splitting*, at concern boundaries rather than line counts: `loader.mbt`
  661 → 341 (print block out to `loader/page.mbt`, mirroring the existing
  axis/cell/style/defs layout); `emit.mbt` 560 → 284 (style translation out to
  `emit/style.mbt`, print setup to `emit/page.mbt`). `build_workbook` was a
  145-line function doing everything; it is now a short driver over `emit_sheet`
  and `emit_cell`.

  *Simplification.* `load_sheet` carried 11 mutable locals to stage keys before
  creating the sheet; it now reads only `name` and `visibility` up front (via
  `find_key`) and applies every other key to the sheet in place — zero mutables.
  `load_page_setup` likewise stopped rebuilding the record per key.

  *Tests.* `model_test.mbt` had not kept up with Phases 4–6: added direct
  coverage for the band setters, `merge` canonicalization, the sheet
  presentation setters, `PageSetup::empty`, `Style::is_empty`, defined names and
  the active sheet, plus `CellRef::rect` in `units`.

  *Docs.* `README.md` was badly stale — it claimed "the compiler is not yet
  functional" (six phases ship) and showed a schema that never existed (a
  `workbook:` wrapper, `A1: { text:, merge: }`, top-level `styles:`). Rewritten
  against the real schema, with the example **verified by compiling it**; the
  package table dropped the `resolve` package that was never built (ADR-013
  folded it into the loader). Two `moon.pkg` comments cited ROADMAP `§4`, which
  §8.6 forbids — a reader without this file cannot follow them.

- **2026-07-25** — **Phase 6 complete: page setup for print.** A sheet takes a
  `print:` block — `area` (a range), `orientation`, `margins` (in inches, Excel's
  unit here), `scale` (a percentage) *or* `fit: { width, height }` (pages across
  and down), `header` / `footer` (Excel's `&`-code syntax, ECMA-376 §18.3.1.46),
  and `breaks` (cells that each start a new page). Nested under one key rather
  than flattened onto the sheet, so the sheet's top level does not balloon.

  Diagnostics: a scale outside Excel's 10–400, a non-integer scale or page
  count, `scale` together with `fit` (Excel offers one or the other), a `fit`
  constraining neither axis, a negative margin, and a break at `A1`.

  Two details the backend does not handle for us. A print area is not a normal
  setting but Excel's built-in **sheet-scoped defined name** `_xlnm.Print_Area`,
  whose value must be absolute and sheet-qualified (`'Report'!$A$1:$D$50`) —
  hence the new `@units.CellRef::to_absolute_a1`. And `fitToWidth`/`fitToHeight`
  are ignored by Excel unless the sheet also carries `<pageSetUpPr
  fitToPage="1"/>`, so a `Fit` scale sets that flag too.

  Added `model.PageSetup` / `PageMargins` / `PageScale` / `Orientation` and
  `Sheet.page` + `set_page_setup`; renamed `MergeRange` → `CellRange` (a print
  area is the same rectangle a merge is) and extracted the shared `parse_range`
  the loader now uses for both. Round-trip verified through the reader, plus the
  emitted `sheet1.xml` and `workbook.xml` inspected directly. 100 tests green.
  **Phase 6 is complete.**

- **2026-07-25** — **Phase 6: freeze / split panes, gridlines, tab color.** A
  sheet takes `freeze: <cell>` — the rows above and columns left of that cell
  stay put, exactly what Excel's "Freeze Panes" does with the cell selected — or
  `split: { x, y }` for a draggable splitter. Freezing names a *cell* because a
  freeze always lands on a row/column boundary; a split names a *position in
  points* because it falls wherever it is dragged. `freeze: A1` (freezes
  nothing), a split with no non-zero axis, and combining `freeze` with `split`
  are all diagnostics. Also `gridlines: false` (the on-screen grid, not cell
  borders) and `tab_color: "RRGGBB"`, reusing the existing color parser.

  Added `model.Panes` (`Freeze` / `Split`) plus `Sheet.panes` / `gridlines` /
  `tab_color` with setters; the emitter maps them to `set_panes`,
  `set_sheet_view`, and `set_sheet_props`, converting a split's points to the
  twips OOXML wants — a backend unit, so the conversion stays behind the seam
  (ADR-002). Round-trip verified for the freeze, gridlines, and tab color; a
  split's `state` attribute is *absent* by design (ECMA-376 §18.18.52 defaults
  it to "split"), which the backend's reader does not model, so that case is
  verified on the emitted `worksheets/sheetN.xml` instead. 95 tests green.

- **2026-07-25** — **Phase 6: multiple sheets — order, active sheet, visibility.**
  Declaration order is the tab order (now asserted, not just assumed). A sheet
  takes `visibility: visible | hidden | very_hidden` — a bareword rather than a
  boolean, because Excel has three states here (`ST_SheetState`, ECMA-376
  §18.18.68), unlike a band's two-state `hidden:`. A top-level `active: <name>`
  picks the tab Excel opens on, by name rather than index, so a typo is a
  diagnostic instead of silently opening elsewhere.

  Three fail-fast checks land in the loader rather than at emit time (ADR-006):
  an `active` naming no declared sheet, an `active` naming a *hidden* one (Excel
  cannot open on a tab it does not show), and a workbook whose every sheet is
  hidden (the backend rejects it with a bare EmitError). Added
  `model.SheetVisibility` + `Sheet.visibility` and `Workbook.active_sheet` /
  `set_active_sheet`; `add_sheet` and `Sheet::new` take an optional
  `visibility~`. The emitter hides tabs in a pass *after* every sheet exists —
  hiding inside the sheet loop would trip the backend's "at least one visible
  sheet" guard whenever an early sheet is the hidden one — then resolves
  `active_sheet` through `sheet_index` to Excel's `activeTab`. Round-trip
  verified, plus the emitted `workbook.xml` inspected directly. 91 tests green.

- **2026-07-25** — **Phase 6: column/row visibility and outline grouping.** A
  column or row band takes `hidden: true` (hide the band) and `group: <level>`
  (the outline level Excel draws as a collapsible bracket — `0` is ungrouped,
  `@model.max_outline_level` = 7 is the deepest). Set both to emit a *collapsed*
  group. The level is validated in the loader, so `group: 8` or `group: 1.5` is a
  `SchemaError` with file context (ADR-006) rather than a backend failure at emit
  time. Round-trip verified: visibility and outline level read back, and a
  band's width/height is unaffected by grouping.

  Structurally, the four per-property band arrays on `Sheet` collapsed into one
  `AxisBand` — an inclusive span plus optional `style`, `size`, `hidden`, and
  `outline_level` — held in `Sheet.columns` / `Sheet.rows`. Adding these two
  properties the old way would have taken the sheet from four band arrays to
  eight, and `load_axis_spec` from a pair to a 4-tuple; one band record per YAML
  entry mirrors the spec shape instead. `set_column_style`/`set_column_width`/
  `set_row_style`/`set_row_height` are replaced by `set_column_band` /
  `set_row_band` (all properties optional) — the whole public-API delta in the
  `.mbti`. An entry that sets nothing (`- at: B`) still contributes no band. 88
  tests green.

- **2026-07-25** — **Phase 6 started: merged cells, column widths, row heights.**
  A sheet may carry `merges:` — a list of `A1:B2` range strings, each merged into
  one cell (corners in any order; the model canonicalizes to top-left /
  bottom-right, and the merge keeps the top-left value). A column band gains an
  optional `width:` (character units) and a row band an optional `height:`
  (points), alongside the existing `style`/`format`; `width` is rejected on a row
  band and `height` on a column band. Added `model.AxisSize` and `MergeRange`,
  `Sheet.column_widths`/`row_heights`/`merges` with their setters and
  `@units.CellRef::rect` (corner canonicalization), wired the loader
  (`load_axis_spec` now returns a style *and* a size; new `load_merges`) and the
  emitter (`set_col_width`/`set_row_height`/`merge_cell`). Round-trip verified:
  the merge range, width, and height read back, and the merged cell keeps its
  value. **Phase 5 is complete** (item 4 tidied: dangling diagnostics done;
  cyclic-reference detection deferred to Phase 7 templating, where definitions
  first reference one another). 85 tests green.

- **2026-07-24** — **Refactor pass (whole tree).** Deduplicated the `units`
  scalar parsers: `CellRef::parse_a1` no longer hand-rolls base-26 and digit
  loops — it splits the letters from the digits and reuses `parse_column` and
  `parse_uint`; `parse_column` now takes a `StringView` (matching `parse_uint`),
  and the loader's `split_range` yields `StringView`s straight into both (dropping
  its `.to_owned()` copies). Replaced ASCII magic numbers (`65`/`48`/`32`) with
  char-literal arithmetic (`'A'.to_int()`, `'0'.to_int()`) and `color`'s hex
  upper-casing loop with `Char::to_ascii_uppercase`. Refreshed two stale package
  docs: the `yaml` seam no longer claims per-node source spans (it has none yet —
  ADR-010), and `units` lists colours/dates as present, not future. No behaviour
  change; the only public-API delta is the intentional `parse_column` →
  `StringView` signature. 81 tests green.

- **2026-07-24** — **Phase 5: references compile to Excel defined names.** Every
  `defs.values`/`defs.formulas` entry now emits a workbook **defined name** (in
  declaration order, deterministic), and a `{ $ref: name }` reference compiles to
  the formula `=name` — so editing the defined name in Excel updates every
  reference (§1's "change once, changes everywhere"). A referenced value caches
  its constant (`=tax_rate` shows `0.08` until recompute); a `refers_to` literal
  quotes strings (inner quotes doubled) and passes numbers/formulas through. The
  other two native mechanisms in the item were already satisfied — repeated
  strings dedupe into shared strings, styles intern to one `cellXfs` id (ADR-004);
  shared *formulas* (fill-down) await a range construct and aren't referenced.
  Added `model.DefinedName` + `Workbook.defined_names`/`add_defined_name`, wired
  the loader to register names and resolve refs to `=name`, and the emitter to
  `set_defined_name`. Recorded as **ADR-013**; the dedicated `resolve` package
  stays deferred (this fit in loader + one model field). Round-trip verified:
  defined names and the `=name` cell survive re-opening. 81 tests green. **Phase 5
  item 3 done; only item 4's cyclic-ref case remains (dangling already covered).**

- **2026-07-24** — **Phase 5 started: named definitions + references (declare
  once).** A spec may carry a top-level `defs:` block — `styles`, `values`, and
  `formulas` maps — that declare a look, constant, or formula once. Cells,
  columns, and rows reference a style by bareword name (`style: header`); a cell
  references a named value or formula by `{ $ref: name }` (e.g. `A1: { $ref:
  company }` as a whole-cell shorthand, or `{ value: { $ref: rate }, format:
  "0.00%" }` / `{ formula: { $ref: subtotal } }` inside the expanded form). A
  referenced style still layers the cell's own `format` on top, and references
  resolve to the same model value/style so the emitter's existing interning
  (ADR-004) collapses them to one shared string / `cellXfs` id. YAML anchors were
  rejected as the mechanism (the parser expands aliases to copies, losing the
  identity needed for Excel-native sharing) — recorded as **ADR-012**, which
  resolves **Q3**. A reference to an undefined name is a fail-fast
  `@diag.SchemaError`. Resolution lives in the `loader` for now; Excel *defined
  names* / *shared formulas* (Phase 5 item 3) will add the `resolve` package.
  80 tests green. **Phase 5 items 1–2 done; items 3–4 (native-sharing compilation,
  cyclic-ref diagnostics) remain.**

- **2026-07-24** — **Refactor pass (whole tree).** Deduplicated: the two
  top-level key scans (`read_date_system`/`read_default_font`) now share a
  `find_key` helper, and `load_sheets` uses the existing `expect_seq`; the
  loader's row-index digit parser reuses `@units.parse_uint` (promoted to public)
  instead of a second hand-rolled loop; the emitter's underline OOXML mapping is
  a single `underline_code` helper shared by the cell-font and rich-text-font
  builders. Moved `combine_style` from `cell.mbt` to `style.mbt`, co-locating
  style assembly with the style parser it drives. Refreshed the stale `load`
  doc comment (it predated `style`/`rich`/`columns`/`rows`/`default_font`) and
  added direct unit tests for `@units.parse_column` and `@units.parse_uint`. No
  behaviour change; only public-API delta is the intentional `parse_uint`
  export. 75 tests green.

- **2026-07-24** — **Phase 4 complete: column/row default styles + workbook
  default font.** A sheet may carry `columns:` and `rows:` — sequences of bands,
  each `{ at: <selector>, style?, format? }` — that apply a default style to a
  whole column/row band; `at` is a column label or range (`B`, `D-F`) or a row
  number or range (`1`, `2-4`). A top-level `default_font: <name>` sets the
  workbook default font face. Band styles share the cell style-id cache, so a
  column style equal to a cell style reuses one `cellXfs` id (ADR-004) — verified
  by re-opening and comparing the cell's, columns', and row's style ids. Added an
  `AxisStyle` model type + `Sheet::set_column_style`/`set_row_style`, a
  `Workbook.default_font`, and `@units.parse_column`; wired the emitter to
  `set_col_style`/`set_row_style`/`set_default_font`. The style parser now takes
  a `context` label so cell, column, and row diagnostics all read naturally.
  (Bands are a **sequence** rather than a selector-keyed mapping because the YAML
  parser rejects bare integer keys, and row selectors are integers.) **Phase 4
  (styling) is done; the active phase is Phase 5 (reuse / dedup engine).**
  73 tests green.

- **2026-07-24** — **Phase 4: rich text.** A cell may hold a `rich:` run list
  instead of a single value — `{ rich: ["Plain ", { text: "bold red", font: {
  bold: true, color: "FF0000" } }] }` — compiling to Excel's inline rich text,
  each run carrying its own optional font (the same `font` shape as a cell
  style). A rich-text cell may still take a cell `style` (alignment, fill,
  borders). Added `CellValue::RichText([RichRun])` and a `RichRun { text, font }`
  model type, wired the emitter to `set_cell_rich_text`, and had the loader
  reject `rich` combined with `value`/`formula`/`type` and an empty run list.
  Round-trip verified: runs and per-run fonts survive re-opening (the backend
  stores colors as ARGB). Only Phase 4 item 5 (column/row default styles + the
  workbook default font) remains. 70 tests green.

- **2026-07-24** — **Phase 4: cell styles (font, fill, border, alignment) with
  interning.** A cell may carry a `style:` mapping —
  `{ font: { bold: true, size: 12, name: Calibri, color: "FF0000" }, fill:
  "FFFF00", align: { horizontal: center, vertical: middle, wrap: true }, border:
  { all: thin, bottom: { style: double, color: "000000" } } }`. The `format:`
  number-format shorthand now folds into the style, so the number format is one
  attribute of the interned look (ADR-004 item 2): equal styles compile to a
  single `cellXfs` id, distinct ones to distinct ids — verified by re-opening the
  bytes and comparing style ids and the read-back `Style`. New model types
  (`Style`, `Font`, `Fill`, `Borders`/`Border`/`BorderStyle`, `Alignment`/
  `HAlign`/`VAlign`) all derive `Eq`/`Hash` so the emitter interns on the model
  style; colors are a type-safe `@units.Color` newtype (validated hex), never a
  bare `String`. `Cell.format : String?` became `Cell.style : Style?`;
  `Sheet::set` gained a `style?` argument beside `format?`. Style parsing lives
  in the new `loader/style.mbt`; the style→backend mapping in `emit`. Rich text
  and column/row default styles remain (Phase 4 items 4–5). 67 tests green.

- **2026-07-23** — **Roadmap tidy: scope audited against the backend, gaps
  captured.** Cross-checked the full `mbtexcel` surface against the phases and
  slotted the missing features in: **Phase 4** — rich text, column/row default
  styles, default font; **Phase 6** — column/row hide-show, outline grouping,
  and page setup for print (orientation/margins/print area/headers-footers/page
  breaks); **Phase 9** — Excel tables vs. pivot tables (split out; pivots flagged
  heaviest), auto filter, password encryption, document properties + calc mode,
  and a catch-all for niche extras (sparklines, shapes, form controls, slicers,
  sheet backgrounds, duration cells). Sharpened §2 non-goals: print *settings*
  are emittable (only rendering/printing is out), and **imperative editing**
  (insert/delete/move rows/cols) is a non-goal — a spec declares the final grid.
  No code change.

- **2026-07-23** — **Refactor pass (whole tree).** Split the 416-line `loader.mbt`
  at its natural seam: structural loading (workbook/sheets/cells + the shared
  `expect_*` helpers) stays in `loader.mbt`, and cell-value interpretation
  (scalar/expanded/typed/date/formula/error) moves to a new `loader/cell.mbt`.
  Extracted the emitter's number-format style interning into a named
  `intern_format_style` helper (ADR-004), and refreshed the `load` doc for the
  richer cell forms. No behaviour change; 60 tests green.

- **2026-07-23** — **Phase 3 complete: error literals + explicit typing.** `type`
  now covers `text`/`number`/`bool`/`date`/`error`. Explicit typing coerces the
  value to the declared type — `{ value: 42, type: text }` stores a string cell,
  `{ value: "42", type: number }` parses a numeric one, `{ value: "true", type:
  bool }` a boolean — with a diagnostic when the value can't convert. `type:
  error` holds an Excel error literal (`#DIV/0!`, `#N/A`, `#REF!`, …) validated
  against the known set. Added `CellValue::Error` and routed it through the
  emitter. **Phase 3 (rich cell values) is done; the active phase is Phase 4
  (styling).**

- **2026-07-23** — **Phase 3: formulas.** A cell may carry a `formula` —
  `C1: { formula: "=SUM(B2:B3)", value: 1650 }` — which compiles to an Excel
  `<f>` the app recomputes on open; the optional `value` is the cached result
  shown until then, and a leading `=` is accepted and stripped. Added a
  `Formula` case to the model, wired the emitter to the backend's
  `set_cell_formula`, and had the loader reject combining `formula` with `type`.
  Verified end to end: `=SUM(B2:B3)` emits `<c><f>SUM(B2:B3)</f><v>1650</v></c>`.

- **2026-07-23** — **Phase 3: dates / date-times (with a date system).** A cell
  typed `date` — `A1: { value: "2026-07-23", type: date }` (or with a time,
  `"2026-07-23 14:30:00"`) — compiles to an Excel serial with a sensible default
  date/date-time format, overridable via `format`. The workbook's date system is
  selectable with a top-level `date1904: true` (default is the 1900 system). All
  the calendar math lives in `units` (`DateTime`, `DateSystem`, `to_serial`),
  pure and independently tested against known serials, including Excel's 1900
  leap-year bug (1900-03-01 → 61, skipping the phantom serial 60); the emitter
  sets the backend's 1904 flag to match. Verified end to end: `2026-07-23
  14:30:00` → serial `46226.604…`, and a date round-trips back to `2000-01-01`.

- **2026-07-23** — **Phase 3: number formats (built-in + custom).** A cell may now
  be written in an **expanded form** — `A1: { value: 0.5, format: "0.00%" }` —
  alongside the scalar shorthand; `format` is any Excel number-format code (a
  built-in code like `0.00` or a custom one like `#,##0.00`). The model's `Cell`
  gained an optional `format`, and the emitter **interns** formats: each distinct
  code becomes one style id reused by every cell that names it, the first
  instance of the reuse machinery (ADR-004). Verified end to end — the format is
  applied on read-back (`0.5` → `50.00%`) and equal codes share one style id.
  Split the roadmap item: dates/date-times (a formatted number + a date system)
  are the next Phase 3 step.

- **2026-07-23** — **Refactor pass (whole tree).** Removed the vestigial root
  `t-ujiie-g/yxl` package — the `moon new` stub and the Phase-0 backend smoke
  test, now covered by the `emit` round-trip and `cli` golden tests. Factored the
  CLI's `yxl:` message prefix into one `report` helper; adopted `guard … is
  Some(x) else …` for the argument and sheet-name checks; and scrubbed roadmap
  phase/section codes from comments (keeping stable `ADR-nnn` references). No
  behaviour change; 35 tests green.

- **2026-07-23** — **Phase 2 complete: the `yxl build` CLI (walking skeleton).**
  Added the `cli` library (I/O-free: argument parsing → a `Command`, and
  `compile`, the whole parse→load→emit pipeline as source → `.xlsx` bytes) and a
  thin `cmd/main` native executable that reads the spec, compiles it, writes the
  workbook, and maps failures to stable exit codes (0 ok · 1 spec/emit/IO error ·
  2 usage) — the only place with filesystem access and process exit (ADR-003).
  Added `moonbitlang/x` for `x/fs`/`x/sys` (ADR-011). A **golden test** compiles a
  spec, re-opens the bytes with the backend reader, and asserts the cells across
  text/number/bool (quoted `"007"` stays text). `yxl build report.yxl.yaml -o
  report.xlsx` now produces a real, valid workbook end to end. **Phase 2 done;
  the active phase is Phase 3 (rich cell values).**

- **2026-07-23** — **Phase 2: the `loader` (spec → model).** Added the `loader`
  package: it interprets the minimal schema — a top-level `sheets` sequence, each
  sheet a `name` plus an optional `cells` mapping of A1 reference → scalar — and
  builds a `model.Workbook`, failing fast (ADR-006) on unknown keys, wrong types,
  invalid cell references, and duplicate sheet names as `@diag.SchemaError`s that
  name the file. Extended `model.CellValue` and the `emit` backend with `Bool`
  cells, and added `units.CellRef::parse_a1` (the validated inverse of `to_a1`).
  The `model → .xlsx` path now covers text, number, and boolean cells end to end.

- **2026-07-23** — **Phase 2: the `yaml` parser seam.** Added
  `moonbit-community/yaml@0.0.6` and the `yaml` package, which turns YAML source
  into our own `Node` tree (`Str`/`Int`/`Float`/`Bool`/`Null`/`Seq`/`Map`) behind
  a seam, so the pipeline never touches the parser directly. Scalars arrive
  already classified — a bare `1` is `Int`, a quoted `"1"` is `Str` — which is
  exactly the text-vs-number distinction the loader needs. Syntax errors and
  unrepresentable values become `@diag.YamlError`s that name the file.
  **Correction to ADR-009:** the library's span-carrying event API is a *sealed*
  trait we cannot implement, and its value tree has no positions, so per-node
  source spans are deferred to Phase 8 (rich diagnostics) — recorded as ADR-010;
  the seam keeps that future swap local.

- **2026-07-23** — **Phase 1 complete: YAML parser chosen (ADR-009).** Picked
  `moonbit-community/yaml@0.0.6` — a pure-MoonBit, libyaml/PyYAML-style event
  parser with `Marker` (line/col) spans, anchor/alias support, and `ToJson`, and
  no native FFI — to be consumed behind the `yaml` seam. Rejected the official
  YAML-org `ingydotnet/*` loaders (native `libyamlscript` FFI, at odds with the
  self-contained native CLI of ADR-003) and a hand-rolled `@json` subset. The
  `moon add` and the seam itself land in Phase 2, where the parser is first used.
  **Phase 1 (foundations: `diag`, `units`, `model`, the emitter seam, and the
  parser decision) is done; the active phase is now Phase 2 (YAML → a sheet of
  values).**

- **2026-07-23** — **Phase 1: emitter seam + minimal model + `units`.** Added the
  first end-to-end `model → bytes` path. `units` holds the type-safe `CellRef`
  (1-based col/row, bijective base-26 `to_a1`, e.g. col 703 → `AAA`), keeping
  cell refs off bare strings (AGENTS §7). `model` is the shared IR — `Workbook` /
  `Sheet` / `Cell` / `CellValue` (`Text` | `Number`) with a small mutable builder
  (`Workbook::new`/`add_sheet`, `Sheet::set`) whose returned sheet shares
  workbook storage; it imports only `units`, never the backend. `emit` defines
  the `Emitter` seam (`model → bytes`, ADR-002) with the sole `bobzhang/mbtexcel`
  implementation (`MbtexcelEmitter`) behind it and a `to_bytes` convenience;
  backend `XlsxError`s are translated to `@diag.EmitError` so the backend never
  leaks past the seam. A golden round-trip test compiles a model to real `.xlsx`
  bytes and re-opens them with the backend reader to assert the cells survived.

- **2026-07-23** — **Phase 1: `diag` package (source spans + subdomain
  errors).** Added `src/diag`, the lowest package (core-only), with `Pos`
  (1-based line/col), `Span` (file + start/end, with a `point` helper and a
  `file:line:col` `render`), and `Diagnostic` (message + optional span, rendering
  as `file:line:col: message`). Defined the five `pub(all) suberror` types —
  `YamlError` / `SchemaError` / `ResolveError` / `EmitError` / `CliError`, one
  per pipeline stage — each wrapping a `Diagnostic` so every stage fails with the
  same source-located shape and a `catch` can tell stages apart (ADR-006, AGENTS
  §7). Covered by unit tests for each render path plus raise/catch round-trips.

- **2026-07-23** — **Phase 1 started: `src/` layout (ADR-008).** Moved the flat
  `moon new` scaffold under `src/` and set `source = "src"` in `moon.mod`, so the
  §4 package map (`diag`, `units`, `yaml`, `model`, `loader`, `resolve`, `emit`,
  and the `cmd/main` CLI) has a home as those packages land. The scaffold package
  and its Phase-0 backend smoke test now live at `src/`; the CLI entry moved to
  `src/cmd/main`. Run it with `moon run src/cmd/main` (AGENTS.md §5 updated).
  Renames preserved history; no `.mbti` drift; the full native validation loop
  (`check`/`test`/`fmt`/`info`/`build`) stays green.

- **2026-07-23** — **Phase 0 complete.** Scaffolded `t-ujiie-g/yxl` from
  `moon new`, set `preferred_target = native`, and established the project
  foundation: this `ROADMAP.md`, `AGENTS.md` (with `CLAUDE.md` symlink), README,
  `.githooks/` (`moon check && moon fmt`), `.claude/` plugin config
  (`moonbit-skills`), and `.gitignore`. Added the `bobzhang/mbtexcel@0.1.8`
  dependency (ADR-001), a CI workflow (`check` / `fmt --check` / `.mbti` drift /
  `test` + `build` on native), and an end-to-end smoke test — `mbtexcel` builds
  a workbook and emits `.xlsx` bytes (verified `PK` ZIP magic) on native (in
  fact all backends passed). Direction set: a YAML→`.xlsx` compiler + native CLI
  on `mbtexcel` behind an emitter seam (ADR-002). **The active phase is now
  Phase 1 (foundations: seam, model, diagnostics).**
