recordRecurrence (src/repositories/lessonRepository.ts:169) writes lastOccurredAt and recurrenceCount together and takes no identifier for the occurrence that caused it. That is why one occurrence bumps once cannot be honoured today: nothing in the store can tell a second call about the SAME occurrence from a genuine second occurrence.
This card adds the ledger that can, and the service operation that writes through it.
A LessonOccurrence model + its migration. id, lessonId (FK to Lesson, onDelete: Cascade), occurrenceRef (the caller's identifier for the event — a MOTIR-<n> key, a job id), at, and source (which door recorded it). @@unique([lessonId, occurrenceRef]) is the whole mechanism — the idempotency key and the audit trail are the same row.
lessonRepository.recordOccurrence(db, { lessonId, occurrenceRef, at, source }). Insert the ledger row; on a unique-constraint conflict, do NOTHING AT ALL — not the row, not the counters — and report which happened. Only a genuine insert bumps lastOccurredAt and increments recurrenceCount, in the same transaction, exactly as recordRecurrence does today.
⚠️ The counters stay STORED, and are written FROM the ledger rather than derived from it. Deriving them would break two things: listForInjection's lastOccurredAt >= staleCutoff predicate is index-served (@@index([lastOccurredAt])), and every existing row carries a recurrenceCount with no ledger rows behind it — so a derived count would read 0 for the entire corpus on the day this ships. No backfill is needed or wanted: the ledger accumulates from now on, and the counters continue from where they are.
lessonService.reinforceLesson(...) — the service operation the HTTP door will call, over a lesson id. It resolves the lesson, calls recordOccurrence, and returns the row plus whether this call was the one that counted.
Re-point the two existing callers — captureMistake's near-dup arm (src/services/lessonService.ts:401) and matchPlanningBugToLesson (:610) — at recordOccurrence, passing the occurrence identifier each already holds (the capture's correction/job id; the planning bug's key). This is what makes one occurrence bumps once true across doors rather than only within this one.
LessonOccurrence model and its forward-only migration exist, with @@unique([lessonId, occurrenceRef]) and a cascade from Lesson.recordOccurrence inserts the ledger row and bumps lastOccurredAt + recurrenceCount in ONE transaction; a second call with the same (lessonId, occurrenceRef) writes nothing and leaves both counters byte-identical. Asserted directly.occurrenceRefs on the same lesson bump twice and leave two ledger rows.humanOverride: retired is still ledgered and still bumped, and is still absent from listForInjection. Retirement is a separate axis and the bump does not revive it — the MOTIR-3343 conclusion, asserted here rather than restated.lessonRepository.update() is unchanged by this card and a test asserts that calling it leaves lastOccurredAt and recurrenceCount untouched — the assertion that separates the bump belongs to the occurrence from every edit bumps.captureMistake and matchPlanningBugToLesson both write through recordOccurrence, and a test drives one occurrence through capture and then through a second recordOccurrence with the same ref, asserting recurrenceCount advanced by exactly one.recordRecurrence is either removed or left with no callers and marked as superseded; the repository does not keep two writers of the same two fields.src/repositories/lessonRepository.ts — recordRecurrence (:169) and the insert / update shapes to sit beside.src/services/lessonService.ts — captureMistake's near-dup arm (:401), matchPlanningBugToLesson (:610).prisma/schema.prisma — the Lesson model, its lastOccurredAt / recurrenceCount comments, and @@index([lastOccurredAt]).MOTIR-3547 (the parent) — the rule this implements, stated once so no child restates it.