Milestone 385 step 7. Closes the "no UI, no ship" gap: the lesson kind existed only through the MCP tools, so a person could not create, read, edit or retire one.
CI run 7060 green on all six jobs at 1c438b2.
1252d0e — a derived mirror survives the generic note door
update_note recomposed the data mirror for snippets only, so a lesson edited through the generic door kept a stale mirror. Worse for a lesson than for a snippet: the stale mirror holds the old TRIGGER, so the lesson keeps firing for a situation it no longer names while displaying the new one.
Fixed with a registry keyed by note type rather than a name check, so the third kind registers instead of editing a conditional. TRIGGER_SEP now lives in exactly one place with an untrigger_title inverse, and a test asserts the separator is spelled once.
d36d68a — the REST door
routes/lessons.py: list, create, read, update, delete, and the reverse taught-by/<id>.
Both doors share one serializer (lessons_svc.lesson_to_dict), so the composed document is identical whichever wrote it — the document is what ranks, and a door composing its own title would produce lessons that rank differently from the agent's. ACL follows rule 78: resolve share-aware, write as the owner. An empty when_to_apply is refused at create and cannot be cleared on update — a triggerless lesson saves, reads correctly in every listing, and never surfaces, with nothing about the stored row to show it.
95dc25e — the UI
LessonEditorView (three named fields, trigger first, live title preview, 409 duplicate panel), LessonDetailView (trigger leads, resolved learned_from_records, an origin footer stating global-by-default as a property rather than an apology), LessonsTaughtPanel (the reverse direction, silent when empty — built as a component because the same question is worth answering on any record a lesson can cite, #3207), api/lessons.ts, three routes, and lesson joining the Knowledge facet chips and badges.
1c438b2 — deleteLesson typed to apiDelete's actual contract
Milestone 385 is 8/9 after this. Step 8 (#3735) is the acceptance read and stays open: it needs real sessions to accumulate before cross-project pull-through can be measured.
Milestone 385 step 7. Closes the "no UI, no ship" gap: the lesson kind existed only through the MCP tools, so a person could not create, read, edit or retire one.
CI run 7060 green on all six jobs at `1c438b2`.
## `1252d0e` — a derived mirror survives the generic note door
`update_note` recomposed the `data` mirror for snippets only, so a lesson edited through the generic door kept a stale mirror. Worse for a lesson than for a snippet: the stale mirror holds the old TRIGGER, so the lesson keeps firing for a situation it no longer names while displaying the new one.
Fixed with a registry keyed by note type rather than a name check, so the third kind registers instead of editing a conditional. `TRIGGER_SEP` now lives in exactly one place with an `untrigger_title` inverse, and a test asserts the separator is spelled once.
## `d36d68a` — the REST door
`routes/lessons.py`: list, create, read, update, delete, and the reverse `taught-by/<id>`.
Both doors share one serializer (`lessons_svc.lesson_to_dict`), so the composed document is identical whichever wrote it — the document is what ranks, and a door composing its own title would produce lessons that rank differently from the agent's. ACL follows rule 78: resolve share-aware, write as the owner. An empty `when_to_apply` is refused at create and cannot be cleared on update — a triggerless lesson saves, reads correctly in every listing, and never surfaces, with nothing about the stored row to show it.
## `95dc25e` — the UI
`LessonEditorView` (three named fields, trigger first, live title preview, 409 duplicate panel), `LessonDetailView` (trigger leads, resolved `learned_from_records`, an origin footer stating global-by-default as a property rather than an apology), `LessonsTaughtPanel` (the reverse direction, silent when empty — built as a component because the same question is worth answering on any record a lesson can cite, #3207), `api/lessons.ts`, three routes, and `lesson` joining the Knowledge facet chips and badges.
## `1c438b2` — `deleteLesson` typed to `apiDelete`'s actual contract
---
Milestone 385 is 8/9 after this. Step 8 (#3735) is the acceptance read and stays open: it needs real sessions to accumulate before cross-project pull-through can be measured.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
Groundwork for step 7, and a data-integrity fix in its own right.
Two kinds keep a queryable mirror in `notes.data` derived from their body:
snippets and, since milestone 385, lessons. Every read prefers the mirror —
deliberately, because parsing markdown to answer what an index can answer is
how a hot path rots. So a write that moves the body must move the mirror.
#3128 found that hole for snippets and plugged it with a hard-coded
`if note.note_type == SNIPPET_NOTE_TYPE`. The plug was correct and did not
generalise: lessons arrived with the same design and none of the protection,
which is precisely the "don't add a fourth instance" defect #3734 was told to
avoid.
The cost is higher for a lesson. A stale snippet mirror reports the wrong
path. A stale lesson mirror reports the wrong TRIGGER, and the trigger is the
whole retrieval story — the lesson goes on firing for the situation it used
to name while displaying the one it now names. Silent, and confident.
So `update_note` now dispatches through `_mirror_recomposers()`, a
note_type -> recomposer table. A kind with a derived mirror is covered by
registering it, not by someone remembering to widen an if.
`lessons.recompose_data` is the lesson's entry. It recovers the subject with
`embeddings.untrigger_title` — new, and deliberately placed beside the join it
inverts rather than in the caller that wanted it, because a separator spelled
in two files is a separator that will one day be changed in one of them
(#3207). `TRIGGER_SEP` is now the one spelling, and `parse_snippet_fields`
uses it too; it had the third copy inline.
The two inverses stay distinct on purpose: a snippet partitions at the first
separator (its name is a symbol), a lesson strips an exact known suffix (its
subject may legitimately contain a dash). Different algorithms, one constant,
so they cannot disagree about where the seam is.
Provenance is DROPPED when the body drops it, which is the opposite call from
a snippet's `verification` — that is carried because it was never in the body
to delete. The body is the authority; carrying a value the reader just removed
is the failure the recompose exists to prevent.
Tests: test_snippet_mirror_generic_door.py becomes
test_derived_mirror_generic_door.py, since the concern is now plural. The
registry property is asserted directly (every kind with a mirror is in the
table; the dispatch names no kind inline), plus the lesson cases and the
join/inverse round-trip. `fake_lesson` moves to tests/helpers.py — it existed
in test_lesson_surfacing.py and a second copy was about to be written — and
gains the explicit `None`s `fake_snippet` carries, because update_note reads
`verify_with` and a MagicMock is truthy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
Step 7, part two. Milestone 385 built the lesson kind through the MCP tools,
which is the agent's surface. The Vue app speaks REST, so a lesson was a
record a person could not create, read, edit or retire — rule 27 failing at
the door rather than in the view.
`/api/lessons` now offers list, create, read, update and trash, plus
`/api/lessons/taught-by/<id>` — the reverse of `learned_from`, which the task
body calls the direction that gets forgotten and arguably the more useful one:
a reader opening an old issue wants to know what was learned from it, and
until now the relation was only navigable from the lesson's side.
`lessons_taught_by` reads `data[taught_by]` through `path_exists`, the same
jsonpath dialect the snippet location lookup uses, so both reverse lookups hit
the GIN index (0070) the same way rather than scanning bodies. Share-aware via
`readable_notes_clause`: it renders beside a record the caller can already
see, so a lesson shared with them belongs there exactly as their own does.
THE TRIGGER IS REFUSED WHEN EMPTY, at create and at update. This is the one
place the door is not a thin wrapper, and it is deliberate: the service will
store a triggerless lesson quite happily — it saves, reads correctly in every
listing, and never surfaces. There is nothing to notice afterwards, because it
looks exactly like a lesson that works. Better to refuse it than to hand back
a record that looks finished. The refusal says why, so the next reader does
not take it for a nag and delete it.
`lesson_to_dict` moves into the service and the MCP tool's `_to_dict` becomes
an alias for it. Both doors now return one shape — a payload spelled once per
door answers the two of them differently the first time a field is added — and
both compose through `services/lessons.py`, so a lesson written from the web
ranks identically to one written by an agent. The document IS what ranks, so
that parity is the whole reason the door is thin.
The dedup gate matches the MCP path: two lessons under one trigger compete in
a single ranked list for one reserved slot, so a duplicate here displaces
rather than merely clutters.
NOT DONE YET: this is the door, not the UI. #3734 stays in_progress until the
Vue views, the router entries, the Knowledge browse badge and the both-ways
sources panel exist — rule 27 is about the operator being able to touch it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
Step 7's actual UI. Before this the frontend had zero lesson code — the kind
existed for agents only, which is rule 27 failing.
THE EDITOR ASKS FOR THE TRIGGER BY NAME, and leads with it. Three fields —
the trigger, the claim, the detail — never one markdown box. That is the
design step 1 settled, and the evidence is blunt: the snippet corpus carries
a trigger on every record with no guard anywhere, because a service composes
the title from a named parameter. What is at 100% is a named structured
field, not a writer remembering a convention. The trigger gets the most room,
its own explanation, and a save button that refuses without it and says why.
The form shows the composed title live, so the writer is agreeing to a
document they can read rather than one assembled out of sight. A 409 from the
duplicate gate is rendered as the record that already covers the moment, with
a link to improve it and an explicit override — not as a failure.
THE BROWSE VOCABULARY GAINS THE KIND, which #3161 warned this step not to get
wrong: a facet chip, a badge label, and routing to `/lessons/:id` rather than
the note editor, which cannot edit a trigger. The badge is neutral alongside
snippet and process — a hue would make the softest record in the corpus look
like the loudest, next to a rule that actually binds.
BOTH DIRECTIONS OF THE PROVENANCE. The detail page resolves `learned_from` to
titles rather than bare ids, because "#4181" tells a reader nothing about
whether it is worth opening. And `LessonsTaughtPanel` answers the reverse on
the record's own page — the direction the task body calls the one that gets
forgotten. It has no author to type it, which is exactly why it tends never
to get built. A component, not markup in the task editor, so the same panel
mounts on any record a lesson can cite instead of being written a second time
(#3207). Silent when empty: most records taught no lesson, and a panel that
says "None yet" everywhere is one people learn to skip.
GLOBAL-BY-DEFAULT IS MADE LEGIBLE. A lesson meeting you on a project it was
not written on reads as a bug unless the page says otherwise, so the origin
line says it as a property of the kind rather than as an apology.
Design system tokens throughout; no new raw hex. `--fs-error` rather than
`--fs-danger` — 31 uses against 1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
CI 7059's typecheck, two errors on one line: `apiDelete` returns
`Promise<void>` and takes no type argument. I had given it the response body's
shape, which it discards.
Matched to how snippets delete, rather than adding a second delete helper to
carry the batch id — no caller has wanted it, and the second helper would be
the duplication rather than the feature.
Everything else in the UI batch typechecked clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Milestone 385 step 7. Closes the "no UI, no ship" gap: the lesson kind existed only through the MCP tools, so a person could not create, read, edit or retire one.
CI run 7060 green on all six jobs at
1c438b2.1252d0e— a derived mirror survives the generic note doorupdate_noterecomposed thedatamirror for snippets only, so a lesson edited through the generic door kept a stale mirror. Worse for a lesson than for a snippet: the stale mirror holds the old TRIGGER, so the lesson keeps firing for a situation it no longer names while displaying the new one.Fixed with a registry keyed by note type rather than a name check, so the third kind registers instead of editing a conditional.
TRIGGER_SEPnow lives in exactly one place with anuntrigger_titleinverse, and a test asserts the separator is spelled once.d36d68a— the REST doorroutes/lessons.py: list, create, read, update, delete, and the reversetaught-by/<id>.Both doors share one serializer (
lessons_svc.lesson_to_dict), so the composed document is identical whichever wrote it — the document is what ranks, and a door composing its own title would produce lessons that rank differently from the agent's. ACL follows rule 78: resolve share-aware, write as the owner. An emptywhen_to_applyis refused at create and cannot be cleared on update — a triggerless lesson saves, reads correctly in every listing, and never surfaces, with nothing about the stored row to show it.95dc25e— the UILessonEditorView(three named fields, trigger first, live title preview, 409 duplicate panel),LessonDetailView(trigger leads, resolvedlearned_from_records, an origin footer stating global-by-default as a property rather than an apology),LessonsTaughtPanel(the reverse direction, silent when empty — built as a component because the same question is worth answering on any record a lesson can cite, #3207),api/lessons.ts, three routes, andlessonjoining the Knowledge facet chips and badges.1c438b2—deleteLessontyped toapiDelete's actual contractMilestone 385 is 8/9 after this. Step 8 (#3735) is the acceptance read and stays open: it needs real sessions to accumulate before cross-project pull-through can be measured.
🤖 Generated with Claude Code
https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy