motir-ai: POST /v1/lessons — create ONE tenant lesson, embedded, never global
The write half of the lessons surface: one route in src/app.ts, serviceAuth like every other /v1 route, creating a single tenant lesson for one project.
It must go through lessonService.createFromCorrection. That function embeds the text and inserts the row in one transaction. A route that inserts directly — or via SQL — leaves embedding NULL, and listForInjection's WHERE … embedding IS NOT NULL then drops the row from every query forever. The write returns 201, the lesson is in the table, the admin list shows it, and the planner never once sees it. Nothing anywhere reports a failure. This is the single way this card can be built wrong.
Global is refused. scope is not an input; the route creates tenant rows only. A global lesson is the product's curated corpus and is added by migration, never by a call. If the body carries scope: "global", that is a validation_error, not a silently-ignored field.
A near-duplicate is reported, not folded. captureMistake treats a tenant correction within CAPTURE_DEDUP_DISTANCE (0.05) of an existing lesson as a recurrence and reinforces that one instead of inserting. That is right for automatic capture and wrong here: a caller deliberately adding a lesson would get back a different lesson's id and have no way to know. Refuse instead, naming the existing lesson's id and title, so the caller can reword it, or go retire the one that is already there.
Accepts the three routing axes — kinds, types, phases — and stores them. Empty on an axis means unconstrained, so an untagged lesson reaches every query; that is the store's rule and this route does not second-guess it.
sourceRef carries provenance and buys idempotency for free: it is unique, and createFromCorrection already re-reads on a lost race. Give a hand-added lesson a sourceRef that marks it as such, so the detail view can distinguish the planner concluded this from someone wrote this down.
The project comes in the way the sibling read route takes it (MOTIR-3335) — same identifier convention, core's id mapped to aiProjectId. Do not invent a second one.
Acceptance criteria
POST /v1/lessonsexists insrc/app.tsunderserviceAuth, alongside the sibling read route.- It calls
lessonService.createFromCorrection; a test asserts the created row's embedding is non-null. - A test asserts the created lesson comes back from
selectForInjectionfor that project — existence in the table is not the assertion, retrievability is. - The same lesson does NOT come back for a different project.
- A body naming
scope: "global"is refused with avalidation_error; no row is written. - A create within
CAPTURE_DEDUP_DISTANCEof an existing tenant lesson is refused, and the response names the existing lesson's id and title. kinds,typesandphasesround-trip; an omitted axis stores empty and the lesson still matches an axis-filtered query.- A repeat with the same
sourceRefreturns the existing lesson rather than a second row. - A body missing
title,bodyorhowToApplyis refused byassertContent's existing rule, not a new one.
Context refs
motir-aisrc/app.ts— the/v1route table andserviceAuth;POST /v1/embeddingsis the shape to copy.motir-aisrc/services/lessonService.ts—createFromCorrection,insertWithEmbedding,assertContent,CAPTURE_DEDUP_DISTANCE,findNearestTenant.motir-aisrc/repositories/lessonRepository.ts—listForInjection'sembedding IS NOT NULLclause, the reason the embedding is not optional.motir-aiprisma/schema.prisma—LessonWorkItemKind,LessonWorkType,LessonPlanPhase,MistakeType.- MOTIR-3335 — the sibling
GET /v1/lessons; take its project-identifier convention.