An admin can RETIRE a lesson — a soft status change that stops injection and keeps the record
Reading a lesson you disagree with and being unable to do anything about it is worse than not seeing it. This story gives the admin the one action that matters: stop applying this.
A status change, never a delete. The lesson stops being injected and stays fully readable, with who retired it and when. A lesson that was applied for two months is part of the explanation for every plan generated in that window; deleting it makes those plans unexplainable afterwards.
Reversible. An admin who retires one and changes their mind can bring it back. That is what makes retiring a low-stakes action rather than one people avoid.
⚠️ A manual retirement is NOT the same state as ageing out, and the difference is the whole design. The corpus already retires by non-recurrence — a lesson unseen for the retention window drops out of results, and a recurrence revives it automatically. If a manual retirement writes the same state, then the next recurrence un-retires what a human deliberately switched off, and the planner resumes injecting a lesson its owner rejected. The two must be distinguishable: a human decision outranks the clock and survives a recurrence.
⚠️ AND THE SAME RULE RUNS THE OTHER WAY — an exempt from retirement FLAG (Yue, 2026-08-23).
The block above says a human's no must outrank the clock. This decides the mirror case: a human's
yes must outrank it too.
MOTIR-3332's design draws two not-applied rows — Not applied (switched off) and
Not seen in 90 days (aged out by non-recurrence) — and gives BOTH the action Apply again. On
the switched-off row that is a flip of the retirement state this card already owes. On the aged-out
row there was nothing to write, and the two candidates were not equal:
- bump
lastOccurredAt— REJECTED. It fabricates an occurrence that did not happen, andrecordRecurrencewrites the clock andrecurrenceCounttogether (MOTIR-3326) precisely so the two can never disagree; a bump would make the count lie or diverge. - an
exempt from retirementflag — CHOSEN. An exempt lesson is never dropped by thelastOccurredAt >= staleCutoffpredicate, whatever its clock says. Nothing is fabricated: the lesson's history stays exactly what happened, and a separate field records that a person decided it still applies.
⚠️ These are ONE mechanism with two values, not two unrelated booleans. Manual retirement is a
human said no, and the clock must not override it; exemption is a human said yes, and the clock
must not override it. Both are the sentence this card already states — a human decision outranks
the clock and survives a recurrence. Modelling them as one human override (unset → the clock
decides · retired → never injected, and a recurrence does not revive it · exempt → always injected,
and ageing out does not drop it) keeps the two rules that matter in one place; two independent flags
invite the fourth state nobody has defined, and let a later edit satisfy one rule while breaking the
other. enabled stays what it is; this is the axis beside it.
On the surface, exempting an aged-out lesson returns it to the ordinary applied row — the
Not seen in 90 days badge disappears, because the statement it makes is no longer true of what the
planner is being told.
That is the sharp end of this story. Everything else is a row, a button and an audit field.
Permission-guarded, on the mutation as well as the control — a request without the permission is refused at the API, not merely un-rendered. Tenant lessons only: a project may retire what it taught its own planner, never the shipped global corpus.
Acceptance criteria
- An admin can retire a lesson from the list or the detail view, and un-retire it.
- A retired lesson is excluded from injection, asserted at the query — not filtered in the view.
- A retired lesson remains readable, and shows who retired it and when.
- A recurrence does NOT revive a manually retired lesson, asserted directly. Ageing out and being switched off are different states.
- An aged-out lesson can be brought back and STAYS back —
Apply againon aNot seen in 90 daysrow exempts it from retirement, and it is still injected after the retention window passes again, asserted with the clock moved rather than waited out. - Nothing writes
lastOccurredAtorrecurrenceCountto express a human decision, asserted: the occurrence history records only occurrences. - A retire request without the permission is refused at the API.
- A global lesson cannot be retired through this surface, asserted.
- The page-state contract is honoured: the row's own state updates from the response, and any server-rendered count elsewhere on the surface refreshes.
- An E2E walks: retire → confirm it no longer applies → un-retire → confirm it does.
Context refs
motir-aisrc/repositories/lessonRepository.ts—enabled, thelastOccurredAt >= staleCutoffclause, andrecordRecurrence, which re-assertsenabledand is exactly what must not override a human.motir-aisrc/services/lessonService.ts—retirementCutoff, the injection read.motir-corelib/permissions/catalog.ts— the keys added by the sibling story.- MOTIR-3329 — the surface and the design this action lives in.
- MOTIR-3323 — the automatic revival this must not collide with.