Re-planned 2026-08-26 by
motir run MOTIR-3547. This card is now the CONTAINER + defect record; the work lands in its seven children, one repository each. One acceptance criterion below was FALSE and has been removed — see The correction. Nothing was archived.
MOTIR-3322 closed the retire-by-non-recurrence loop at both ends — for the two automatic paths. A third path was never wired, and on Motir's own corpus it is the dominant one: a human or agent matching an occurrence to a lesson by hand, through the runbook's search_lessons sweep. That path writes nothing, so the corpus still decays on a timer that only the machine can reset.
origin/main, 2026-08-26recordRecurrence (motir-ai src/repositories/lessonRepository.ts:169) is the ONLY writer of lastOccurredAt / recurrenceCount, and writes them together by design so they cannot disagree.captureMistake's near-dup arm (src/services/lessonService.ts:401) and matchPlanningBugToLesson (:610). Both are machine-fired.lessonRepository.update() (:123) patches title / body / why / howToApply / categories / kinds / types / phases / bugWorkItemKey — and nothing else.updateStatement (scripts/generate-lessons-migration.ts:186) sets content, the three axes, updatedAt, and embedding = NULL when an embedded field changed. No clock, no count.motir-meta prompts/run.md step 5 and _shared.md's lesson-store row instruct one search_lessons call and carrying the results into the prompt. Nothing after a hit writes anything anywhere.This card asserted that search_lessons "returns no identifier", citing motir-core lib/mcp/tools/searchLessons.ts:150. That line is the PROSE render, which is one of the tool's TWO return channels. Traced on origin/main, id survives every hop of the other one:
POST /v1/lessons/search (motir-ai src/app.ts — id: l.id) → RawRankedLesson.id (motir-core lib/ai/motirAiClient.ts:1153) → toRankedLessonDTO (lib/services/projectLessonsService.ts:162) → toolOk(summarizeLessonSearch(result), exempt(SEARCH_LESSONS_TOOL_NAME, { outcome, lessons })) — and lib/mcp/payloads/exemptions.ts:70 documents the payload as { id, title, body, howToApply, scope, kinds, types, phases, distance }.
The handle already exists. The criterion asking for one is REMOVED, no search_lessons change is a prerequisite, and the reinforcement call names the lesson by the id the caller is already holding. (A prose-render line so a human reading a transcript also sees the id is a nicety, not a prerequisite; it rides the MCP-tool card if it is wanted at all.)
The existing retire / apply endpoints resolve through resolveOwnTenantLesson (motir-ai src/services/lessonService.ts:1579), which is TENANT-ONLY and refuses a global row as not_found — deliberately, on the existence-oracle posture. Reinforcement must reach GLOBAL rows: that is exactly what MOTIR-3323 established, and the entire curated base corpus is global. So the reinforce path may NOT reuse that resolver. It takes POST /v1/lessons/search's scope predicate — scope = 'global' OR (scope = 'tenant' AND aiProjectId = <caller>) — instead.
recurrenceCount lie. This is not a new judgement: it is the precise reasoning that produced LessonHumanOverride.exempt, whose schema comment records that bumping lastOccurredAt for a human "still applies" decision was the rejected alternative. exempt stays the instrument for that case.captureMistake or filePlanningBug already fired for it, the follow-up widening must not bump again.search_lessons already returns id, and no child proposes changing it to obtain a handle.lastOccurredAt / recurrenceCount derived from it.POST /v1/lessons/:id/reinforce — the named act, over BOTH scopes.reinforce_lesson — the seam, the MCP tool and the grant.SHARED_PLANNING_RULES mirror.Per-unit tests ride each code card's own floor (code + tests are one deliverable); only the cross-boundary seam earns a dedicated test card.
motir-ai src/repositories/lessonRepository.ts — recordRecurrence (:169), update (:123), setHumanOverride (:186).motir-ai src/services/lessonService.ts — captureMistake's near-dup arm (:401), matchPlanningBugToLesson (:610), resolveOwnTenantLesson (:1579), retireTenantLessonByCoreIds (:1606), applyTenantLessonByCoreIds (:1639).motir-ai src/app.ts — the /v1/lessons block: the tenant-only reads, POST /v1/lessons/search's both-scopes predicate, and the retire / apply named acts.motir-ai scripts/generate-lessons-migration.ts — updateStatement (:186), the content-hash drift marker.motir-ai prisma/schema.prisma — the LessonHumanOverride enum comment (the rejected-alternative reasoning) and the lastOccurredAt / recurrenceCount field comments.motir-core lib/mcp/tools/searchLessons.ts — summarizeLessonSearch (the prose channel) and runSearchLessons's toolOk(..., exempt(...)) (the structured one).motir-meta prompts/run.md step 5, prompts/_shared.md line 106 — the sweep that reads and never writes.