PLANNING BUG — three domain cards were sized on "ungated", and the gates were there and TIGHTER; the guard's name whitelist produced the inversion
Planning-bug RECORD card. The correction is already applied — this card records it. No PR carries its id.
What happened
motir run MOTIR-2256 reached MOTIR-2296 (board:configure) and read lib/services/boardsService.ts before editing it. The card said "four of its operations reach boardsService with no project gate at all today… any signed-in workspace member can add or delete a column on any board they can reach. Closing that is most of this card's value." Every board write in that file calls the module-private assertBoardConfigAdmin (boardsService.ts:1138), which resolves isOwnerRole(membership?.role) and throws NotBoardAdminError — "You must be a workspace owner to change board configuration."
Not ungated. Gated tighter than project:administer. The card's premise was not merely imprecise, it was inverted: running it as written would have GRANTED board configuration to workspace admins and project admins under a heading that promised a hole was being closed.
A sweep of the remaining domain cards found the same shape twice more, and the cause is one instrument:
| Card | Domain | Claimed | Measured on origin/main | Effect of the split |
|---|---|---|---|---|
| MOTIR-2296 | board | "no project gate at all" | assertBoardConfigAdmin → workspace OWNER only | LOOSENS |
| MOTIR-2297 | workflow | "no gate at all" / "only a session" | assertProjectAdmin (workflowsService.ts:47) → workspace OWNER only | LOOSENS |
| MOTIR-2297 | automation | already assertCanManage | project:administer | neutral (card was right) |
| MOTIR-2298 | estimation | "workspace membership alone" | assertEstimationAdmin (estimationService.ts:351) → workspace OWNER only | LOOSENS |
| MOTIR-2298 | field / component / label | "workspace membership alone" | private assertCanManage ×2 + projectAccessService.assertCanManage | neutral |
| MOTIR-2299 | repository | ungated | assertCanEdit → project member | tightens (card was right) |
| MOTIR-2300 | ai | assertCanManage | project:administer | neutral (card was right) |
The instrument: tests/permissions/noUngovernedOperation.test.ts's GATE is a whitelist of NAMES. assertBoardConfigAdmin, assertProjectAdmin and assertEstimationAdmin are not on it, so every operation behind them was reported UNGOVERNED, and the story and its cards were written from that output.
This is MOTIR-2292 recurring one level up. That card fixed WHERE the walk looks (a parameter's inline object type was captured as the method body) and left WHAT it recognises alone. Its own explanation says "a number produced by a tool nobody has tried to break is a hypothesis, and this story starts by breaking it" — the tool was broken in one dimension and then trusted in the other.
Fix applied
- MOTIR-2304 filed (
bug,code, highest, 3 pts) — the instrument repair:serviceBodies()'s walk follows a call into a MODULE-LOCAL function in the same file, so the whitelist stops being the only way a gate is seen; both halves of a control fixture; the inventory gains a measured GATE-TODAY table; both pins re-derived. - MOTIR-2296 / 2297 / 2298
blocked_byMOTIR-2304, and each re-scoped IN PLACE (update_work_item, description + explanation): the false premise replaced by the measured gate table, the LOOSENING named as a decision rather than implied to be neutral, and a new acceptance criterion requiring a test that asserts the project admin could not do this yesterday and can today. The private helpers are added to each card's delete list. - Nothing was archived. No card was cancelled and recreated.
Why the loosening is right, not a regression to avoid
MOTIR-2293 (merged into this story's parent branch) already put board:configure, workflow:manage and estimation:manage into BUILTIN_ROLE_PERMISSIONS.admin. If those gates stayed workspace-owner-only, the catalog would advertise permissions a project admin holds while the code refuses them — the precise lie lib/permissions/catalog.ts's opening rule exists to prevent. Both mirrors put board / workflow / estimation configuration under project administration. So the split must widen them; what it must not do is widen them silently inside cards whose text promises nobody's access changes.
Lesson
Authored as notes.html #231 — "A negative claim sourced from a tool inherits the tool's blind spot" — in motir-meta PR #132. The lesson did not exist at close-out time (see the verification below), so it was written, not merely verified.
A card that describes an operation as UNGATED is making a negative claim, and a negative claim sourced from a TOOL inherits every blind spot the tool has. When the tool is a name whitelist — GATE lists assertCan*, getXCapabilities, hasPermission( — its blind spot is exactly the case a careful author would name differently, so the false negatives cluster on the gates somebody thought hard enough about to give a specific name. Read the service before writing "ungated" into a card, and when a whole family of cards is sized against one measurement, break the measurement in the dimension the last repair did not touch.
The sharper half: the guard's numbers came with a STORY attached ("any signed-in workspace member can delete a status"), and the story survived into three card bodies as motivation, scope and estimate. A count is checkable; a narrative built on it is not, and it is what the next reader actually acts on.
Close-out verification (2026-08-06, motir run MOTIR-2305)
1. The correction HELD — and was then EXECUTED. All three re-scoped cards carry the corrected premise header ("⚠️ Re-scoped 2026-08-06 against the branch"), the measured gate table, and a blocked_by edge to MOTIR-2304. Since this record was written, all four shipped as commits on parent/MOTIR-2256-per-domain-admin and sit at In Review:
| Commit | Card |
|---|---|
488b27a3 | MOTIR-2304 — the ungoverned-operation walk was a NAME WHITELIST — 39 gated operations read as ungoverned |
ba2847f6 | MOTIR-2296 — board:configure — and a project admin can finally configure their own board |
f48291ee | MOTIR-2297 — workflow:manage + automation:manage — one card, one widening and one no-op |
569f5d64 | MOTIR-2298 — the four project vocabularies — three neutral re-pointings and one real widening |
2. The remedy's CODE was read, not its status. MOTIR-2304 being in_review proves nothing by this record's own argument, so each limb was verified in tests/permissions/noUngovernedOperation.test.ts on the parent branch: localFunctionBodies() builds a service → name map (:152); "Hop 2" recurses into a module-local function of the same file (:223–226); GATE gains assertPermission(, isOwnerRole( and isWorkspaceManager( (:58–59); both halves of the control fixture exist — a local call is followed (:504) and a local helper that gates nothing still reports ungoverned (:535); and the three helpers are pinned by name (:559–561).
3. Every rung-2 citation re-verified on origin/main — all EXACT. Unusually, nothing drifted:
assertBoardConfigAdmin—boardsService.ts:1138, the declaration line, dead on. Body resolvesisOwnerRole(membership?.role)→NotBoardAdminError.assertProjectAdmin—workflowsService.ts:47, dead on. Same shape →NotProjectAdminError.assertEstimationAdmin—estimationService.ts:351, dead on.NotBoardAdminError—lib/boards/errors.ts:98–100, codeNOT_BOARD_ADMIN, message verbatim "You must be a workspace owner to change board configuration."GATE—noUngovernedOperation.test.ts:29–30, quoted verbatim:/assertCan[A-Za-z]+|get[A-Za-z]*Capabilities|hasPermission\(|canManageProject\(|canBrowse\(|canEdit\(/.
Two inexactnesses corrected in place above (both cosmetic, neither load-bearing): the gate table's header read "Measured on the branch" while its line numbers are origin/main numbers — on the parent branch today they are 1187 / 68 / 364, because the very cards this record protected have since landed on it; and the body was quoted as isOwnerRole(workspaceMembership.role) where the source reads isOwnerRole(membership?.role).
4. The lesson was ABSENT and has been authored. Searched by CONTENT, never by citation: notes.html on origin/main (corpus head #230) had 0 hits for assertBoardConfigAdmin, MOTIR-2304, MOTIR-2296 or "name whitelist"; the 2 ungated and 1 whitelist near-misses are unrelated entries (#-level check on notification gating and an MCP field whitelist). No motir-meta remote branch carried it and no motir-meta PR was open. Written as #231, HTML tag balance parsed before/after as identical, "Across the N mistakes" bumped 230 → 231.
5. Promote question: LESSON, not a rule. This card poses none, but the check was run so the next reader does not re-open it. plan-rules.md's claim-vs-pointer gate already covers the case verbatim — "a card may not assert a code fact — a policy branch, a helper, an exported symbol, a column, a route, an enum value, a config key — as EXISTING, NOR AS ABSENT, unless it was grepped on origin/main at authoring time" — and its asserted-ABSENCE limb landed in 2eb70d1 on 2026-08-05 09:53Z, twenty-five hours before these cards were authored (2026-08-06 11:13Z), same planner, same story. A rule whose general clause covers the case is a diligence miss, not a trigger gap. Supporting grounds: the honest family on this axis is two (MOTIR-2292 and this), both from the same instrument in the same story, well short of the four-in-two-days threshold; the sibling precedent MOTIR-1917 was reverted on an adjacent axis as "not a planning rule, it looks like a lesson, a detailed example"; and the draft needs the incident to make sense, which is the register test. The one genuinely new observation — that the gate's discharge is "one git grep per asserted fact" and a purpose-built tool report reads as stronger than a grep while being a search for strings its author chose — is recorded as #231's prompt hint, which is where a lexical tell belongs.
Close-out
Both close conditions are met: the correction is verified in the tenant (and has since shipped as four commits), and the lesson is authored as notes.html #231. This card rests at In Review until motir-meta PR #132 merges — that merge is its close condition. No code PR carries this card's id, so no status sync will move it; the next run's close-out sweep does.