(motir-ai) `GET /v1/jobs/:id` and its stream become TENANT-SCOPED — a job that is not the caller's project's is a 404
The producer half of the job-ownership boundary contract. One PR in motir-ai; its consumer half is ai:plan (4/4) in motir-core, which lands FIRST and starts sending the core project id.
What is on origin/main (a01d96b). src/app.ts registers app.get('/v1/jobs/:id', serviceAuth, …) answering getJobView(c.req.param('id')), and src/jobs/planJobService.ts's getJobView(id) is planJobRepository.findById(db, id) with no tenant predicate. The stream route beside it is the same. The binding is right there and unused: PlanJob.aiProjectId → AiProject, which submitJob resolves through findOrCreateByCoreIds({ coreOrganizationId, coreWorkspaceId, coreProjectId }). So the job knows whose it is, and the read does not ask.
The convention to follow is in the same file. GET /v1/usage already requires the core ids per scope — its own comment says "the scope's coreWorkspaceId/coreProjectId are required per level so a foreign id can't widen the rollup" — and /v1/preplan requires coreProjectId outright. This card applies the same posture to the two job reads, which are the only /v1 endpoints that take an opaque id and answer without one.
The shape
- Both routes require a
coreProjectIdquery parameter, validated the way the neighbouring routes validate theirs (requireQuery). - The lookup resolves the job's
AiProjectand comparescoreProjectId. On a mismatch it raises the samenot_foundApiErroras an unknown id — a foreign job and a missing job must be indistinguishable from outside, or the endpoint becomes an existence oracle for other tenants' job ids. - The stream applies the check ONCE before the first frame, not per poll.
- Nothing about
POST /v1/jobschanges: it already carries the tenant envelope.
Acceptance criteria
GET /v1/jobs/:idandGET /v1/jobs/:id/streamboth requirecoreProjectIdand reject a request without it with the samevalidation_errorshape the sibling routes use.- A job whose
AiProject.coreProjectIddiffers from the query's returnsnot_foundwith a body byte-identical in shape to the unknown-id case, asserted by a test that compares the two responses rather than each separately. - The stream refuses before emitting any frame, proved by a test that asserts on an empty body.
getJobView's signature carries the scoping argument, so a future caller cannot get an unscoped read by accident — there is no unscoped overload left in the module.- Any document in this repo describing the §2.4 job-view contract is updated in the same PR; if
grep -rn "2.4" src docsfinds none, say so in the PR body rather than leaving it ambiguous. - The existing job-lifecycle tests still pass with the new required parameter, and the suite is green.
Context refs
src/app.ts— the two/v1/jobs/:id*route registrations, and/v1/usage+/v1/preplanas the convention to copy.src/jobs/planJobService.ts—getJobView,toJobView,submitJoband itsfindOrCreateByCoreIdstenant resolution.src/jobs/planJobRepository.ts—findById, the lookup that needs the predicate.prisma/schema.prisma—PlanJob.aiProjectIdand theAiProjectcore-id columns.ai:plan(4/4) — the consumer half, which must be merged before this one.