# CLAUDE.md

Guidance for AI-assisted sessions working on this repository.

## What this is

rigc compiles a **rig spec** plus a motion spec — and, for a cut with measured art
behind it, a cut manifest — into Spine 4.3 skeleton data and a one-part-per-page
atlas, then round-trips the result through `@esotericsoftware/spine-core` and
51 named assertions before anything is written. Read [README.md](README.md) for
the formats, the CLI and the assertion list; [`src/rig.ts`](src/rig.ts) is the
rig spec's own documentation.

[docs/AUTHORING.md](docs/AUTHORING.md) is the guide an agent authors *from* — the
two input files field by field, the emission rules, the CLI loop, and the map from
each named failure to the file that has to change. It is a first-class deliverable,
not a summary of this file: the guide and the validator's messages together are the
only interface an agent that cannot see the rig actually has. **Anything that
changes an input format, an error message or an assertion changes that guide too.**

## The doctrine: a tool for AI, not for people

Everything below follows from one observation. An agent authoring a rig cannot see
it. Spine's parser accepts a great deal of wrongness in silence — the constraint
that vanishes, the NaN curve, the mesh that quietly loses its bone weights — so an
agent with only a parser for feedback will report success on a broken rig and be
sincere about it. rigc exists to convert that silence into a named failure.

- **The validator's messages are the UI.** They are what the agent reads and what
  it acts on, so a failure detail must name the object, the value found and the
  value required. `A20_MESH_WEIGHTS_COHERENT: mesh "x" vertex 12 weights sum to
  0.9000` is the product. "invalid mesh" is not.
- **Everything in a rig spec resolves by name, and a miss is refused by name.**
  A bone's `parent`, a slot's `bone`, a constraint's `bones` and `target`, a
  draw-order key's `slot`, and an authored mesh's vertex `weights`. The last of
  those used to be the exception — weights carried raw indices into the *emitted*
  bone array, so inserting a bone rebound every vertex of every mesh below it with
  a green gate and an unmoved `diff` (issue #45, the third example in the
  paragraph above, reproduced rather than caught). Spine's index encoding is still
  reachable behind `"boneIndexing": "raw"`, because what it buys is exactly the
  silence, and that has to be asked for.
- **The compiler never invents a value that is not in the spec.** No defaults
  guessed from the art, no re-measuring of plates, no "reasonable" fallbacks. If a
  number is missing, that is a `CompileError` naming the field. A compiler that
  fills in gaps produces rigs nobody can reason about — and it makes the manifest
  stop being the record of what was measured.
- **Emit only after green.** `build` compiles, validates, and writes *only* if
  every assertion passes. Never reorder that. A wrong file on disk outlives the
  console output that warned about it.
- 🔒 **Validation is not optional — this is a structural invariant, not a
  default.** There must never be a `--no-validate` or `--emit-anyway` flag, an
  environment escape, or an exported API that hands back emitted artifacts
  without the gate having run. **Correctness is the whole of the reason, and it
  is sufficient by itself.** The gate is one body of assertions with two
  suppliers: the round trip through `@esotericsoftware/spine-core`, which
  `cli.ts build` runs wherever the runtime is installed — a clone, CI, an install
  with it added beside — and rigc's own validator over the model document and
  the emitted text, which `cli_core.ts build` runs where it is not (the published
  package, since 2.0.0: `spine-core` is a devDependency). The second supplier is
  admitted on measurement, not on its word, and the measurement's scope is
  stated rather than rounded up: of the 51 assertions, the 42 the model side
  runs are held to the round trip's verdict lines by `verdict_gate`, the 8
  restated over the emitted text (A01, A02, A05, A07, A16, A31, A35, A18) by
  `RC28`, and `A00_ROUNDTRIP_PARSE` — the parse by the official runtime — does
  not run on the core build and is reported as a SKIP that says so. `core_gate`
  holds rigc's poser to spine-core's poses at tolerance 0. CI runs all of that
  on every change over the public recipes and every selftest call; the 14
  production rigs are measured by hand with private recipes CI does not have,
  before each landing — a practice, not a gate CI enforces. A bypass of either
  supplier turns rigc back into a program that prints plausible JSON. Do not
  accept a "just for testing" bypass.

  A second reason stood here until 2026-09-05, when issue #398 retired it: that a
  build path not linking the runtime would be a Spine-format emitter with no
  runtime dependency, and that rigc therefore "must remain structurally incapable
  of being used as a substitute for" the editor. Declaring a format-agnostic core
  with its own format and player (issue #380) makes that a promise the roadmap
  already contradicts, and an unkeepable promise is worth less than none — so it
  is retired rather than reworded. What that does not touch: this repository
  links `spine-core` as a devDependency, so the Spine Runtimes License covers
  running it from a clone, in CI, or with the runtime installed beside the
  package; the published package links none. That is a fact about what the code
  links, [NOTICE.md](NOTICE.md) states it, and it stands whether or not the
  clause does.

  ⚠️ **None of that authorises an emit path with no oracle behind it.** What the
  invariant requires is that everything written to disk was gated by a reader
  held to a parser rigc did not write: spine-core supplies that reading in CI,
  and the own validator ships only because the gate above holds it equal. A
  backend emitting a format with no such parser needs its own oracle first —
  issue #380's cross-backend oracle was the prerequisite for the core entry's
  build, not a nicety attached to it.
- **Determinism is a contract, not a habit.** `A18_DETERMINISTIC_EMIT` compares a
  second, independent compile byte for byte. Anything non-deterministic —
  iteration over an unordered set, a timestamp, a locale-sensitive format, floating
  noise — breaks it, and that is the point.
- **A gate nobody has seen fail is not a gate.** Every assertion needs a mutant in
  `selftest.ts` that makes it fire, and every suite needs a positive control. An
  assertion whose data is absent reports **SKIP**, never a pass — folding vacuous
  checks into the pass count is how a gate comes to look kept while checking
  nothing.
- **No `any`, no `as any`, in `src/` or `cli.ts`.** `selftest.ts` is the one
  exception and it is scoped: the mutants deliberately forge malformed skeleton
  JSON, so they turn the rule off around the mutant tables and back on after.
  Since 2026-08-22 `bun run lint` enforces this rather than a reader, which is
  also what makes the scope of that exemption checkable — the file's
  `eslint-disable` comments now have to actually bracket every `any` in it.
- **rigc compiles an object. The scene belongs to the consumer.** What the rig
  can *do* is ours — its angles, its jiggle, its expression axes, the sockets
  something attaches to, and the guarantee that its animations meet cleanly at
  their rest poses. *When* it does any of that is not: the order animations play
  in, what else is on screen, where the camera is, how two characters relate.
  The line is not a modesty; crossing it makes the claims contradict each other,
  because the code already refuses what the prose would be promising.

  ⭐ **It is a name for a choice already made, which is why it holds.**
  `gallery/look` deliberately makes a face angle *a value rather than a time* —
  the object offers a dial and does not decide when it turns. A39 poses a
  slider-applied animation at the slider's own mapping for the same reason
  (issue #407): posing it as if a track were playing it invents a moment the
  object never claimed.

  ⚠️ The failure mode is vocabulary, not code. `films/three-scene` states the
  boundary exactly right in its own text — *"that is a property of the rig
  rather than of the assembler"*, measured at 0 differing pixels across the
  hand-offs — and the README caption over it once called the same thing "scene
  direction". The caption now says the rig guarantees the seams and the
  composing is the consumer's, so the phrase survives only as the film's own
  title, which the clause below sanctions. Prefer the honest claim, which is
  also the stronger one and the only measurable one: **rigc makes an object that
  can be directed. It guarantees the seams; the consumer does the composing.**

  🎬 **A demo is the one place a scene belongs, because there we are the
  consumer.** Showing what an object can do means putting it in time, and
  someone has to compose that take — so `films/three-scene` is a scene, made
  deliberately, and its name is honest. What is not honest is presenting that
  composition as a rigc capability. The film may stage; the caption may not
  claim. Read a "scene" in this tree as a question of *who is speaking*: the
  demo says "we composed this with the tool", the product may only say "here is
  what the object gives you to compose with".

- **The same doctrine governs how the repository is worked, and for a while it
  did not.** Everything above says an agent is the author and the messages are
  its only interface — and then the working habit was to carry decisions to the
  owner for a ruling. Those two contradict, in the same file. A tool built on
  the premise that the work happens without a person watching cannot be *built*
  by asking a person to watch.

  ⇒ **The fix is a change of lens, not a transfer of authority.** Most questions
  worth carrying to a person are artifacts of looking at the work through a human
  organisation, where conventions have owners and "which would you prefer" is a
  real question. Under the lens this tool is built for they dissolve, because the
  criterion is never preference: *which reading is derivable*, *which claim is
  measurable*, *does a gate already cover this*. Each of those answers itself,
  and each was asked of a person here at least once.

  What survives the lens is short and none of it is a convention: direction,
  licence posture, what the spend is worth, and any text that speaks as the
  owner. Everything else is decided by stating the argument, because **stating a
  decisive argument *is* the decision** — taking it to a human afterwards is a
  round trip wearing a review's clothes.

  🤝 **And the short list is not a list of things somebody dictates.** The axis
  is not who owns an item, it is *what settles it*: what can be derived is
  settled by derivation, and what cannot is settled by **agreement** — which
  means an objection that has not been raised is a decision that has not been
  made. Reading those four as handed down is how the human frame gets back in
  through the side door, one clause after being shown the door: the authority
  over conventions is removed, and then returned over direction.

  ⚠️ An objection is held to the same standard as everything else here. *"I
  would rather not"* is not one; **"it contradicts X", "it cannot be measured",
  "it breaks Y"** are. A veto without grounds is the same emptiness as a
  question without grounds, pointed the other way.

  🔸 One of the four is the owner's alone, and the reason is observability
  rather than authority: **what the spend is worth cannot be agreed to, because
  an agent cannot see the cost.** The remaining budget, the reset, what else it
  was being saved for — none of that is visible from here, so assent to it
  would have the shape of agreement and none of the content. Which is the same
  criterion applied in the other direction: consultation is worth something
  exactly where measurement is unavailable, and here it is unavailable to the
  agent.

  🔒 **And a question that survives because the tree cannot answer it is a card
  or a check, never a question.** This is the doctrine's own first move applied
  one level up: the answer to "an agent cannot see it" was never "ask someone",
  it was "convert the silence into a named failure". So "I cannot tell" is not a
  reason to escalate — it is the exact condition this tool exists to remove, and
  meeting it means the instrument is missing.

  🚨 **This is not a licence to be more confident.** A reviewer was doing real
  work — forcing the argument to be said out loud — and removing one without
  replacing it makes the tree worse, not faster. The replacement is adversarial
  structure *below*: a task that says **measure it yourself rather than trusting
  this brief, and report what you rejected** comes back having contradicted its
  own instructions where they were wrong, which is the outcome to design for.
  Two claims in a task brief were corrected that way on the day this was written,
  and neither would have been caught by reading the brief again.

  📖 **The reader to write for is an agent with no memory of the conversation
  that produced the work.** So a thing settled in a chat and not written into
  the tree did not happen. What convinces such a reader is exactly what the
  gates already produce — **a claim with its measurement beside it, and the
  alternative that was rejected with the reason** — which is why commit messages
  and pull requests here carry the argument rather than a summary of the diff.
  That is not ceremony; it is the only channel that survives the session.

## Conventions

- Bun + TypeScript, ESM, `.ts` extensions in relative imports.
- `src/` is pure: no clock, no randomness, no network. **Three** files link
  spine-core and they are named here, because an unnamed exception is how a rule
  erodes: `src/validate.ts` owns the round trip, `src/render.ts` poses a
  skeleton in order to draw it, and `src/deformmeasure.ts` poses one in order to
  measure what a deform key did to it. All three are the same justification —
  posing *is* running the runtime, and there is no honest way to read a posed
  vertex without it. Since issue #968 that is `src/render.ts`'s reason for a
  **Spine export** only: a rigc build, whose `skeleton.model.json` sits beside
  the pair, is posed through rigc's own core by render_core.ts (under `src/`,
  linking nothing from the runtime — every frame, geometry file and framing box
  measured identical to the spine-core render on every corpus), and the render
  says which poser drew it. `src/render.ts` still links spine-core for the
  export's poser, for the atlas pages the draw samples and texture
  substitution reads (`TextureAtlas`), and for the spine-core entries
  `validate.ts`'s A10 steps a skeleton through. What the rule protects has not moved: `src/compile.ts` must
  stay independent of the runtime so the compiler and the gate are not checking
  each other's assumptions. Since issue #922 that independence is held by
  controls rather than by this sentence: `MD02` holds the compiler free of every
  Spine shape (the Spine emitter module is the one writer of Spine data, called
  once), and `MD03` holds the set of modules naming those shapes to the one it
  measured. Neither of those modules links spine-core; `CUR07` reads this bullet
  for the ones that do, so they are not named here by path.

  ⚠️ This sentence said **Two** for two weeks after the third file arrived
  (issue #379) — the rule eroded in exactly the way its own clause predicts,
  because nothing read it. `CUR07` in `selftest.ts` now derives the list from
  the tree and compares, and its sharpest clause is the negative one: nothing
  had ever asserted that `src/compile.ts` does *not* link the runtime.
- **A new runtime import that crosses a directory has to be added to `files` in
  `package.json`.** The published package is an allowlist, not the repository,
  and what is in it is deliberately not written out here: `npm pack --dry-run`
  lists what would ship, and RELEASING.md says what belongs there, why, and why
  no document keeps that list by hand. The only **two** modules `src/` reaches
  outside itself are `tools/plate.ts` and `tools/font5x7.ts`. A module added
  under `tools/` and left out of `files` still runs from a clone — the installed
  package is where it throws `Cannot find module`, on the command that needs it.
- **`exports` in `package.json` is the second allowlist**: `files` says what
  ships, `exports` says what a dependant may import, and RELEASING.md *The
  import surface* says which entries are API, which symbols of them are
  promised, and which keys are a courtesy until a major. `bun run smoke` is
  what reads it, from an install.
- Spine data generations — `docs/GENERATIONS.md` is the policy (detect from
  `skeleton.spine`, never guess, play on the matching runtime, the editor is the
  migration path); `src/generation.ts` is its one reader.
- Coordinate contract: manifests are in **crop pixels, y down, origin top-left**;
  Spine world is **y up, origin at the bottom-left of the crop**. The whole
  conversion lives in `src/transform.ts` (`cropToSpineY`, `toBoneLocal`,
  `screenToSpineDegrees`). Do not open-code it anywhere else.
- Conventional Commits, English subject and body. Commit each finished unit.
- Pushing, tagging and publishing are the owner's call.

## Verification — run these before you call a unit finished

| Command | Checks |
| --- | --- |
| `bun run typecheck` | `bunx tsc --noEmit` over the paths `tsconfig.json`'s `include` names — read it there rather than from a copy here, which is how this cell came to be missing two of them. `strict: true` since 2026-10-02 — the comment in that file carries the measurement that raised it |
| `bun run lint` | one rule: `@typescript-eslint/no-explicit-any` as an **error**. `eslint.config.js` says why it is only one |
| `bun run selftest` | the validator's own negative controls, on fixtures it generates. Add `--cuts <cuts.json>` to gate a project's real cuts as well — see *The selftest and its fixtures* |
| `bun run selftest -- --only <suite>[,<suite>…]` | one squad's own suite while iterating — a partial run exits 2 when green (1 when red), prints `SKIP` for every suite it did not run and never the summary; the full run is the only verdict on the tree |
| `bun run smoke` | the **package**, rather than the tree: pack, install into an empty directory, build a rig from the install. Needed when a change touches `files`, `bin/`, a runtime import that crosses a directory, or the dependency — a green checkout cannot see any of those. It installs packages, so unlike the three above it needs a network. `-- --source registry --version X --wait N` asks the same question of the artifact npm serves, which is what a cut is confirmed with, and answers it in four exit codes: a version the registry has not served yet is **3** and is not a package that failed. RELEASING.md *Whether the tarball runs* says what it proves, what it cannot, and how long the registry has been measured to take |
| `bun cli.ts bench 3 --candidate <dir>` | the ladder still reproduces its rung. `docs/LADDER.md` §*B1's proof* carries that proof beneath the two commands that produce it — and every figure on that page is one **named** candidate's, so compare against the run you passed and not against the first `bench` line the page shows |
| `bun cli.ts check --candidate <dir> --frames <dir>` | the candidate still *looks* like the reference. The gate cannot see a wrong animation — it passed a build with every easing reversed — so a change to timelines, curves or the rasteriser is not verified until this has run |

Neither of the first two existed before 2026-08-22, and both found real defects on
their first run — a `let` assigned inside a callback that made 30 later reads
type-check against `never`, and a spread that quietly overwrote the file paths in
`diff --json`'s and `bench --json`'s own reports. Assume the same of the next rule
anybody adds: turn it on, read what it says, fix it, and only then commit it.

Anything touching an input format, an error message or an assertion also changes
[docs/AUTHORING.md](docs/AUTHORING.md).

## The selftest and its fixtures

`bun run selftest` is **self-contained**. It needs no arguments, no art and no
private repository — a fresh clone can run it, and CI does.

⛔ **The editor round trip (`tools/editor_roundtrip.ts`) is not, and must not
become, a selftest control.** CI has no editor; a control that needed one would
report SKIP for ever, which is how a gate comes to look kept while checking
nothing. Its *refusals* are gated, because they are the half a machine with no
editor can answer for: the suite points the tool at stubs in a temp directory and
reads what comes back, including the case that must *not* be refused — an editor
at an unfamiliar path, which is who `--editor` exists for. The trip itself is run
by hand, on a machine that has the editor.

Where its fixtures come from, in three tiers:

| Tier | Built by | What it carries |
| --- | --- | --- |
| **generated rigs** | [`fixtures/public.ts`](fixtures/public.ts), into a temp dir per run | `overlay_probe`, `articulated_probe`, `contained_probe`, `segments_probe` — between them: region attachments, attachment swaps, rgba fades, a ring mesh on a control bone, a ribbon on a bone chain, a `segments` mesh over a two-island part pulled by two chains, an axis bone, a detached emitter, physics constraints, and both measured ceilings |
| **inline probes** | `selftest.ts` itself | the two-slot rig the static-rig and draw-order suites break |
| **the example corpus** | `bun run fetch-examples` | the rung-3 and rung-6 transcriptions and their rendered reference frames, which the `diff`, `check` and mesh suites measure against — and the twelve editor exports themselves, which the `ingest` suite's corpus half ingests, rebuilds and diffs (issue #594). Every suite that reads it names a HOLE when it is absent |

Three rules hold that together and none of them is optional:

- **The plates are checkerboards with `PLACEHOLDER` burned into them.** They exist
  to be structurally real — a true size, a true alpha channel, in the place the
  manifest says — so the compiler measures something and the atlas points
  somewhere. No claim about seams, blending or appearance can come from any of
  them, and none is made.
- **No mutant hardcodes a measured number.** Vertex offsets are found by walking
  the weight run (`weightRuns`, `firstBlendedRun`), atlas edits target the first
  page or region structurally, and the two ceiling mutants state the *smallest*
  whole-pixel edit that crosses the line — which is checkable only because
  `fixtures/public.ts` chose the gap. Reintroducing a literal here is how the
  suite became unrunnable outside one repository the first time.
- **An absent corpus is a HOLE, not a pass.** When `examples/` is missing the
  `diff` and `check` suites say so loudly, the summary repeats it, and a run where
  *nothing* substantive executed exits 2 rather than printing green. Since issue
  #439 that floor is one **per suite** rather than one on their sum, and it is
  counted off the case lines each suite prints rather than restated beside the
  call: any suite that reports it ran and then measures nothing exits 2 by name,
  even when the total grew around it. The summary's own figures are read from the
  same count, so adding a control to a wrapped suite needs no edit to it.
- **A suite's figure counts every case it prints, its own positive control
  included** (issue #451). The tree answered this both ways — four suites counted
  their control and two did not — so for a while no single derivation reproduced
  the summary and four figures stood as literals. The rule is chosen for one
  reason: the tally can only count printed lines, so a convention that subtracts
  something the run printed cannot be derived at all and needs a hand-kept
  exception table beside it, which is the "✅ applied" antipattern this repository
  already has a judgment about. ⚠️ The run's *total* count of positive controls
  was **deleted** rather than derived, because "positive control" is a role and
  not a token on the line: `M16` and `M19` carry the word only because a **control
  bone** is a rig concept, while `PT04` and `PS25` are positive controls whose names
  say nothing at all. Six, seventeen and twenty-nine were each a reading of it and
  none of them was the number. Whether every suite *has* one is an invariant rather
  than a figure, and nothing enforces it yet.
- **A suite may be stated in halves, provided the halves are counted and sum to
  the suite** (issue #453). The `diff` clause states its identity controls and
  its measures as two figures over one suite, and no whole-suite count reproduces
  either half — which is how the last hand-written figure survived #452: it
  reached the summary through an interpolation, so the scanner that refuses a
  typed digit read it as derived. Raising it to 99 produced a **green run, exit
  0**, printing a figure its suite contradicts.

  ⇒ A phase is bracketed the way a suite is and counted off the lines it printed,
  🔒 and the clause that makes the split honest is the floor: **phases that do not
  add up to their suite are a fault**, because two derived halves that need not
  reconcile are two hand-written numbers wearing a derivation. The near-miss
  direction is the load-bearing one — a control weakened to catch only
  over-counting was measured green on a suite one case short.

  ⭐ **With no literal left, the check #452 could not build is built** (`TY13`): a
  top-level constant holding one bare number, named by the summary's code, that
  nothing outside the summary reads. It needed no exception table only because
  the tree had none left to except — and on the run where it names a planted
  constant, `TY10` prints PASS on the same file, which is the card's whole thesis
  in one run.

  ⚠️ Its rejected sibling is worth keeping: a "still matching" clause of the form
  *the summary must name at least one module constant* **goes red exactly when
  the tree improves**, since naming none is the ideal end state. A two-sided
  check has to be two-sided about the right thing.

`--cuts <cuts.json>` (or `RIGC_CUTS=<path>`) adds an **extra suite**: every cut in
that table is compiled, gated and compiled again for `A18`. It is a positive
control and deliberately nothing else — hand-aiming a second set of mutants at
somebody's real art is what made this file unrunnable before. What real art adds
is the geometry: measured offsets, a measured axis, a measured ceiling, a mesh
built over a contour nobody drew by hand. So the question it asks is the one only
those cuts can answer — *does the whole gate still come back green on them?*

`RIGC_EMIT_HASHES_BASE=<hashes.json>` gives the five step-1 gates (`MB07`, `MV09`,
`MS12`, `MA12`, `MD07`) a fuller base hash document written by `tools/emit_hashes.ts`
at an earlier commit, and every recipe whose inputs are present is then held
byte-identical to that document's rows — the gate every cut of issue #380's step 1
landed under. Without it they read the tracked gallery base,
`tools/emit_hashes.base.json`, which `bun tools/emit_hashes.ts base` writes and
`base --check` holds current (`EH06`): a change that moves a gallery build's bytes
on purpose regenerates it in the same PR. With neither, they print `SKIP` and a
HOLE line naming the file and the command; they never pass on nothing.

`tools/render_hashes.ts` is the same instrument for step 3 of issue #380, where
`src/render.ts` stops posing through spine-core: every recipe built, rendered
through `rigc render --geometry` at the protocol rate, and hashed file by file —
frames, contact sheets, `geometry.json`, `frames.json` — with the framing box's
numbers, the setup pose drawn over the same box and every bone snapshot beside
them; every PNG carries two hashes, its file bytes and its decoded pixels. Two
`run` documents from one machine, before and after a cut, are the gate
(`compare`, which names a PNG whose bytes differ and pixels agree as the
encoder's, not a DIFF). The tracked gallery base,
`tools/render_hashes.base.json`, holds the PNGs' pixel hashes only: a one-ulp
change of every libm result moved no PNG and every file carrying a double, and
the PNG encoder on Linux CI wrote different bytes for the same frames than the
macOS base (PR #972). `bun tools/render_hashes.ts base` writes it and `base --check` holds it current (`RH05`), so a change that
moves a gallery render on purpose regenerates it in the same PR. CI uploads a
run over every recipe as the `render-hashes-linux` artifact, which is where a
second machine's doubles are read.

⚠️ A cuts path that is **named and missing** exits 2. Treating a typo as "no cuts
file" would mean the one caller who asked for the extra suite is the one caller
who silently does not get it. A path that is not named at all is a normal run.

## Going public

The repository was a snapshot of a game project's sandbox, and the split moved the
cut-specific *files* out without sanitising the *code*. That inventory is closed;
what follows is what keeps it closed.

- **Cut-specific knowledge lives with the consumer, not here.** Slot names,
  anatomy, plan documents, per-project budgets. If a comment, a default or an
  assertion can only be understood by someone who has seen one particular set of
  art, it is in the wrong repository.
- **An archetype assertion reads the rig, never a name.** A24–A30 and A39 take
  everything they know from the rig spec's `invariants` block, and
  `A13_MESH_BUDGET` takes
  its two numbers from `invariants.meshSlots` / `invariants.meshTriangles`. A
  budget baked in here would be one consumer's frame time failing correct foreign
  data — the editor's own example projects ship meshes many times denser.
- **An assertion with nothing to measure reports SKIP.** Never a pass. A rig that
  declares no invariant is unmeasured, not certified, and the two must not print
  the same. ⚠️ The failure mode is subtler than forgetting to write the SKIP: an
  assertion can have a *default* that quietly turns "nothing to measure" into a
  measurement of the wrong thing. `A21_MESH_RIM_PINNED` resolved a mesh's kind as
  `meshKinds[slot] || 'ring'`, so authored geometry — which has no entry, because
  rigc did not build it — was checked as a ring and reported 40 failures on a
  correct 40-vertex editor mesh. `meshKinds` now has a third state, `authored`,
  and the generator-topology rules skip on it (issue #44).
- **Design notes live with the consumer.** Comments state their invariant outright
  rather than citing a document a reader cannot open.

Two tools moved out with the cuts they only made sense for
(`measure_joint_anchors.ts`, `make_stroke_strip.ts`); they import rigc's generic
helpers back through the owning project's symlink. The generic measuring tools
stayed, minus their per-cut defaults — `tools/measure_contact_depth.ts` now
requires both slot names, because a default would measure the wrong pair on the
next cut and still print a plausible number.
