`ai:plan` (4/4) — the job status and the eight streams, which today read ANY job by its id alone
The last ai:plan card, and the only one that closes a hole bigger than a missing permission.
What is on origin/main. app/api/ai/jobs/[jobId]/route.ts resolves the actor's session and active project, then calls getJob(jobId) — and lib/ai/motirAiClient.ts's getJob sends GET /v1/jobs/{id} with the SERVICE token and nothing else. No project, no workspace, no actor. The eight [jobId]/stream routes are the same passthrough one layer down: aiChatService.streamDiscovery(jobId) is a one-line return streamJob(jobId), and its siblings in aiPlanEditsService, aiExplanationService, aiGenerationService and aiSprintPlanningService are identical. Nothing on either side of the boundary binds a jobId to the project that submitted it. The binding exists — motir-ai's PlanJob.aiProjectId resolves to an AiProject carrying coreProjectId — but its JobView response is { jobId, status, result, error }, so motir-core has nothing to check against even if it wanted to.
So this card does two things: it gates the nine routes on ai:plan, and it starts SENDING the actor's project id on every job read and stream so that the motir-ai card can enforce the binding. Sending an id the producer does not yet read is harmless; requiring one the consumer does not yet send is not, which is why the order is this way round and not the other.
The nine operations
GET /api/ai/jobs/[jobId], and the streams under /api/ai/augment, /api/ai/chat, /api/ai/expand, /api/ai/explanation, /api/ai/plan/generate, /api/ai/plan/sprint, /api/ai/replan and /api/work-items/[id]/ai/plan.
Acceptance criteria
- Every one of the nine asserts
projectAccessService.assertPermission(projectId, ctx, 'ai:plan')before it opens a stream or reads a job, using the project the route already resolves. getJobandstreamJobinlib/ai/motirAiClient.tstake the core project id and send it to motir-ai as a query parameter; every caller passes it, and the parameter is not optional in the TypeScript signature — a call site that cannot supply one is a call site that has not resolved its project.- A test proves the client sends the id: assert on the request URL the fake
aiFetchreceives, not on the service's return value. - A test proves each stream route refuses a
viewerbefore any SSE frame is written. - The header comment in
app/api/ai/jobs/[jobId]/route.tsrecords what the gate does and does NOT cover — it establishes the caller may plan in THEIR project; the job's own ownership is enforced by motir-ai once its card lands, and until then a job id is still readable across projects by an actor who has it. ai:plandoes NOT flip toenforcement: 'enforced'here — the close-out card owns the flip.- The inventory rows for the nine 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
app/api/ai/jobs/[jobId]/route.ts— the route that reads any job by id.lib/ai/motirAiClient.ts—getJob(~line 200) and the stream helper (~line 771); both send only the id.lib/services/aiChatService.tsstreamDiscovery— the one-line passthrough the other seven copy.docs/decisions/member-facing-permissions.md— the role assignment forai:plan.- The role-set seam — the prerequisite.
- The motir-ai half — the producer card this one unblocks.