docs(6205): lessons comments state the reason, not the instance that taught it

The module docstring keeps why the kind exists, where its trigger lives, what
it inherits and why it needs no migration, without milestone, decision and
note ids or this instance's snippet counts. The body-strip docstring states
why taking the note matters instead of retelling the defect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 20:21:57 -04:00
co-authored by Claude Opus 5.5
parent 7e9d33d9d9
commit d1b442c572
+71 -84
View File
@@ -4,12 +4,11 @@ A *lesson* is a Note with ``note_type='lesson'``: a better way to think about a
problem, or a solution that transfers, recorded so a later session meets it at problem, or a solution that transfers, recorded so a later session meets it at
the moment it applies — and **without binding the reader**. the moment it applies — and **without binding the reader**.
WHY THE KIND EXISTS (milestone 385, from note #3727) WHY THE KIND EXISTS
Rules were the only surface that is global AND situation-keyed, so an agent Without it, rules are the only surface that is global AND situation-keyed, so
holding a transferable insight had one door, and that door binds. The observed an agent holding a transferable insight has one door, and that door binds —
symptom was sessions offering rule proposals for things that should not be and sessions propose rules for things that should not be rules.
rules.
The gap is a document-shape fact, not a threshold: The gap is a document-shape fact, not a threshold:
@@ -23,31 +22,26 @@ No tuning reaches across that: the field the query would match on is simply not
in a note's document. So a lesson carries a trigger and is embedded like a rule, in a note's document. So a lesson carries a trigger and is embedded like a rule,
while staying a note in every other respect. while staying a note in every other respect.
WHERE THE TRIGGER LIVES (decision #4157, milestone 385 step 1) WHERE THE TRIGGER LIVES
In ``notes.data`` under ``when_to_apply``, written through a named parameter and In ``notes.data`` under ``when_to_apply``, written through a named parameter and
mirrored into the head of the body — the shape snippets already use for mirrored into the head of the body — the shape snippets use for
``when_to_use``. Not a column on ``notes``. Since milestone 427 it is NOT in the ``when_to_use``. Not a column on ``notes``. It is NOT in the stored title: the
stored title: the title is the lesson's subject, and the trigger joins it only title is the lesson's subject, and the trigger joins it only in the embedded
in the embedded document (``embeddings.document_title``). document (``embeddings.document_title``).
That decision was measured rather than assumed. The whole snippet corpus — A named structured field rather than a column: snippets show such a field gets
164 of 164 — carries a ``when_to_use`` with **no guard anywhere**, which refutes filled reliably without a guard, and a column would decide enforceability for
the premise that an unenforced field gets skipped. What it does NOT show is that every note kind at once.
an agent types a title convention correctly: the service composed the title
from the parameter, so what is at 100% is a named structured field. A column
would have bought enforceability at the price of deciding, for every note kind
at once, a question nothing had measured.
The trigger in the document is what makes the vector sharp. It reaches it The trigger in the document is what makes the vector sharp. It reaches it
twice — in the embedded title, joined at embed time, and in the body's first twice — in the embedded title, joined at embed time, and in the body's first
line — which is the text these kinds were always embedded as, so nothing line.
re-embeds and ``CHUNKER_VERSION`` does not move.
WHAT A LESSON INHERITS, AND THE CELLS LEFT EMPTY ON PURPOSE (#3163) WHAT A LESSON INHERITS, AND THE CELLS LEFT EMPTY ON PURPOSE
A new kind inherits the note machinery wholesale, and #3163 asks which parts it A new kind inherits the note machinery wholesale. Which parts it should NOT get
should NOT get — so that an empty cell is a decision rather than an oversight. is stated here, so that an empty cell is a decision rather than an oversight.
Inherited, all deliberately: Inherited, all deliberately:
@@ -67,25 +61,23 @@ NOT inherited, and each for a stated reason:
in by accident silently changes what the record IS. in by accident silently changes what the record IS.
- **recurrence** — task-only, and a lesson does not recur. - **recurrence** — task-only, and a lesson does not recur.
- **verify_with / expires_when** — available, because they are generic note - **verify_with / expires_when** — available, because they are generic note
fields, but not part of a lesson's contract and not asked for on create. The fields, but not part of a lesson's contract and not asked for on create.
milestone-312 distinction is why: those mark a record that asserts a FACT Those mark a record that asserts a FACT about someone else's software and
about someone else's software and can go false unwatched. A lesson is closer can go false unwatched. A lesson is closer
to a norm — "a better way to think about this" has no truth value that rots to a norm — "a better way to think about this" has no truth value that rots
on its own. A lesson that does assert such a fact can still carry them. on its own. A lesson that does assert such a fact can still carry them.
WHY THERE IS NO MIGRATION WHY A KIND NEEDS NO MIGRATION
``note_type`` carries **no CHECK constraint** — only ``task_kind`` does ``note_type`` carries **no CHECK constraint** — only ``task_kind`` does
(``notes_task_kind_check``, migrations 0056 / 0065). Migration 0036 added (``notes_task_kind_check``). It is plain ``Text`` with a server default, so a
``note_type`` as plain ``Text`` with a server default and nothing has gated it value the database refuses on an un-migrated instance cannot arise for this
since. So rule 36 has nothing to expand here, and the failure it guards against column.
— a value the database refuses on an instance predating its migration — cannot
arise for this column.
The real vocabulary is ``services.knowledge._FACETS``, which is where a kind The real vocabulary is ``services.knowledge._FACETS``, which is where a kind
becomes reachable on the browse surface and validated at the door. That is one becomes reachable on the browse surface and validated at the door. That is one
table feeding both dialects of the type filter, so adding a kind there is a table feeding both dialects of the type filter, so adding a kind there is a
single edit — a property #3161 recommended and that landed before this. single edit.
""" """
from __future__ import annotations from __future__ import annotations
@@ -142,18 +134,18 @@ def normalize_sources(entries: list | None) -> list[int]:
THE CARDINALITY DECISION, and why it is a list. THE CARDINALITY DECISION, and why it is a list.
`arose_from_id` already exists and holds ONE id, which is the obvious first `arose_from_id` already exists and holds ONE id, which is the obvious first
answer and the wrong one. The lesson that started this milestone generalised answer and the wrong one. A good lesson often generalises several
THREE incidents — a badge collision, an un-backfilled column, a duplicated incidents — a badge collision, an un-backfilled column, a duplicated const
const — into one claim about failure classes no CI lane can see. Generalising — into one claim about a failure class. That is the shape a good lesson
across incidents is the shape a good lesson HAS, not an edge case. A single HAS, not an edge case. A single
id would keep the first and silently drop the rest, and a record that drops id would keep the first and silently drop the rest, and a record that drops
two of its three sources is worse than one that names none, because it reads two of its three sources is worse than one that names none, because it reads
as complete. as complete.
It lives in `notes.data` rather than a join table for exactly the reason It lives in `notes.data` rather than a join table for the reason the
decision #4157 put the trigger there: a join table would settle, for every trigger lives there: a join table would settle, for every note kind at
note kind at once, whether provenance is multi-valued — a question nothing once, whether provenance is multi-valued. `data` is JSONB with a GIN
has measured. `data` is JSONB with a GIN index (0070), so the list is index, so the list is
queryable today and a table can be migrated to later if the need is shown. queryable today and a table can be migrated to later if the need is shown.
Order is history, not sorting: the incidents stay in the sequence the writer Order is history, not sorting: the incidents stay in the sequence the writer
@@ -217,10 +209,10 @@ def compose_body(
The trigger's home in the text of the document, and half of what makes a The trigger's home in the text of the document, and half of what makes a
lesson findable: `chunk_document` joins `{embedded title}\\n{body}`, and lesson findable: `chunk_document` joins `{embedded title}\\n{body}`, and
the embedded title is `what — when it applies` (`embeddings.document_title`, the embedded title is `what — when it applies` (`embeddings.document_title`),
milestone 427), so the document states WHEN IT APPLIES in the title and so the document states WHEN IT APPLIES in the title and again in the first
again in the first line of the body. That is the twice-in-a-short-document line of the body. That twice-in-a-short-document shape is what measures
shape note #2485 measured as the only sharp one in the corpus. sharp in the corpus.
`**When to apply:**` rather than plain text: the body is the READABLE `**When to apply:**` rather than plain text: the body is the READABLE
form, `data` is the queryable mirror, and `_BODY_TRIGGER_RE` reads this form, `data` is the queryable mirror, and `_BODY_TRIGGER_RE` reads this
@@ -229,11 +221,11 @@ def compose_body(
anywhere else. anywhere else.
The insight goes in the body rather than being held out of the document. The insight goes in the body rather than being held out of the document.
`rule_document` excludes a rule's `why` because long dated narrative made `rule_document` excludes a rule's `why` because long narrative in ONE
sixteen dev-logs land on the centroid of "development" — but that finding vector lands on the centroid of "development" — but a body over the
predates chunking (#280). A body over the budget is now split into several budget is split into several chunks, EACH prefixed with the title, so a
chunks, EACH prefixed with the title, so a lesson's story no longer lesson's story does not average itself into its trigger: it occupies its
averages itself into its trigger: it occupies its own vectors, and every own vectors, and every
one of them still carries the trigger in its prefix. Holding it out would one of them still carries the trigger in its prefix. Holding it out would
cost the reader the only part that explains the insight and would buy a cost the reader the only part that explains the insight and would buy a
sharpness the chunker already provides. sharpness the chunker already provides.
@@ -255,11 +247,10 @@ def compose_body(
return "\n\n".join(lines) return "\n\n".join(lines)
# A lesson's NAME is its claim, and the claim is one line (#4797). `what` is # A lesson's NAME is its claim, and the claim is one line. `what` is the title
# the title every listing, menu and search row prints, so a story written there # every listing, menu and search row prints, so a story written there instead
# instead of in `insight` turns each of those lines into a document — the menu # of in `insight` turns each of those lines into a document, with the claim it
# line for one such lesson ran to 1,416 characters, and the claim it existed to # exists to transfer somewhere in the middle. It also ranks badly: the embedded title
# transfer sat somewhere in the middle. It also ranks badly: the embedded title
# is `what — when_to_apply`, so a narrative crowds out the trigger. # is `what — when_to_apply`, so a narrative crowds out the trigger.
# #
# 240 is a long sentence. The bound is on what a NAME can be, not on what a # 240 is a long sentence. The bound is on what a NAME can be, not on what a
@@ -320,12 +311,10 @@ def lesson_document(
it: the record would look right in every listing and simply never be it: the record would look right in every listing and simply never be
retrieved at the moment it applies. retrieved at the moment it applies.
The title is the SUBJECT alone (milestone 427). It used to carry the The title is the SUBJECT alone. A stored title carrying the trigger would
trigger too, so the stored record was itself the sharp document — and every make every listing, menu and search row show a title that runs to
listing, menu and search row then showed a title that ran to kilobytes. kilobytes. The trigger joins the title at embed time
The trigger now joins the title at embed time (`embeddings.document_title`, (`embeddings.document_title`, reading `data`).
reading `data`), producing the same text as before, so nothing re-embeds
and `CHUNKER_VERSION` does not move.
""" """
return ( return (
(what or "").strip(), (what or "").strip(),
@@ -368,7 +357,7 @@ def recompose_data(note) -> dict:
snippet mirror reports the wrong path. A stale lesson mirror reports the snippet mirror reports the wrong path. A stale lesson mirror reports the
wrong TRIGGER — and `lesson_trigger` prefers the mirror, so the lesson goes wrong TRIGGER — and `lesson_trigger` prefers the mirror, so the lesson goes
on being retrieved for the situation it used to name while displaying the on being retrieved for the situation it used to name while displaying the
one it now names. The trigger is the entire retrieval story (step 3), so one it now names. The trigger is the entire retrieval story, so
that is not a degraded record; it is a record that fires at the wrong that is not a degraded record; it is a record that fires at the wrong
moment and looks right when it does. moment and looks right when it does.
@@ -410,14 +399,12 @@ async def create_lesson(
"""Create a lesson note. Returns the created Note. """Create a lesson note. Returns the created Note.
The title, the body and the `data` mirror are composed HERE from named The title, the body and the `data` mirror are composed HERE from named
parameters rather than asked of the caller. That is the whole evidence base parameters rather than asked of the caller. That is the design, not a
for this design and not a convenience: the snippet corpus carries a trigger convenience: a named structured field that a service composes is filled
on 164 of 164 records with no guard anywhere, because a service builds the reliably, where a convention an agent must type correctly is not.
title from a parameter — what is at 100% is a named structured field, not an
agent typing a convention correctly.
`project_id` is accepted and kept, even though a lesson is reachable from `project_id` is accepted and kept, even though a lesson is reachable from
every project (step 3). Where it was learned is a fact worth keeping; it every project. Where it was learned is a fact worth keeping; it
simply stops being the limit of where it can be found. simply stops being the limit of where it can be found.
""" """
from scribe.services import notes as notes_svc from scribe.services import notes as notes_svc
@@ -440,9 +427,10 @@ async def create_lesson(
async def get_lesson(user_id: int, lesson_id: int): async def get_lesson(user_id: int, lesson_id: int):
"""Fetch a lesson by id, or None if it isn't one / isn't readable. """Fetch a lesson by id, or None if it isn't one / isn't readable.
Share-aware (rule 78): a fetch by id is an explicit act, so it resolves the Share-aware (services/access.py): a fetch by id is an explicit act, so it
caller's full read scope rather than ownership alone — without this, a resolves the caller's full read scope rather than ownership alone —
lesson a search legitimately surfaced could not then be opened (#2093). without this, a lesson a search legitimately surfaced could not then be
opened.
""" """
from scribe.services import notes as notes_svc from scribe.services import notes as notes_svc
@@ -494,7 +482,7 @@ async def source_records(user_id: int, ids: list[int]) -> list[dict]:
One query for the whole list, not one per id — a lesson with six sources One query for the whole list, not one per id — a lesson with six sources
would otherwise be six round trips to draw one panel. would otherwise be six round trips to draw one panel.
Share-aware (rule 78). Order follows `ids`, because that order is the Share-aware (services/access.py). Order follows `ids`, because that order is the
writer's: the first source is the one they reached for first. writer's: the first source is the one they reached for first.
""" """
from sqlalchemy import select from sqlalchemy import select
@@ -535,11 +523,11 @@ async def lessons_taught_by(user_id: int, record_id: int, limit: int = 20):
record that taught something should say so on its own page. record that taught something should say so on its own page.
Queried through `data[SOURCES_KEY]` rather than by scanning bodies: the Queried through `data[SOURCES_KEY]` rather than by scanning bodies: the
mirror is JSONB with a GIN index (0070), which is the whole reason step 4 mirror is JSONB with a GIN index, which is the whole reason the list
put the list there. `path_exists` is the same dialect the snippet location lives there. `path_exists` is the same dialect the snippet location
lookup uses, so both reverse lookups read the index the same way. lookup uses, so both reverse lookups read the index the same way.
Share-aware (rule 78) via `readable_notes_clause`: this renders beside a Share-aware via `readable_notes_clause`: this renders beside a
record the caller can already see, and a lesson someone shared with them record the caller can already see, and a lesson someone shared with them
belongs in that list exactly as their own does. belongs in that list exactly as their own does.
""" """
@@ -630,13 +618,12 @@ def lesson_insight(note) -> str:
added; anything it leaves behind is re-prepended and the body grows by that added; anything it leaves behind is re-prepended and the body grows by that
much on every edit. much on every edit.
TAKES THE NOTE, NOT THE BODY, and that is the whole fix (#4272). The TAKES THE NOTE, NOT THE BODY. Filtering out lines matching
previous shape filtered out lines matching `_BODY_TRIGGER_RE`, which is `_BODY_TRIGGER_RE` is not enough: that pattern is `re.M` and matches ONE
`re.M` and therefore matches ONE LINE. `when_to_apply` is free text and is LINE, while `when_to_apply` is free text and routinely several paragraphs
routinely several paragraphs — `compose_body` writes all of it after the — `compose_body` writes all of it after the marker. Paragraphs 2..n would
marker, so paragraphs 2..n were ordinary lines the filter never touched. come back as part of "the insight", the full trigger would be prepended
They came back as part of "the insight", the full trigger was prepended again, and the record would gain (n-1) paragraphs per update. Silent, and
again, and the record gained (n-1) paragraphs per update. Silent, and
compounding. compounding.
What makes an exact strip possible is that the trigger is stored: the What makes an exact strip possible is that the trigger is stored: the
@@ -645,9 +632,9 @@ def lesson_insight(note) -> str:
subtraction rather than pattern-matching. subtraction rather than pattern-matching.
The fallback is not dead code. A row whose mirror is missing degrades to The fallback is not dead code. A row whose mirror is missing degrades to
the old line-wise removal, which is right for a one-paragraph trigger and line-wise removal, which is right for a one-paragraph trigger and is the
is the best that can be done without knowing where the trigger ends — best that can be done without knowing where the trigger ends — there is
there is no marker for its last line, which is why guessing was wrong. no marker for its last line.
The provenance line needs no such care: `compose_body` builds it by The provenance line needs no such care: `compose_body` builds it by
joining ids with ", ", so it cannot contain a newline. joining ids with ", ", so it cannot contain a newline.