MOTIR-2358Done
`ai:plan` (3/4) — generation, sprint planning, the pre-plan read and the AI-access probe
The third ai:plan card: the jobs that build a plan from nothing, and the two reads that sit beside them.
| Operation | Service method | Today |
|---|---|---|
POST /api/ai/plan/generate | aiGenerationService.startGeneration | nothing |
POST /api/ai/plan/sprint | aiSprintPlanningService.submitSprintPlan | nothing |
POST /api/ai/plan/sprint/[jobId]/review | aiSprintPlanningService.reviewSprintPlan | nothing |
POST /api/ai/plan/sprint/approve | aiSprintPlanningService.approveSprintPlan | reaches a gate indirectly |
GET/PATCH /api/ai/pre-plan | aiPreplanService.getPreplanState, saveDesignChoice | nothing |
GET /api/ai/access | billingService.getAiAccessForContext | nothing |
Generation is the heaviest planning job the product runs, and both it and sprint planning reach no gate at all today.
The two that need a decision rather than a copy
/api/ai/pre-planhas a READ and a WRITE on the same route. The write (saveDesignChoice) is a planning act →ai:plan. The read returns the project's pre-plan tier documents, which is closer to browsing than to spending — gate the read onproject:browseand say so in the row, rather than making the whole route one key because it is one file./api/ai/accessanswers is AI available here, and is there credit — it is the probe the UI uses to decide whether to offer a planning affordance at all. Gating it onai:planmeans an actor who may not plan cannot even discover that planning exists; gating it onproject:browsemeans the answer is readable and the ACT is still refused. Takeproject:browse, and record why in the inventory row: a capability probe that refuses to answer teaches the client nothing and pushes the discovery into a failed write.
Approving a sprint plan creates real work items. approveSprintPlan is the one write here whose output is durable project data, so its gate must be asserted before anything is materialized, not after the plan is read back.
Acceptance criteria
startGeneration,submitSprintPlan,reviewSprintPlan,approveSprintPlanandsaveDesignChoiceeach assertassertPermission(projectId, ctx, 'ai:plan')before submitting or writing.getPreplanStateandgetAiAccessForContexteach assertproject:browse, and both inventory rows are corrected fromai:planwith the reason written in.- A test proves
approveSprintPlanrefuses aviewerBEFORE any work item is created — assert on the absence of the rows, not only on the thrown error. - A test proves a workspace member with no project membership cannot generate a plan on an
openproject. ai:plandoes NOT flip toenforcement: 'enforced'here — the close-out card owns the flip.- The inventory rows record what landed, including the two corrections.
- The guard's pinned counts are re-derived by running it on this branch.
pnpm test tests/permissions/ tests/services/is green.
Context refs
lib/services/aiGenerationService.ts,lib/services/aiSprintPlanningService.ts,lib/services/aiPreplanService.ts— none holds an assertion today.lib/services/billingService.ts—getAiAccessForContext, and whatever scope it already resolves.app/api/ai/pre-plan/route.ts— the read and the write that need different keys.docs/decisions/member-facing-permissions.md— the role assignment forai:plan.- The role-set seam — the prerequisite.
ai:plan(1/4) ·ai:plan(2/4) — the sibling cards using the same gate shape.