A lesson matched BY HAND is never reinforced — the runbook sweep's hit and every widening leave `lastOccurredAt` untouched, so only the two automatic paths can reset the retirement clock
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.
Measured against origin/main, 2026-08-26
recordRecurrence(motir-aisrc/repositories/lessonRepository.ts:169) is the ONLY writer oflastOccurredAt/recurrenceCount, and writes them together by design so they cannot disagree.- It has exactly two callers:
captureMistake's near-dup arm (src/services/lessonService.ts:401) andmatchPlanningBugToLesson(:610). Both are machine-fired. - Tenant widening does not bump.
lessonRepository.update()(:123) patchestitle/body/why/howToApply/categories/kinds/types/phases/bugWorkItemKey— and nothing else. - Global widening does not bump. The generator's
updateStatement(scripts/generate-lessons-migration.ts:186) sets content, the three axes,updatedAt, andembedding = NULLwhen an embedded field changed. No clock, no count. - The sweep is a pure READ.
motir-metaprompts/run.mdstep 5 and_shared.md's lesson-store row instruct onesearch_lessonscall and carrying the results into the prompt. Nothing after a hit writes anything anywhere.
⚠️ The correction — a claim this card made that is not true
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 trap this split pins, so a child does not walk into it
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.
The rule — the bump belongs to the OCCURRENCE, not to the edit
- A match to an occurrence that just happened is a HIT, and it bumps whether or not any edit follows. Deciding the lesson already covers the case is a hit. Widening it is a hit. Same write, both times — the edit is optional, the bump is not.
- An editorial touch with no occurrence behind it bumps NOTHING — a corpus sweep broadening axes, a reword, a batch retag. Bumping there fabricates an occurrence and makes
recurrenceCountlie. This is not a new judgement: it is the precise reasoning that producedLessonHumanOverride.exempt, whose schema comment records that bumpinglastOccurredAtfor a human "still applies" decision was the rejected alternative.exemptstays the instrument for that case. - One occurrence bumps once. If
captureMistakeorfilePlanningBugalready fired for it, the follow-up widening must not bump again.
Acceptance criteria — this CONTAINER is done when its children are
- Every child below has a merged pull request on its own repository's default branch. The container names all three repositories, so it does not complete until all three have delivered.
- The rule above is what the children implement, and it is stated here once so no child restates it and they cannot drift apart.
- No child re-derives the correction:
search_lessonsalready returnsid, and no child proposes changing it to obtain a handle.
The children — one repository each
- (motir-ai) The OCCURRENCE record — the row a hit becomes, with
lastOccurredAt/recurrenceCountderived from it. - (motir-ai)
POST /v1/lessons/:id/reinforce— the named act, over BOTH scopes. - (motir-core)
reinforce_lesson— the seam, the MCP tool and the grant. - (motir-ai) The generator carries the occurrence explicitly.
- (motir-core) Integration vitest through the real MCP transport.
- (motir-meta) The runbook amendment — the sweep's hit reinforces.
- (motir-ai) Its
SHARED_PLANNING_RULESmirror.
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.
Context refs
motir-aisrc/repositories/lessonRepository.ts—recordRecurrence(:169),update(:123),setHumanOverride(:186).motir-aisrc/services/lessonService.ts—captureMistake's near-dup arm (:401),matchPlanningBugToLesson(:610),resolveOwnTenantLesson(:1579),retireTenantLessonByCoreIds(:1606),applyTenantLessonByCoreIds(:1639).motir-aisrc/app.ts— the/v1/lessonsblock: the tenant-only reads,POST /v1/lessons/search's both-scopes predicate, and the retire / apply named acts.motir-aiscripts/generate-lessons-migration.ts—updateStatement(:186), the content-hash drift marker.motir-aiprisma/schema.prisma— theLessonHumanOverrideenum comment (the rejected-alternative reasoning) and thelastOccurredAt/recurrenceCountfield comments.motir-corelib/mcp/tools/searchLessons.ts—summarizeLessonSearch(the prose channel) andrunSearchLessons'stoolOk(..., exempt(...))(the structured one).motir-metaprompts/run.mdstep 5,prompts/_shared.mdline 106 — the sweep that reads and never writes.- MOTIR-3322 — the lifecycle story this completes. MOTIR-3326 — the hit/miss verdict this extends to the hand path. MOTIR-3343 — the human-override rule the bump must respect. MOTIR-3323 — why global rows must be reinforceable.