update_lesson is field-wise: an edit that touches only system_ids, or only the title, passes insight=None. The service then derives the insight from the stored body and hands it straight back to compose_body, which re-adds the trigger and the provenance line. The derivation and the composition have to be inverses, and they were not.
_strip_composed_lines filtered out body lines matching the trigger marker's pattern, which is re.M and therefore matches one line. when_to_apply is free text and is routinely several paragraphs — compose_body writes all of it after the marker, so paragraphs 2..n were ordinary lines the filter had no reason to touch. They came back as part of "the insight", the whole trigger was prepended again on top of them, and the record gained (n-1) paragraphs per update. Silent, compounding, and in the half of the document that does the retrieving.
Measured on the one real row this damaged (lesson #4207, a three-paragraph trigger): 607 characters added per edit, taking the body from 1291 to 7361 over ten edits, at which point the trigger appears eleven times and the insight is 17% of its own record.
#4249 did not create this — it removed what was hiding it. Before system_ids existed, every call changed text, so a caller nearly always passed insight explicitly and skipped the recovery path.
The fix
lesson_insight(note) replaces _strip_composed_lines(body). Taking the note rather than the body is the whole change: the body alone does not say where the trigger ends, which is the information the old shape lacked and pattern-matched around. The trigger is mirrored in data[TRIGGER_KEY], so the composed head is rebuilt byte for byte and removed by subtraction.
No marker could have fixed this. There is no end-of-trigger marker in the body, and adding one would not repair a row already written without it.
The line-wise removal survives as the fallback for a row whose mirror is missing — right for a one-paragraph trigger, and the best available without one. A mirror that disagrees with the body falls back rather than cutting into the insight, which matters because recompose_data exists precisely for rows where a generic update_note(body=...) left the mirror stale.
The guard
tests/test_lesson_insight_round_trip.py asserts the property rather than the strip:
across six shapes, and again over ten consecutive no-op edits. The six include the boundary case an over-clever fix would get wrong — an insight that opens with the same words the trigger closes with, where exact prefix removal is right and "cut to the last matching paragraph" is not.
Falsified against the old code before committing (rule 167): 0 failures on the fix, 11 of 23 on 43ed7f4.
Repair, and scope
#4207 — damaged by a system_ids edit, repaired by hand against its authored text, body verified byte-identical to the original.
#4221 — created_at == updated_at, never updated, body intact. Its derived insight was wrong; its storage was not.
Those are the only three lessons in the corpus with a multi-paragraph trigger (list_lessons is unscoped by project, so its total of 17 is the whole corpus). src/ was swept for a second copy of the lossy strip: there is none — every lesson read goes through lesson_to_dict, the only caller of lesson_insight.
CI
Run 7210 on b47dc97: green across typecheck, plugin hooks, lint, Python tests and integration.
Two red runs preceded it, both in the new test file rather than the fix: fake_note is a MagicMock, lesson_sources falls back to arose_from_id, and int(MagicMock()) is 1, so every no-sources shape recomposed with a **Learned from:** #1 nobody asked for. fake_lesson pins that field to None. Recorded as lesson #4273, because the deeper problem was a local harness that used a plain stub class and was therefore kinder than the suite's fixture.
Three commits, one defect.
## The problem
`update_lesson` is field-wise: an edit that touches only `system_ids`, or only the title, passes `insight=None`. The service then derives the insight from the stored body and hands it straight back to `compose_body`, which re-adds the trigger and the provenance line. The derivation and the composition have to be inverses, and they were not.
`_strip_composed_lines` filtered out body **lines** matching the trigger marker's pattern, which is `re.M` and therefore matches one line. `when_to_apply` is free text and is routinely several paragraphs — `compose_body` writes all of it after the marker, so paragraphs 2..n were ordinary lines the filter had no reason to touch. They came back as part of "the insight", the whole trigger was prepended again on top of them, and the record gained (n-1) paragraphs per update. Silent, compounding, and in the half of the document that does the retrieving.
Measured on the one real row this damaged (lesson #4207, a three-paragraph trigger): **607 characters added per edit**, taking the body from 1291 to 7361 over ten edits, at which point the trigger appears eleven times and the insight is 17% of its own record.
#4249 did not create this — it removed what was hiding it. Before `system_ids` existed, every call changed text, so a caller nearly always passed `insight` explicitly and skipped the recovery path.
## The fix
`lesson_insight(note)` replaces `_strip_composed_lines(body)`. Taking the **note** rather than the body is the whole change: the body alone does not say where the trigger ends, which is the information the old shape lacked and pattern-matched around. The trigger is mirrored in `data[TRIGGER_KEY]`, so the composed head is rebuilt byte for byte and removed by subtraction.
No marker could have fixed this. There is no end-of-trigger marker in the body, and adding one would not repair a row already written without it.
The line-wise removal survives as the fallback for a row whose mirror is missing — right for a one-paragraph trigger, and the best available without one. A mirror that *disagrees* with the body falls back rather than cutting into the insight, which matters because `recompose_data` exists precisely for rows where a generic `update_note(body=...)` left the mirror stale.
## The guard
`tests/test_lesson_insight_round_trip.py` asserts the property rather than the strip:
lesson_document(what, trigger(n), insight(n), sources(n)) == (n.title, n.body)
across six shapes, and again over ten consecutive no-op edits. The six include the boundary case an over-clever fix would get wrong — an insight that *opens* with the same words the trigger *closes* with, where exact prefix removal is right and "cut to the last matching paragraph" is not.
Falsified against the old code before committing (rule 167): **0 failures on the fix, 11 of 23 on `43ed7f4`**.
## Repair, and scope
- **#4207** — damaged by a `system_ids` edit, repaired by hand against its authored text, body verified byte-identical to the original.
- **#4221** — `created_at == updated_at`, never updated, body intact. Its derived `insight` was wrong; its storage was not.
- **#4273** — created after the fix, intact.
Those are the only three lessons in the corpus with a multi-paragraph trigger (`list_lessons` is unscoped by project, so its total of 17 is the whole corpus). `src/` was swept for a second copy of the lossy strip: there is none — every lesson read goes through `lesson_to_dict`, the only caller of `lesson_insight`.
## CI
Run 7210 on `b47dc97`: green across typecheck, plugin hooks, lint, Python tests and integration.
Two red runs preceded it, both in the new test file rather than the fix: `fake_note` is a MagicMock, `lesson_sources` falls back to `arose_from_id`, and `int(MagicMock())` is `1`, so every no-sources shape recomposed with a `**Learned from:** #1` nobody asked for. `fake_lesson` pins that field to None. Recorded as lesson #4273, because the deeper problem was a local harness that used a plain stub class and was therefore *kinder* than the suite's fixture.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
`update_lesson` is field-wise: an edit that touches only `system_ids`, or
only the title, passes `insight=None`. The service then derives the insight
from the stored body and hands it straight back to `compose_body`, which
re-adds the trigger and the provenance line. The derivation and the
composition have to be inverses, and they were not.
`_strip_composed_lines` filtered out body LINES matching the trigger
marker's pattern. That pattern is `re.M`, so it matches one line.
`when_to_apply` is free text and is routinely several paragraphs —
`compose_body` writes all of it after the marker, so paragraphs 2..n were
ordinary lines the filter had no reason to touch. They came back as part of
"the insight", the whole trigger was prepended again on top of them, and the
record gained (n-1) paragraphs per update. Silent, compounding, and in the
half of the document that does the retrieving: a lesson edited a few times
ends up stating when it applies several times over and burying the insight
below it.
No marker could fix this. There is no end-of-trigger marker in the body, and
adding one would not repair a row already written without it. What makes an
exact strip possible is that the trigger is mirrored in `data[TRIGGER_KEY]`,
so the composed head can be rebuilt byte for byte and removed by subtraction
rather than matched by pattern. Hence `lesson_insight(note)` rather than
`_strip_composed_lines(body)` — it takes the record because the body alone
does not say where the trigger ends. The line-wise removal survives as the
fallback for a row whose mirror is missing, where it is right for a
one-paragraph trigger and is the best available without one; a mirror that
disagrees with the body falls back rather than cutting into the insight.
tests/test_lesson_insight_round_trip.py asserts the property rather than the
strip: `lesson_document(what, trigger(n), insight(n), sources(n)) ==
(n.title, n.body)` across six shapes, and again over ten consecutive no-op
edits. Falsified against the old code before committing: 11 of 23 fail on
HEAD, the headline one reporting a body grown by 3230 characters over ten
edits.
The one damaged row in the corpus (#4207, two paragraphs duplicated by a
`system_ids` edit) was repaired by hand against its authored text. #4221 is
the only other lesson with a multi-paragraph trigger and has never been
updated, so its body is intact — its derived `insight` was wrong, its
storage was not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
The module docstring said the defect grew the worst real body by 520
characters over ten edits. That number was recalled, not measured, and it is
wrong — as was the 2500 I then got by deriving it on paper instead of
running it.
Replayed lesson #4207's actual stored shape through the old code: 607
characters per edit, exactly the length of the trigger's paragraphs 2 and 3
with their separators, taking the body from 1291 to 7361 over ten edits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
Run 7208 failed 5 of 23 in the new file, all with a `**Learned from:** #1`
that no shape asked for.
`lesson_sources` falls back to `arose_from_id` when neither the mirror nor
the body names a source. `fake_note` leaves that field to MagicMock, which
auto-creates it truthy and coerces to 1 — so every no-sources shape
recomposed with a provenance line and the round trip failed for a reason
with nothing to do with the trigger. `fake_lesson` pins `arose_from_id` to
None and its docstring says why; it is also the truthful stand-in, since
these are lessons.
Worth recording separately: the local harness that cleared this change used
a plain stub class, so it was kinder than the real fixture and could not
reproduce the truthiness. The stub has been given the same None. A
stand-in that is more forgiving than the one the suite uses will verify a
change that CI then rejects — note #2109's point, arriving from the other
direction.
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.
Three commits, one defect.
The problem
update_lessonis field-wise: an edit that touches onlysystem_ids, or only the title, passesinsight=None. The service then derives the insight from the stored body and hands it straight back tocompose_body, which re-adds the trigger and the provenance line. The derivation and the composition have to be inverses, and they were not._strip_composed_linesfiltered out body lines matching the trigger marker's pattern, which isre.Mand therefore matches one line.when_to_applyis free text and is routinely several paragraphs —compose_bodywrites all of it after the marker, so paragraphs 2..n were ordinary lines the filter had no reason to touch. They came back as part of "the insight", the whole trigger was prepended again on top of them, and the record gained (n-1) paragraphs per update. Silent, compounding, and in the half of the document that does the retrieving.Measured on the one real row this damaged (lesson #4207, a three-paragraph trigger): 607 characters added per edit, taking the body from 1291 to 7361 over ten edits, at which point the trigger appears eleven times and the insight is 17% of its own record.
#4249 did not create this — it removed what was hiding it. Before
system_idsexisted, every call changed text, so a caller nearly always passedinsightexplicitly and skipped the recovery path.The fix
lesson_insight(note)replaces_strip_composed_lines(body). Taking the note rather than the body is the whole change: the body alone does not say where the trigger ends, which is the information the old shape lacked and pattern-matched around. The trigger is mirrored indata[TRIGGER_KEY], so the composed head is rebuilt byte for byte and removed by subtraction.No marker could have fixed this. There is no end-of-trigger marker in the body, and adding one would not repair a row already written without it.
The line-wise removal survives as the fallback for a row whose mirror is missing — right for a one-paragraph trigger, and the best available without one. A mirror that disagrees with the body falls back rather than cutting into the insight, which matters because
recompose_dataexists precisely for rows where a genericupdate_note(body=...)left the mirror stale.The guard
tests/test_lesson_insight_round_trip.pyasserts the property rather than the strip:across six shapes, and again over ten consecutive no-op edits. The six include the boundary case an over-clever fix would get wrong — an insight that opens with the same words the trigger closes with, where exact prefix removal is right and "cut to the last matching paragraph" is not.
Falsified against the old code before committing (rule 167): 0 failures on the fix, 11 of 23 on
43ed7f4.Repair, and scope
system_idsedit, repaired by hand against its authored text, body verified byte-identical to the original.created_at == updated_at, never updated, body intact. Its derivedinsightwas wrong; its storage was not.Those are the only three lessons in the corpus with a multi-paragraph trigger (
list_lessonsis unscoped by project, so its total of 17 is the whole corpus).src/was swept for a second copy of the lossy strip: there is none — every lesson read goes throughlesson_to_dict, the only caller oflesson_insight.CI
Run 7210 on
b47dc97: green across typecheck, plugin hooks, lint, Python tests and integration.Two red runs preceded it, both in the new test file rather than the fix:
fake_noteis a MagicMock,lesson_sourcesfalls back toarose_from_id, andint(MagicMock())is1, so every no-sources shape recomposed with a**Learned from:** #1nobody asked for.fake_lessonpins that field to None. Recorded as lesson #4273, because the deeper problem was a local harness that used a plain stub class and was therefore kinder than the suite's fixture.🤖 Generated with Claude Code
https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy