(motir-ai) The OCCURRENCE ledger — a hit becomes a row, and that row is what makes the clock bump exactly once
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.
What to build
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.
Acceptance criteria
- A
LessonOccurrencemodel and its forward-only migration exist, with@@unique([lessonId, occurrenceRef])and a cascade fromLesson. recordOccurrenceinserts the ledger row and bumpslastOccurredAt+recurrenceCountin ONE transaction; a second call with the same(lessonId, occurrenceRef)writes nothing and leaves both counters byte-identical. Asserted directly.- Two calls with DIFFERENT
occurrenceRefs on the same lesson bump twice and leave two ledger rows. - A row a person set to
humanOverride: retiredis still ledgered and still bumped, and is still absent fromlistForInjection. Retirement is a separate axis and the bump does not revive it — the MOTIR-3343 conclusion, asserted here rather than restated. - An EDIT bumps nothing.
lessonRepository.update()is unchanged by this card and a test asserts that calling it leaveslastOccurredAtandrecurrenceCountuntouched — the assertion that separates the bump belongs to the occurrence from every edit bumps. captureMistakeandmatchPlanningBugToLessonboth write throughrecordOccurrence, and a test drives one occurrence through capture and then through a secondrecordOccurrencewith the same ref, assertingrecurrenceCountadvanced by exactly one.recordRecurrenceis either removed or left with no callers and marked as superseded; the repository does not keep two writers of the same two fields.
Context refs
src/repositories/lessonRepository.ts—recordRecurrence(:169) and theinsert/updateshapes to sit beside.src/services/lessonService.ts—captureMistake's near-dup arm (:401),matchPlanningBugToLesson(:610).prisma/schema.prisma— theLessonmodel, itslastOccurredAt/recurrenceCountcomments, and@@index([lastOccurredAt]).MOTIR-3547(the parent) — the rule this implements, stated once so no child restates it.