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

(motir-ai) The OCCURRENCE ledger — a hit becomes a row, and that row is what makes the clock bump exactly once

Done
Description

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 callerscaptureMistake'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 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.
  • Two calls with DIFFERENT occurrenceRefs on the same lesson bump twice and leave two ledger rows.
  • A row a person set to 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.
  • An EDIT bumps nothing. 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.

Context refs

  • src/repositories/lessonRepository.tsrecordRecurrence (:169) and the insert / update shapes to sit beside.
  • src/services/lessonService.tscaptureMistake'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.