(motir-ai) `POST /v1/lessons/:id/reinforce` — the named act, over BOTH scopes, not the tenant-only resolver the retire endpoints use
The HTTP door onto the ledger the previous card built, so motir-core can record a hand-fired hit across the 7.1 boundary.
What to build
POST /v1/lessons/:id/reinforce, under serviceAuth, in the /v1/lessons block of src/app.ts. A named act on a resource, POST rather than PATCH — the shape the retire / apply pair already established two routes above. Body: coreWorkspaceId, coreProjectId, occurrenceRef, optional source. It calls lessonService.reinforceLesson and returns the lesson plus whether this call was the one that counted, so a caller can tell a first record from a replay.
⚠️ It resolves over BOTH SCOPES, and this is the whole reason it is its own card
The obvious move is to reuse resolveOwnTenantLesson (src/services/lessonService.ts:1579), which retireTenantLessonByCoreIds (:1606) and applyTenantLessonByCoreIds (:1639) both call. Do not. That resolver is TENANT-ONLY by decision and refuses a global row as not_found on the existence-oracle posture — correct for those two, because retiring a global row from one customer's console would reach every other tenant.
Reinforcement is the opposite case. MOTIR-3323 established that a global lesson must be reinforceable, and the entire curated base corpus is global — a reinforce route that refuses global rows would leave this card's whole parent unable to touch the rows it exists for. So resolve with POST /v1/lessons/search's predicate instead: scope = 'global' OR (scope = 'tenant' AND aiProjectId = <caller>). Another tenant's row still raises not_found in the same words. Two questions, two resolvers, one store — the distinction src/app.ts already draws in prose between the tenant-only reads and the teaching read, applied to a write for the first time.
Acceptance criteria
POST /v1/lessons/:id/reinforceexists underserviceAuth, keyed bycoreWorkspaceId+coreProjectIdexactly as its siblings are, and takesoccurrenceRefas a required body field.- A GLOBAL lesson is reinforceable through it — asserted directly, because this is the case
resolveOwnTenantLessonwould have refused. - This project's own tenant lesson is reinforceable; ANOTHER project's raises
not_found, in the same words an unknown id does. Both asserted. - A replay with the same
occurrenceRefreturns 200 withcounted: falseand leaves both counters unchanged — the ledger's guarantee, surfaced rather than hidden. resolveOwnTenantLessonis UNCHANGED and still tenant-only; a test pins that the retire and apply endpoints still refuse a global row, so this card cannot loosen them by accident.- The route is registered in the
/v1/lessonsblock with a comment saying WHY its resolver differs from the two above it.
Context refs
src/app.ts— the/v1/lessonsblock: the tenant-only reads,POST /v1/lessons/search(the both-scopes predicate to copy), and the retire / apply named acts (the request shape to copy).src/services/lessonService.ts—resolveOwnTenantLesson(:1579),retireTenantLessonByCoreIds(:1606),applyTenantLessonByCoreIds(:1639),searchLessonsByCoreIds.tests/lessonInspectionSurface.test.ts— where the narrower tenant-only predicate is already pinned; this card's "still refuses a global row" assertion belongs beside it.MOTIR-3547(the parent) — the rule and the resolver trap, stated once.