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-3547

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

Done
Description

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-ai src/repositories/lessonRepository.ts:169) is the ONLY writer of lastOccurredAt / 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) and matchPlanningBugToLesson (:610). Both are machine-fired.
  • Tenant widening does not bump. lessonRepository.update() (:123) patches title / 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, and embedding = NULL when an embedded field changed. No clock, no count.
  • The sweep is a pure READ. 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.

⚠️ 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.tsid: 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

  1. 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.
  2. 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 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.
  3. One occurrence bumps once. If captureMistake or filePlanningBug already 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_lessons already returns id, and no child proposes changing it to obtain a handle.

The children — one repository each

  1. (motir-ai) The OCCURRENCE record — the row a hit becomes, with lastOccurredAt / recurrenceCount derived from it.
  2. (motir-ai) POST /v1/lessons/:id/reinforce — the named act, over BOTH scopes.
  3. (motir-core) reinforce_lesson — the seam, the MCP tool and the grant.
  4. (motir-ai) The generator carries the occurrence explicitly.
  5. (motir-core) Integration vitest through the real MCP transport.
  6. (motir-meta) The runbook amendment — the sweep's hit reinforces.
  7. (motir-ai) Its 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.

Context refs

  • motir-ai src/repositories/lessonRepository.tsrecordRecurrence (:169), update (:123), setHumanOverride (:186).
  • motir-ai src/services/lessonService.tscaptureMistake'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.tsupdateStatement (: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.tssummarizeLessonSearch (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.
  • 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.
Status
Done
Type
Bug