motir-core: the create-lesson seam — motirAiClient method behind a permission-asserting service
motir-core's half of the create path: a motirAiClient method for POST /v1/lessons and a service function above it that asserts the permission and resolves the project.
The MCP tool built next is a thin adapter over this, the way add_comment is a thin adapter over commentsService.addComment — no business logic in the tool. Putting the gate here rather than in the tool is what keeps it true for the next caller: if the settings UI ever grows an add a lesson button, it goes through this same function and inherits the same check.
The permission is asserted before the upstream call, not after. A caller without it must never cause a request to motir-ai — a refusal that has already written a row upstream is not a refusal, and it spends a tenant's budget on a rejected request.
The lesson is tenant-scoped by construction. The service resolves the acting project from the caller's context and passes that; it does not take a project from the caller and trust it, and it has no parameter that could produce a global lesson.
motirAiClient is the file that carries the warning about this envelope being a cross-repo contract that fails closed and silently. The same discipline as MOTIR-3350 applies: field names in one place, and the test asserts on the serialized body rather than on the argument object.
Errors from upstream are surfaced, not swallowed. The near-duplicate refusal that MOTIR-3359 returns carries the existing lesson's id and title, and the caller needs both — a generic "could not create" turns an actionable answer into a dead end.
Acceptance criteria
- A
motirAiClientmethod forPOST /v1/lessons, following the neighbouring methods' shape. - A service function above it that asserts the lesson-library-change permission before the client is called, and no upstream request is made on refusal — asserted by a test that fails if the request goes out.
- The project is resolved from the acting context; there is no parameter through which a caller can name another project or ask for a global lesson.
- The upstream near-duplicate refusal reaches the caller with the existing lesson's id and title intact.
- A test asserts on the serialized request body, not the argument object, and the wire field names appear as literals in the test.
- Nothing in this card renders UI or registers a tool.
Context refs
motir-corelib/ai/motirAiClient.ts— the client, itsserviceAuthcalls, and its warning about silent contract failure.motir-corelib/permissions/resolve.ts— how a key is checked at a call site.motir-corelib/services/— the service layer an MCP tool adapts over;commentsServiceis the shape.- MOTIR-3359 — the route this calls, and the refusals it must pass through.
- MOTIR-3336 — the permission key this asserts (via the story-level ordering on MOTIR-3329).
- MOTIR-3337 — the read seam; this is its write sibling and should look like it.