# CLAUDE.md

This file provides guidance for Claude Code when working with this repository.

## Welcome — Contributing to mxcli

If you're starting a new task, here's how contributions work in this repo:

1. **File an issue first** — describe the bug or feature before coding. See `CONTRIBUTING.md` for details.
2. **Get approval** — wait for maintainer sign-off before starting work.
3. **Create a feature branch** — `feature/123-short-description` or `fix/456-what-broke`.
4. **Use the contributor commands** to stay on track:
   - `/mxcli-dev:proposal` — create a structured feature proposal (asks the right questions, investigates BSON storage)
   - `/mxcli-dev:review` — review your changes against the PR checklist before pushing
5. **Validate locally** — `make build && make test && make lint` must all pass.
6. **Open a PR** — link the issue, document Mendix Studio Pro validation, confirm agentic testing.

For the full workflow, read `CONTRIBUTING.md`. The evidence bar every change is held
to is in "Working Rules for a Change" below; the subsystem checklists live in
`/mxcli-dev:review`.

## Project Overview

**ModelSDK Go** is a Go library for reading and modifying Mendix application projects (`.mpr` files) stored locally on disk. It's a Go-native alternative to the TypeScript-based Mendix Model SDK, enabling programmatic access without cloud connectivity.

## Build & Test Commands

```bash
# build the CLI (preferred - uses Makefile)
make build

# run tests
make test

# format and vet code
make fmt
make vet

# run a specific example
go run ./examples/read_project/main.go /path/to/project.mpr
go run ./examples/modify_project/main.go /path/to/project.mpr

# run the code generator
go run ./cmd/codegen/main.go -reflection-dir ./reference/mendixmodellib/reflection-data -version 10.0.0 -output ./generated/metamodel
```

**Note**: This project uses `modernc.org/sqlite` (pure Go) and does **not** require CGO. No C compiler is needed.

**Note**: The VS Code extension (`vscode-mdl/`) uses **bun**, not npm/node. Use `bun install`, `bun run compile`, etc. The Makefile targets (`make vscode-ext`, `make vscode-install`) already use bun.

## Mendix Tools

The `mx` command-line tool validates and builds Mendix projects. Location depends on environment:

| Environment | Path |
|-------------|------|
| Dev container | `~/.mxcli/mxbuild/{version}/modeler/mx` |
| This repo | `reference/mxbuild/modeler/mx` |

```bash
# Auto-download mxbuild for the project's Mendix version
mxcli setup mxbuild -p app.mpr

# check/validate a Mendix project
mxcli docker check -p /path/to/app.mpr

# or use the integrated command (auto-downloads mxbuild)
mxcli docker check -p app.mpr
```

**Devcontainer gotcha — libSkiaSharp/FreeType crash on some mxbuild releases.** Certain bundled `mx` binaries (observed on 11.10.0) abort with `symbol lookup error: .../libSkiaSharp.so: undefined symbol: FT_Get_BDF_Property`. Root cause: `mx`/mxbuild run under the Temurin JVM, whose bundled libfreetype is stripped and lacks `FT_Get_BDF_Property`, so Skia loads the *JVM's* FreeType instead of the system one (which has the symbol). Preloading the system libfreetype makes it load first and fixes `mx check`/`build`/`run` while keeping Skia working.

`mxcli docker check`/`build`/`new` apply this automatically (`docker.PrepareMxCommand`, which globs the system libfreetype and sets `LD_PRELOAD` on the `mx` child — no-op on non-Linux or when none is found). To invoke a bundled `mx` directly, use the wrapper (same fix) or export `LD_PRELOAD` yourself:

```bash
scripts/mx-check.sh -p /path/to/app.mpr --version 11.10.0
# or, for any mx command:
export LD_PRELOAD=/usr/lib/$(uname -m)-linux-gnu/libfreetype.so.6
```

## Project Architecture

Run `ls` or read the package docs; the tree is not restated here. The orientation
that is not obvious from the layout:

- `modelsdk/` is **the** MPR engine — `mpr/` (file format), `codec/` (document <-> BSON),
  `canon/` (canonical form, identity transplant, write elision), `gen/` (vendored
  metamodel types). `sdk/mpr`, the legacy engine, is deleted.
- `mdl/` is the language: `grammar/` (ANTLR4) -> `visitor/` -> `ast/` -> `executor/`,
  with `backend/` as the abstraction the executor speaks to and `catalog/` the SQLite
  index behind `show`/`select`.
- `api/` is the fluent builder layer over any backend; `sdk/` holds the semantic types.
- `cmd/mxcli/` is the CLI; `generated/metamodel/` is generated by `cmd/codegen`.
- `reference/` is gitignored reference material, not Go code.

## Key Concepts

### MPR File Formats
- **v1**: Single `.mpr` SQLite database file (Mendix < 10.18)
- **v2**: `.mpr` metadata + `mprcontents/` folder with individual documents (Mendix >= 10.18)
- Format detection is automatic

### BSON Storage Names vs Qualified Names

**CRITICAL**: Mendix uses different "storage names" in BSON `$type` fields than the "qualified names" shown in the TypeScript SDK documentation. Using the wrong name causes `TypeCacheUnknownTypeException` when opening in Studio Pro.

| Qualified Name (SDK/docs) | Storage Name (BSON $Type) | Note |
|---------------------------|---------------------------|------|
| CreateObjectAction | CreateChangeAction | |
| ChangeObjectAction | ChangeAction | |
| DeleteObjectAction | DeleteAction | |
| CommitObjectsAction | CommitAction | |
| RollbackObjectAction | RollbackAction | |
| AggregateListAction | AggregateAction | |
| ListOperationAction | ListOperationsAction | |
| ShowPageAction | ShowFormAction | "Form" was original term for "Page" |
| ClosePageAction | CloseFormAction | "Form" was original term for "Page" |

When adding new types, always verify the storage name by:
1. Examining existing MPR files with the `mx` tool or SQLite browser
2. Checking the reflection data in `reference/mendixmodellib/reflection-data/`
3. Looking at the decoder in `modelsdk/codec/` and the types in `modelsdk/gen/microflows/`

**IMPORTANT**: When unsure about the correct BSON structure for a new feature, **ask the user to create a working example in Mendix Studio Pro** so you can compare the generated BSON against a known-good reference.

### Mendix Expression String Escaping

When generating Mendix expression strings (e.g., in `expressionToString()`), single quotes within string literals must be escaped by doubling them: `'it''s here'`. Do NOT use backslash escaping (`\'`). This matches Mendix Studio Pro's expression syntax.

### Quoting Escapes Parser Keywords, Not Platform-Reserved Member Names

The skills advise **quoting all identifiers** to avoid keyword collisions, but this only escapes **MDL parser** keywords (so `"create"`, `"status"`, `"end"` become valid attribute names). It does **not** exempt names the Mendix **platform** reserves for entity members — those are rejected by Studio Pro (and by `mxcli check --references`) **even when quoted**, because the check strips the quotes and validates the bare name:

- `Type` → CE7247 / `MDL021` ("reserved word"). Rename (e.g. `ResourceType`, `TypeValue`). Also `ID`, `GUID`, `CurrentUser`, and the Java-keyword list.
- `CreatedDate` / `ChangedDate` / `Owner` / `ChangedBy` → `MDL020` on persistent entities. Use the `AutoCreatedDate` / `AutoChangedDate` / `AutoOwner` / `AutoChangedBy` pseudo-types for the audit fields, or a different name for an unrelated value.

The reserved-word lists live in `mdl/executor/cmd_enumerations.go` (`mendixReservedWords`, `mendixSystemAttributeNames`). "Always safe to quote" in the skills means *parser*-safe, not *platform*-safe.

### A `GUID` Is the Database's Identity — Never Mint One for an Existing Element

An element's `GUID` is not decorative and is not interchangeable with its `$ID`.
The **runtime keys the database on it**: `mendixsystem$entity.id` and
`mendixsystem$attribute.id` hold the model's `GUID` verbatim (byte-identical once
the .NET field order is undone). Measured on Mendix 11.12.1 against a live
PostgreSQL: changing **only** an entity's `GUID` — same name, same table name,
same attributes — makes the runtime treat it as a different entity and **destroys
its rows**. An unchanged reboot is the control, and preserves them. See
[PROPOSAL_marketplace_module_upgrade.md §8](docs/11-proposals/PROPOSAL_marketplace_module_upgrade.md).

Re-measured per **attribute** (#1119): the columns are dropped and recreated, and a
recreated column with a model default **comes back filled with that default** — so
`count(col)` proves nothing; seed a non-default value and compare values. Method:
[rewrite-drops-unauthored-state](docs-wiki/bug-patterns/rewrite-drops-unauthored-state.md).

Consequences for any write path:

1. **Preserve the stored `GUID` when rewriting an existing element.** A codec that
   mints a fresh one on rebuild silently drops a table's worth of production data
   on the next deploy — a failure that no `mx check` and no build will catch,
   because the model is perfectly valid. This is the same class as the identity
   properties in `canon.identityFields` and belongs in that decision.

   **The write path refuses it** — `canon.StorageGUIDError`, pairing on `$ID` +
   `$Type` + `Name`: the transplant's pairing is structural, so a shared `$ID` alone
   is NOT one member, and reading it as one made the guard refuse correct writes. It
   refuses rather than repairs; the carry belongs with the write, which knows which
   element is which (`carryChildIdentity`) — keyed on name as well as `$ID`, since
   `CREATE OR MODIFY` declares members with no ID.
   The one deliberate GUID transplant, the marketplace module update, opts out by
   name via `UpdateRawUnitOwningStorageGUIDs` — passing that because "the guard was
   in the way" is how #1119 ships again.

   The carry is fixed per rebuild **shape**, not per element type: swapping one element
   into a list leaves its siblings passing through as stored bytes (#657, #1119), while
   emptying the list and rebuilding all of it has no safe siblings (#1169). The reported
   statement is rarely the blast radius — enumerate the converter's **call sites**.
2. **`$ID` renumbering is irrelevant to data safety** — the inverse of the natural
   assumption. Studio Pro renumbers every `$ID` in a module on update and preserves
   every `GUID`, which is exactly why its update does not lose data. `$ID` matters
   for *intra-unit pointer consistency* (see below); `GUID` for the database.
3. **A new element must get a fresh `GUID`**, and an element copied from another
   model must not keep the source's — two elements sharing a `GUID` are one entity
   as far as the runtime is concerned.
4. **Moving an element between modules is the most expensive case, not a lesser one.**
   Measured, same 250-row start both ways: `GUID` preserved → the runtime **renames**
   the table, all 250 rows survive; re-minted → the table is dropped and an empty one
   created. The runtime resolves the entity by `GUID`, not by table name, so a move
   loses a whole **table** where an ALTER loses a column (#503) — and `RENAME ENTITY`
   is the same case, the name being the table name (#1169). A `$Type` change on the
   way needs a **raw transform**: not `SetRaw` (it passes the stored `$Type` through),
   not gen's `SetDataStorageGuid` (wrong key, `string` where the property is binary).

**Before trusting any GUID test, check the subject.** An element **mxcli created**
has `GUID == $ID` from birth, so re-minting `GUID = $ID` reproduces the same value
and the defect is undetectable — only a **Studio Pro-authored** element can fail.
This has already voided a live-database control and an MDL repro script. Suspect it
first whenever a GUID test passes.

### The Tunnel Is Linux-Only, On Purpose — Do Not "Restore" It

`run --hub` / `tunnel-hub` embed chisel, which got the Windows and macOS builds
flagged by Defender and denied by enterprise EDR. Linux-only is the fix, not a
portability gap: making it cross-platform again re-introduces the detection for
most downloads. Reasoning and alternatives in
[ADR-0009](docs/13-decisions/0009-tunnel-is-linux-only.md).

Two rules that are not in the ADR:

- **Never obfuscate, pack, or rename to evade detection.** That is attacker
  tradecraft and makes things strictly worse; code signing does not substitute,
  because a signed binary containing chisel is still flagged behaviourally.
- **Every chisel import lives behind one of two seams** (`tunnel_linux.go` /
  `tunnel_other.go`, `control_linux.go` / `control_other.go`). An import anywhere
  else is what `make check-tunnel-deps` exists to catch.

### Theme Files: Where SCSS Actually Compiles

Styling written to the wrong place fails **silently** — the build succeeds and
the rules are simply absent. Which file compiles, in what order, and why a
literal colour outside the palette is wrong under every theme but one:
`.claude/skills/mendix/theme-styling/SKILL.md`.

### Writes Are Conditional, and an `$ID` Is Never Renumbered In Place

Storage does not write a unit whose new content is semantically equal to what is
stored ([ADR-0008](docs/13-decisions/0008-identity-and-idempotence.md)), and when
a write does land the stored element `$ID`s are carried onto it rather than
replaced. Mechanism, measurements and the reporting rules:
[idempotent-writes](docs-site/src/internals/idempotent-writes.md).

Three rules, each already violated once:

1. **Never rewrite an element `$ID` without rewriting every reference to it in the
   same pass.** Pointers are primitive properties holding an `element.ID`, so a
   containment walk never sees one. PR #125 renumbered this way and made projects
   unopenable. A unit is rewritten wholesale or not at all.
2. **A new write path must be wired to `canon.Reconcile`.** One that writes
   directly churns silently while everything else is quiet, so the diff blames the
   wrong change.
3. **A new document type with an identity property needs a row in
   `canon.identityFields`.** It cannot be generated — Mendix's `IsIdentifier` is
   not in the reflection data.

**Any test asserting "nothing changed" must include a control.** Without one it
passes against a build that never had the fix, which is how PR #125 shipped green.

### Association Parent/Child Pointer Semantics (Counter-Intuitive)

**CRITICAL**: Mendix BSON uses inverted naming for association pointers:

| BSON Field | Points To | MDL Keyword |
|------------|-----------|-------------|
| `ParentPointer` | **FROM** entity (FK owner) | `from Module.Child` |
| `ChildPointer` | **TO** entity (referenced) | `to Module.Parent` |

`create association Mod.Child_Parent from Mod.Child to Mod.Parent` stores:
- `ParentPointer = Child.$ID` (the FROM entity owns the foreign key)
- `ChildPointer = Parent.$ID` (the TO entity is being referenced)

This affects **entity access rules**: MemberAccess entries for associations must only be added to the **FROM** entity (the one stored in `ParentPointer`). Adding them to the TO entity triggers CE0066 "Entity access is out of date".

The same convention applies in `domainmodel.Association`: `ParentID` = FROM entity, `ChildID` = TO entity.


## Code Style Guidelines

- Follow standard Go conventions (`go fmt`, `go vet`)
- Use descriptive names matching Mendix terminology
- Keep BSON/JSON tags consistent with Mendix serialization format
- Export types that should be part of the public API
- Use interfaces for polymorphic types (e.g., `Element`, `MicroflowObject`)

## Documentation Artifacts

mxcli uses a layered documentation system — each artifact type has a single canonical home. If a value can change without anyone touching the artifact, it does not belong there; link to the canonical home instead.

| Artifact | Lives in | Created via | Purpose |
|----------|----------|-------------|---------|
| PRD / feature proposal | `docs/11-proposals/` | `/mxcli-dev:proposal` | What to build and why |
| Bug report | `docs/12-bug-reports/` | — | Reproduction + diagnosis |
| ADR | `docs/13-decisions/` | `/mxcli-dev:adr-new` | Cross-cutting decisions (immutable audit trail) |
| User manual | `docs-site/src/` | hand-edited | How to use mxcli / MDL |
| Concept wiki | `docs-wiki/` | `/mxcli-dev:wiki-sync` | Synthesized brain — framing and connecting only |
| Skill | `.claude/skills/` | hand-edited | Step-by-step procedure for a recurring task |
| Bug findings | `.claude/skills/fix-issue/findings/*.jsonl` | append on every bug fix | Bug symptom → file → fix recipe (evidence; grep or DuckDB, never read whole) |
| Load-bearing rule | this file | hand-edited | Always-in-context invariants and routing |

**State stays in its native home.** Proposal status, PR / issue numbers, roadmap, version registries — these live only where they're authoritative (proposal frontmatter, GitHub, the `sdk/versions/*.yaml` files). The wiki and CLAUDE.md may cite this state but never mirror it.

**ADRs are immutable once accepted.** Supersede with a new ADR rather than editing in place. Conventions and template in [`docs/13-decisions/README.md`](docs/13-decisions/README.md).

**Bug findings are read in the opposite order from how they are written.** A fix *appends* one record to `.claude/skills/fix-issue/findings/<area>.jsonl`; a diagnosis *starts* at `docs-wiki/bug-patterns/`, which digests those records into failure classes, and drills into the findings only for the specific instance. The findings are append-only evidence — grep them, or query them with DuckDB (`select … from 'findings/*.jsonl'`), never read them whole. Coverage is **computed, never quoted**: `make digest-status` reports pattern pages, findings, and per-area coverage. A figure written into prose here is stale the next time anyone appends a finding — the previous version of this sentence claimed 83% for an area that had since doubled. A pattern miss means "not yet digested", not "not seen before".

**The wiki is synthesized, not stated.** It frames and connects across the other artifacts — it never restates content that has a canonical home. Rules and seed page list in [`.claude/skills/maintain-wiki.md`](.claude/skills/maintain-wiki.md).

## Working Rules for a Change


When reviewing pull requests or validating work before commit, verify these items:

### Bug fixes
- [ ] **Fix-issue skill consulted** — start at [`docs-wiki/bug-patterns/`](docs-wiki/bug-patterns/) for the failure *class*, then `grep -i` `.claude/skills/fix-issue/findings/*.jsonl` for the *instance*; match before opening files. A pattern-page miss means the finding has not been digested yet, never that it has not been seen
- [ ] **Finding recorded** — one JSON line appended to `.claude/skills/fix-issue/findings/<area>.jsonl` if the symptom is not already covered, and `make check-findings` passes (it prints how far `docs-wiki/bug-patterns/` has fallen behind; `make digest-status` breaks it down by area). **If the class of failure keeps recurring, sync its pattern page** — the digest is on-demand and nothing else asks for it, which is how it went three months without a sync. Write the *insight* (what would have made it cheaper to find, what measurement settled it), not the changelog. `merge=union` in `.gitattributes` keeps both sides when two fixes append at once; order carries no meaning, since these are looked up by matching a symptom
- [ ] **Test written first** — failing test exists before implementation (codec/parser test in `modelsdk/codec/` or `modelsdk/mpr/`, backend mutation test in `mdl/backend/modelsdk/`, executor handler test in `mdl/executor/` using `MockBackend`)
- [ ] **Verified at the layer the symptom lives in** — a test proves something about the layer it exercises and nothing more. Parser/grammar → unit test. BSON we write → unit test on the encoded document. Files on disk after `mx` runs → integration test (`-tags integration`). **The rendered app's behaviour or appearance → `.claude/skills/verify-in-runtime.md`** (boot with `run --local`, assert in Playwright). A page can serialize to valid-looking BSON, pass `mx check`, build cleanly, and still render wrong — that was #812.
- [ ] **Fix proven to be the cause** — revert the fix (or stub the guard) and confirm the test fails with the reported symptom. A test that only passes against fixed code has not been shown to detect anything; two bugs this week had a green suite while live (#812 a clobbered `RegisterTypeDefaults`, #808 an integration test that had only ever skipped)

### Scope & atomicity
- [ ] Each commit does **one thing** — a feature, a bugfix, or a refactor, not a mix
- [ ] Each PR is scoped to a **single feature or concern** — if the description needs "and" between unrelated items, split it
- [ ] Independent features (e.g., a new command, a formatter, UX improvements) go in separate PRs even if developed together
- [ ] Refactors that touch many files (e.g., renaming a helper across executors) are their own commit, not bundled with feature work

The subsystem checklists — backend abstraction, full-stack wiring, version
gating, test coverage, security, docs, code quality — are in
[`/mxcli-dev:review`](.claude/commands/mxcli-dev/review.md), which is the command
that applies them. They only bite when a change touches that subsystem.

## Dependencies

- `modernc.org/sqlite` - Pure Go SQLite driver (no CGO required)
- `go.mongodb.org/mongo-driver` - BSON parsing for Mendix document format
- `github.com/jackc/pgx/v5` - PostgreSQL driver for external SQL connectivity
- `github.com/sijms/go-ora/v2` - Oracle driver for external SQL connectivity
- `github.com/microsoft/go-mssqldb` - SQL Server driver for external SQL connectivity

## MDL CLI (mxcli)

```bash
./bin/mxcli                                   # REPL
./bin/mxcli -p app.mpr -c "show entities"     # one command
./bin/mxcli exec script.mdl -p app.mpr        # a script
./bin/mxcli check script.mdl -p app.mpr       # validate without applying
```

`./bin/mxcli help <command>` documents every command and `./bin/mxcli syntax`
every MDL statement. Neither is restated here.

Three things no `--help` tells you:

- **Generated parser files are not committed.** `mdl/grammar/parser/` is produced by
  `make grammar`, which `make build` runs — a fresh clone does not compile without it.
- **Skills are edited in `.claude/skills/mendix/`, never in `cmd/mxcli/skills/`.** The
  latter is an embed dir regenerated by `make sync-skills` with `rsync --delete`. A
  skill's frontmatter `description` is the routing mechanism; any table of skills is a
  shortcut that drifts (it reached 12 of 68 before #906 caught it).
- **`.claude/commands/mendix/` is synced into user projects; `mxcli-dev/` is not.**
  Contributor tooling goes in `mxcli-dev/`.

## Before Writing MDL

**Read the matching skill first.** They are in `.claude/skills/` (contributor) and
`.claude/skills/mendix/<name>/SKILL.md` (synced to user projects). Each one's
frontmatter `description` says when to reach for it — that IS the index, so list the
directory rather than looking for a table here.

### Mendix Microflow/Nanoflow Idioms (MUST follow)

These rules apply whenever generating microflow or nanoflow MDL. Violations are caught by `mxcli check`.

1. **NEVER create empty list variables as loop sources.** If processing imported data, accept the list as a microflow parameter — `declare $Items list of ... = empty` followed by `loop $item in $Items` is always wrong.
2. **NEVER use nested LOOPs for list matching.** Loop over the primary list and use `$match = FIND($TargetList, key = $item/key)` for an O(N) in-memory lookup. A plain `retrieve … where` **cannot** filter a list variable (only a database/association source), so `retrieve $match from $TargetList where …` is a parse error — use `FIND`/`FILTER`. Nested loops are O(N^2). The `$item` there is the enclosing loop's iterator and stays valid — MDL-LISTOP01 flags a predicate variable that is *not in scope*, not the name. Inside the predicate itself, the item under test is `$currentObject` (a bare attribute name resolves to it).
3. **NEVER nest one list operation inside another.** Each of HEAD/TAIL/FIND/FILTER/SORT/UNION/INTERSECT/SUBTRACT/RANGE and the aggregates is a separate **activity**, and an activity stores its list as a **variable reference** — there is no slot for a nested computation. `$n = COUNT(FILTER($reqs, …))` parses, and used to drop the inner call entirely and write an activity with an empty list: `check` clean, `exec` reporting "Created microflow", then CE0012 / CE0096 at build time — and `sort(filter(…), Attr)` made mxbuild abort outright, because the sort attribute resolves against the now-absent list's entity. One statement each: `$approved = FILTER($reqs, …); $n = COUNT($approved);`. `mxcli check` now refuses the nested form as MDL-LISTOP02 (mendixlabs/mxcli#1101).
4. **Use append logic when merging**, not overwrite: `$Existing/Field + '\n' + $New/Field` inside an `if $New/Field != empty` guard.
5. **`retrieve … limit 1` binds a single OBJECT, not a one-element list** — it is Mendix's "First object" range, so `head()`, `count()` or a `loop` over that variable is **CE0097**. Drop the `limit` for a list; `limit 1 offset n` and every other `limit` ARE lists. Note the same word means the opposite on `import from mapping`, where `first` binds the object and `limit 1` a one-element list. `describe` re-emits `limit 1` either way, so the source of an object retrieve and a list retrieve are identical text and only MDL-RETRIEVE01 distinguishes them before a build (mendixlabs/mxcli#1103).
6. **Read `.claude/skills/patterns-data-processing.md`** for delta merge, batch processing, and list operation patterns.

**Always validate before presenting to user:**
```bash
./bin/mxcli check script.mdl                    # Syntax + anti-pattern check
./bin/mxcli check script.mdl -p app.mpr --references  # With reference validation
```

## What mxcli Can Do — Ask the Tool

**This file does not list features or syntax.** `./bin/mxcli syntax` enumerates every
MDL statement (`--json` for bulk), `./bin/mxcli help <command>` documents each command,
and `./bin/mxcli lint --list-rules` names every rule. A copy here is a transcription of
what those answer authoritatively, and it goes stale the next time anything ships. Full
syntax tables: [MDL_QUICK_REFERENCE.md](docs/01-project/MDL_QUICK_REFERENCE.md).

Per-doctype gotchas, CE numbers and the measurements behind them live in the skill for
that doctype — loaded when you touch that area rather than re-read every session.
Design rationale is in `docs/11-proposals/`; cross-cutting decisions in
`docs/13-decisions/`.

Still absent: 47 of 52 metamodel domains, delta/change tracking, runtime type
reflection.

## Where to Look First

Only the routing that reading the filename does not give you:

| Before you | Read |
|---|---|
| extend the modelsdk engine | `docs/03-development/MODELSDK_ENGINE_ARCHITECTURE.md` |
| change the parser or grammar | `docs/03-development/MDL_PARSER_ARCHITECTURE.md` |
| write or debug widget BSON | `docs/03-development/PAGE_BSON_SERIALIZATION.md`, `WIDGET_BSON_VERSION_COMPATIBILITY.md` |
| act on a CE0463 report | `.claude/skills/diagnose-ce0463.md` — **read first**, it has the two controls that separate a user's widget upgrade from an mxcli defect |
| debug any other BSON issue | `.claude/skills/debug-bson.md` |
| prove a fix in the running app | `.claude/skills/verify-in-runtime.md` — for symptoms where valid BSON and a clean `mx check` prove nothing (#812) |
| add a pluggable widget template | `sdk/widgets/templates/README.md` — a template needs **both** `type` and `object` |
| rely on a System-module attribute's length | `modelsdk/meta/system_module.go` — the lengths are **measured** from a built `model.mdp`, not taken from the Model SDK, which does not contain them; `Length` 0 is Mendix's "unlimited", never "unmeasured" |
