MotirBuilding in public
MOTIR · moooon
onMotir
You’re viewing a public project. Anyone can view it — no account needed. Sign in to submit, upvote, or comment on requests.View-only — you can’t edit work items
MOTIR-3551

(motir-ai) `POST /v1/lessons/:id/reinforce` — the named act, over BOTH scopes, not the tenant-only resolver the retire endpoints use

Done
Description

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/reinforce exists under serviceAuth, keyed by coreWorkspaceId + coreProjectId exactly as its siblings are, and takes occurrenceRef as a required body field.
  • A GLOBAL lesson is reinforceable through it — asserted directly, because this is the case resolveOwnTenantLesson would 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 occurrenceRef returns 200 with counted: false and leaves both counters unchanged — the ledger's guarantee, surfaced rather than hidden.
  • resolveOwnTenantLesson is 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/lessons block with a comment saying WHY its resolver differs from the two above it.

Context refs

  • src/app.ts — the /v1/lessons block: 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.tsresolveOwnTenantLesson (: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.