Nothing conserves the ROUTING — `SHARED_PLANNING_RULES` drops the pack tag, so re-tagging a rule silently changes which cells receive it while every check stays green
Repo: motir-ai. One PR. Surfaced 2026-08-18 in conversation while explaining MOTIR-2967's baseline. It is the sibling axis that card built the text half of and, correctly, never claimed: conservation is stated over the corpus TEXT and not over its DELIVERY.
The defect
A rule reaches a planning call only if its pack is selected for that cell. The pack is decided by one tag in CORPUS_ORDER — and that tag is discarded before anything holds the corpus steady:
const CORPUS_ORDER: readonly (readonly [string, string])[] = [
['core', BANNER_AND_TERMINOLOGY],
['kind-container', PLAN_ALONG_THE_USER_JOURNEY],
['kind-story', STORY_IS_A_VERTICAL_SLICE],
…
];
export const SHARED_PLANNING_RULES = CORPUS_ORDER.map(([, text]) => text).join('\n');
// ↑ the pack tag is DROPPED
The one-line proof. Change ['core', THE_GATE] to ['type-design', THE_GATE], touching not one character of rule text:
SHARED_PLANNING_RULESis byte-identical ⇒planningRuleConservation.test.tspasses.- Invariant B compares the rejoined packs to the corpus as a sorted line multiset, and moving a line between packs leaves that multiset untouched ⇒ passes. (Its self-referentiality is already recorded on MOTIR-2967; this is the same blindness on a second axis.)
- "every non-blank corpus line is reachable from some cell" passes —
type-designis loaded by some cell. - And a rule that reached all 104 legal cells now reaches only the
designones.
Nothing goes red, the corpus diff shows one word changed inside an array literal, and a gate stops firing for 90-odd cells.
What partially guards this, and exactly where each stops
| guard | catches | misses |
|---|---|---|
cell tests on resolvePlanningRulePacks | selector SHAPE — every cell has core, no duplicates, no type-* in a skeleton cell, every pack reachable | which rule sits in which pack — it never reads CORPUS_ORDER |
every non-blank corpus line is reachable from some cell | a rule routed into a pack no cell loads | a rule routed into a narrower pack |
| the co-location suites (the MOTIR-2931 pattern) | a clause separated from its host constant | the host itself narrowing — see below |
The third is the closest thing that exists, and its denominator is computed from the routing under test:
const cellsCarryingHost = CELLS.filter((c) => composePlanningRules(c).includes(HOST));
it.each(cellsCarryingHost…)(…, (_l, cell) => expect(composePlanningRules(cell)).toContain(CLAUSE));
If a re-partition moves HOST into a narrower pack, cellsCarryingHost shrinks and every assertion still passes, over a smaller set; the only backstop is expect(cellsCarryingHost.length).toBeGreaterThan(0). Its own comment names the fear — "a future re-partition that drops op-replan from a cell would otherwise silently stop shipping this rule to that cell, which is invariant A's failure mode one level down" — and answers it per rule, by hand, for the four or five rules somebody thought to protect.
Why this axis is the worse one to lose
A re-worded rule still fires, slightly differently. A re-routed rule stops firing entirely for the cells that lost it — which composeSkeletonRules' own comment calls "a rule that stops firing, the one failure the invariants exist to prevent", and which it pays real prompt size to avoid by taking a union rather than guessing a kind. The module is architecturally afraid of exactly this and has no check for it.
Deliberately NOT decided here — and the OBVIOUS answer is the WRONG one
The granularity of the baseline. Read this section before writing the fixture; the cheapest form does not catch the defect.
- (a) Pack list per cell —
label(cell) → resolvePlanningRulePacks(cell).join(','). INSUFFICIENT, and it is what this defect was first proposed to be fixed with. Re-tagging a rule leaves every cell's pack LIST unchanged — the cell still loadscore; it is the CONTENT ofcorethat moved. This fixture would be byte-identical across the exact edit above. It catches changes to the selector FUNCTION and nothing else. - (b) A digest of
composePlanningRules(cell)per cell — catches everything, including re-tagging. But it also churns on every legitimate re-wording, duplicating the text baseline, and a changed hash tells a reviewer nothing about what moved. A diff nobody can read is the failureCOMPRESSION.md§ Decision 1 spends its whole escape-hatch argument on. - (c) A per-cell list of the rule SEGMENTS delivered, identified stably — a short digest, or a fixed-length lead-in, per
CORPUS_ORDERsegment. Re-tagging shows as one identifier leaving one cell's list and joining another's, which is a diff a human reads in seconds. Costs a churn on re-wording, but only on the one line, and the text baseline's own diff explains it in the same PR.
Choose by what the fixture's DIFF says when it fires, not by what is smallest to write. That is the property MOTIR-2967's refresh script was built around and the one that makes a baseline worth having.
Acceptance criteria
- The defect is REPRODUCED before the fix and quoted: re-tag one
CORPUS_ORDERentry on a scratch worktree, showplanningRuleConservation.test.tsandplanningRulePacks.test.tsboth green, and showcomposePlanningRulesfor two cells differing. Revert it. Without this the card ships a guard against a hazard nobody demonstrated. - A routing baseline is committed under
tests/fixtures/, at the granularity chosen from (a)–(c) above, covering every celllegalPlanningRuleCells()returns (104 atd37fc53) — the enumeration is the domain, never a hand-picked subset. - A refresh script beside
scripts/refresh-planning-rules-baseline.ts, or an extension of it, regenerates the fixture; its docstring states — as that one does — that refreshing without reading the diff defeats the purpose. - The reproduction from criterion 1, re-run against the new check, FAILS, and the failure names the rule and the cells it gained or lost. A test that goes red without saying what moved has not met this.
- The chosen granularity and the rejection of the other two are recorded in a comment at the new suite, in the form the invariant-B block already uses.
pnpm vitest run tests/planningRulePacks.test.ts tests/planningRuleConservation.test.ts tests/treeGeneration.test.tspasses and is quoted. The full-suite number is CI's to report, not this card's.- No rule TEXT and no pack tag in
src/llm/planningRulePacks.tsis changed by this card — the deliverable is the check, and a baseline captured over an unnoticed re-routing proves nothing (MOTIR-2967's criterion 6, one axis over).
Context refs
src/llm/planningRulePacks.ts—CORPUS_ORDERand the tag-dropping.map(([, text]) => text),PLANNING_RULE_PACKS,resolvePlanningRulePacks,composePlanningRules,legalPlanningRuleCells.tests/planningRulePacks.test.ts— the cell suites, INVARIANT B — disjoint cover and its comment block, the reachability test, and the MOTIR-2931 co-location pattern whose denominator this card is about.tests/planningRuleConservation.test.ts+tests/fixtures/sharedPlanningRules.baseline.txt+scripts/refresh-planning-rules-baseline.ts— the TEXT half (MOTIR-2967, PR moooon-B-V/motir-ai#243); this card is its delivery-axis twin and should read like it.