MotirBuilding in public
MOTIR · moooon
onMotir
You’re viewing a public project. Anyone can view it — no account needed. Sign in to submit, upvote, or comment on requests.View-only — you can’t edit work items
MOTIR-3641

Planning bug: the ADR allocated the account-SUSPEND action to a card while the suspension MECHANISM — a schema column, session revocation and a sign-in refusal — was owned by nobody and priced into nothing

Done
Description

Found while running MOTIR-1167 (motir run, 2026-08-26, branch subtask/MOTIR-1167-day1-platform-admin, base origin/main @ 2852f19b2). Filed under the planner-bug home because the fix is a change to the plan and to the rule that would have prevented it, not to product code — the code shipped in MOTIR-1167's own PR.

What went wrong

docs/decisions/platform-staff-auth.md §7 is the allocation table every consumer builds to. It carries this row:

| Suspend / reactivate an account | 8.5 MOTIR-1167 | operator | yes | yes |

and design/platform-admin/design-notes.md Panel 9 states the behaviour precisely:

"They are signed out of every session immediately and cannot sign back in."

Both allocate the ACTION. Neither allocates the MECHANISM, and the mechanism did not exist. Verified on origin/main @ 2852f19b2 before a line was written:

  • git grep -iE 'suspendedAt|suspended_at|banned|banReason' prisma/schema.prisma0 hits. User had no suspension column of any shape.
  • Better-Auth's admin plugin — which ships banned / banReason / banExpires — is not in lib/auth/index.ts's plugins array (nextCookies, deviceAuthorization, twoFactor).
  • Nothing anywhere refused a sign-in for a disabled account, and nothing revoked another user's sessions.

So the card as written asked for a WRITE and got, unpriced: a schema migration, a session-revocation path, and a change to the authentication boundary — which session-creating path is refused, where that refusal lives, and what the person on the other end is told.

Why it was invisible, which is the part worth keeping

Every signal read green, and each was answering a narrower question than the one that mattered.

  • validate_work_item("MOTIR-658") returned valid: true with the card ready: true, openBlockers: []. Its likely-over-gate-sizing advisory reads the card's DECLARED storyPoints / estimateMinutes — 5 and 60, comfortably under the gate — so an under-estimate is precisely the defect that channel is structurally unable to report.
  • likely-missing-edge scans a card's own description for not-done work items. The suspension mechanism has no work-item key to name, so there was nothing to detect.
  • The ADR's own "What this ADR deliberately does NOT decide" table is the instrument for exactly this, and it names six things — the tenant policy arms, PlatformUsageDTO, the console's layout, the audit vocabulary's seed, hash-chained tamper evidence, the production bootstrap of the first staff row. It does not name the suspension mechanism, so the card and the ADR agreed the work was owned while neither owned it.

The 2026-08-17 amendment on MOTIR-1167 (from the MOTIR-2933 close-out) came within one sentence of catching it. It correctly widened the criterion from one write to two, and then said:

"Not changed, deliberately: the estimate (60 min / 5 pts). The second write shares the drill-down, the confirm dialog and the PlatformAuditLog writer the first one needs, so it is one more action on built rails rather than a second feature."

Half right, and the wrong half is the load-bearing one. The second write does share the drill-down, the dialog and the audit writer — the SURFACE was correctly priced. What it does not share is its own SUBSTRATE, and nothing prompted anybody to ask whether it had any. Send password reset had substrate (the shipped requestPasswordReset flow); Suspend account had none.

The measurement

Run against a production build, 2026-08-26 — the card is 5 points / 60 minutes, and the delivered diff is 35 files / +3,782 lines, of which the suspension mechanism alone is a migration, two schema columns, a repository with a SELECT … FOR UPDATE guard, lib/auth/accountSuspension.ts, a databaseHooks.session.create.before hook, a copy branch on both credential sign-in surfaces, and its own test file. The two panels priced at 60 minutes were, by themselves, roughly the size the card named.

What the fix is

Two things, and the second is the one that stops it recurring.

  1. The plan. Re-estimate MOTIR-1167 on the record (its numbers are wrong on the row, not merely optimistic), and add a row to platform-staff-auth.md's "deliberately does NOT decide" table naming the account-suspension mechanism and its owner. As of this filing the mechanism ships in MOTIR-1167's PR, so the row records where it landed rather than deferring it — the MOTIR-1916 rule, applied after the fact.

  2. The rule. A planning-rule limb, in the pack the estimation gate lives in: an allocation table that names an ACTION owes a line about the SUBSTRATE that action writes to. "Suspend an account" is a verb over a column, a session set and an auth path; "send a password reset" is a verb over a flow that already ships. The two look identical in a table with an owner column and a role column, and the discriminator — does the thing this verb acts ON exist yet? — is exactly plan-rules/phase-deepen.md's precondition-verification rule, which nobody thought to run on a WRITE because the rule reads as being about surfaces a card renders. The dispatch-time backstop (run.md guard #4) has the same blind spot for the same reason.

What this does NOT claim

The card was not wrong about what to build, and no re-plan was run: run.md's guards #2–#5 are manual/human, the design gate, an absent precondition and a repo straddle, and none of them fired. There is no run-time sizing guard, and inventing one from this incident would be the wrong generalisation — under-estimates are ordinary. What is not ordinary is an allocation table that assigns a write whose substrate no card owns, and that is what this records.