`ai:plan` (1/4) — the conversational planners: the discovery chat and the plan-change session
The first of four cards that wire ai:plan, split by the service that owns each job so no single PR touches eleven services. This one takes the two conversational surfaces — the discovery chat and the plan-change session — five routes in total.
| Operation | Service method | Today |
|---|---|---|
POST /api/ai/chat | aiChatService.submitDiscoveryTurn | nothing |
POST /api/ai/plan-change/session | planChangeSessionsService.getOrCreateForProject | nothing |
POST /api/ai/plan-change/session/turns | planChangeSessionsService.appendTurn | assertCanEdit |
POST /api/ai/plan-change/session/submit | planChangeSessionsService.submit | assertCanEdit |
POST /api/ai/plan-change/session/planner-turn | planChangeSessionsService.recordPlannerTurn, contextualPlanningService.recordPlannerTurnForWorkItem | assertCanBrowse |
Two of these reach no gate at all: starting a plan-change session and sending a discovery turn are, today, available to any signed-in workspace member with the project open. Both spend the workspace's AI credits.
The planner-turn route is not the same shape as the others. It records what the PLANNER said, so its caller may be a job callback rather than a person. Read the route before gating it: if the actor is the signed-in user relaying a result, ai:plan is right; if it is a service-to-service callback, it belongs on the serviceAuth axis and its inventory row is corrected rather than gated — say which in the PR body, with the line you read.
Acceptance criteria
submitDiscoveryTurn,getOrCreateForProject,appendTurnandsubmiteach assertprojectAccessService.assertPermission(projectId, ctx, 'ai:plan')before doing anything else, in the service.- The
planner-turnpath is either gated onai:planor recorded as service-authenticated with its inventory row corrected — one of the two, decided from the code and stated in the PR body. - A unit test proves a project
vieweris refused every gated method, and that a workspace member with NO project membership is refused on anopenproject — the second case is the oneai:plan-at-member is really about, and it does not follow from the first. ai:plandoes NOT flip toenforcement: 'enforced'here: three sibling cards still hold ungatedai:planoperations, and the flag may only turn when the last of them lands. The close-out card owns the flip.tests/permissions/catalog.test.tsis therefore unchanged by this PR.- The inventory rows for these five operations record the gate that landed.
- 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/aiChatService.ts,lib/services/planChangeSessionsService.ts,lib/services/contextualPlanningService.ts— the three services this card gates.app/api/ai/plan-change/session/planner-turn/route.ts— the route whose caller must be read before it is gated.docs/decisions/member-facing-permissions.md— the role assignment forai:plan, including the implicit-workspace-member answer.- The role-set seam — the prerequisite.
lib/services/projectAccessService.ts—assertPermission.