`ai:view_plan` — reading a generated plan, and the approve / decline / proposal-edit that turn it into work items
The last member-facing key, and the smallest domain: four routes plus the server actions behind the plan-review surface.
| Operation | Service method | Today |
|---|---|---|
GET /api/plans/[id] | planReviewService.getPlanReview | reaches a gate indirectly — planReviewService holds no assert of its own |
POST /api/plans/[id]/approve | plansService.approvePlan | assertCanEdit |
POST /api/plans/[id]/decline | plansService.declinePlan | assertCanEdit |
PATCH /api/plans/[id]/items/[itemId] | plansService.updateProposal | assertCanEdit |
app/(authed)/plans/_actions.ts | the plan-review server actions | mapped to ai:plan by the inventory; they act on a PLAN, so they belong to this key |
Approving materializes work items — that is what makes ai:view_plan a write key rather than a read one, and why docs/decisions/member-facing-permissions.md puts it at member rather than at browse. The name is the misleading part of the domain and is worth a comment where the gate lands: ai:view_plan governs reading a generated plan AND acting on it, because the two are the same surface and a reviewer who may not act has nothing to review for.
The plan's project comes from the PLAN row, not from the actor's active project. Resolve it there.
Acceptance criteria
- All four routes' service methods assert
assertPermission(projectId, ctx, 'ai:view_plan'), with the project id resolved from the plan. - The server actions in
app/(authed)/plans/_actions.tsassert the same key; their inventory row is corrected fromai:plan, with the correction annotated. - A test proves
approvePlanrefuses aviewerbefore any work item is created — assert on the absence of the rows, not only on the thrown error. - A test proves a plan belonging to another project cannot be approved by an actor who holds the key in their own.
ai:view_plandoes NOT flip toenforcement: 'enforced'here if any mapped operation is still ungated after this PR; check by running the guard, and if every one of them is covered, flip it and extendtests/permissions/catalog.test.tsby exactly this key. Say which happened in the PR body.- The inventory rows record the gate; 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/plansService.ts—approvePlan,declinePlan,updateProposaland theirassertCanEditcalls.lib/services/planReviewService.ts—getPlanReview, with no assertion of its own.app/(authed)/plans/_actions.ts— the server-action file the inventory maps toai:plan.docs/decisions/member-facing-permissions.md— the role assignment and the read-versus-act reasoning.- The role-set seam — the prerequisite.