Lessons step 7 — a lesson is readable, writable and browsable by a human (#3734) #169

Merged
bvandeusen merged 4 commits from dev into main 2026-09-19 16:12:39 -04:00
Owner

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.

1c438b2deleteLesson 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.ai/code/session_01821k5B3Ysecp9fNYs92Kuy

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
bvandeusen added 4 commits 2026-09-19 16:12:31 -04:00
fix(lessons): a derived mirror survives the generic note door, by kind not by name (#3734)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m31s
CI & Build / Build & push image (push) Successful in 23s
1252d0e305
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
feat(lessons): the REST door a human can actually reach (#3734)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 48s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / Python tests (push) Successful in 1m33s
CI & Build / Build & push image (push) Successful in 23s
d36d68a20f
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
feat(lessons): a lesson is readable, writable and browsable by a human (#3734)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Failing after 31s
CI & Build / integration (push) Successful in 48s
CI & Build / Python tests (push) Successful in 1m33s
CI & Build / Build & push image (push) Skipped
95dc25eaab
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
fix(lessons): deleteLesson matches apiDelete's contract (#3734)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 42s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m32s
CI & Build / Build & push image (push) Successful in 31s
1c438b27e2
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
bvandeusen merged commit b131e2b04c into main 2026-09-19 16:12:39 -04:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bvandeusen/FabledScribe#169