fix(storage): repair graphs written by 4.8.1, and stop the build opening the real one - #246
Merged
Conversation
…ing the real one Three one-shot passes at the first open after upgrade (src/storage/ graph-repairs.ts, runOnceMigration like every other backfill): duplicate observations on session-* entities are removed; a summary that claimed "0 files edited" beside a Bash command that writes files is rewritten to say the count was not recorded; every explicit lesson after the first in a lesson-<project>-other bucket moves — rows, ids and timestamps intact — to lesson-<project>-<slug>, the name learn() gives it now. severity: is carried only when the bucket had exactly one. FTS is rebuilt whole, not patched per row: entities_fts is contentless and a delete for a row the hook never indexed corrupts the index. Vectors for moved text are dropped and a reindex is marked owed whether or not sqlite-vec loaded. lessonSlug moves to src/core/lesson-slug.ts so storage/ and lesson-engine share one definition without an import cycle through db.ts. scripts/smoke-test.mjs: the HTTP-server child inherited no database path, so `npm run build` opened the developer's real ~/.memesh and ran every migration in the working tree against it. The child now gets its own throwaway path. [Verified-By: node scripts/run-tests-isolated.mjs -> exit=0, 223 files / 3010 tests; npm run verify:release -> exit=0 (5 ok); eg check -> PASS 4/4 CODE_COMPLETE, 12 receipts replayed, 4 failure paths red then restored; real-graph copy: invariants exit 1 -> one open -> exit 0, second open changes no row count; three builds with real WAL mtime unchanged]
F1 "0 files edited" matched by suffix — "10 files edited" ends the same way and is true; anchored on ", 0 files edited" in the repair and the invariant. F7 the Bash-write test was a substring match on "<<" and "tee " — a JS shift (`1<<32`) and `| tee /tmp/x` counted as edits; it is now the Stop hook's own bashEditedPaths shapes, mirrored in the invariant. On the maintainer's graph the one remaining hit was exactly that: a `node -e` snippet containing `1<<32`, and its "0 files edited" was true. F2 the entities_vec DELETEs could never run (sqlite-vec loads after the backfills); removed, and the docs now say vectors stay and a reindex is owed. F3/F4 every lesson leaves the bucket, the bucket loses source:explicit and is archived when empty, so a later auto-learned lesson cannot re-trip the invariant and no lesson is left beside its own history. F5 a target archived by `forget` is revived, not written into and hidden. F6 each pass prints one stderr line when it changed something. F8 the project falls back to the name when the tag is missing. smoke-test cleanup in `finally`; .smoke-test*.db-* ignored. [Verified-By: node scripts/run-tests-isolated.mjs -> exit=0, 223 files / 3011 tests; npm run verify:release -> exit=0 (5 ok); eg check -> PASS 4/4, 12 receipts replayed, failure paths red then restored; real-graph copy: 24 bucket observations -> 0, markers 3, invariants exit 0, second open changes no row count; build with real WAL mtime unchanged]
N1 the invariant's Bash-write test moved into JS but the SQL kept LIMIT 8, so the cap bounded candidates, not violations — eight honest sessions sorting first hid a real one. The cap now applies after the filter. N2 bashWritesFiles took the first match only; `| tee /tmp/t.log && | tee CHANGELOG.md` was not a write. Every match is examined, as in the hook. N3 the bucket lost source:explicit even when a stray row kept it non-empty, hiding it from the invariant; both the tag drop and the archive now require an emptied bucket. N4/N5 wording: hooks that import dist/db.js do run migrations; archived ends are hidden by the work-layer view. N6 doctor no longer tells a vec-less machine to run a reindex it cannot; regression test mocks hasVectorIndex. N7 split-out lessons carry the bucket's metadata and get a signal_score at insert. N8 a post-filter error is a detector bug (exit 2), not a schema skip. The metadata parse guard is break-tested (catch -> rethrow turns the unreadable-metadata test red). [Verified-By: node scripts/run-tests-isolated.mjs -> exit=0, 224 files / 3016 tests; npm run verify:release -> exit=0 (5 ok); eg check -> PASS 4/4, 12 receipts recorded on this tree; real graph under the fixed detector: 3 invariants ok, exit 0, every candidate examined; build with real WAL mtime unchanged]
Copying the bucket's metadata onto each split-out lesson also copied an accepted `guard` — one human acceptance would have become N identical warnings, N× fires and N accepted guards in doctor, on a migration that cannot re-run. `guard`, `evidence_for` and `previous_namespace` are per-entity facts and are dropped from the copy. A group whose error slugs to "other" would target a bucket (its own, or another after a project rename); it is left in place instead of counted as a move. The invariant's post-filter prepares its statement once; its contract comment names the new exit-2 case; the 8-row cap is now pinned by a test. The C1 baseline entry for memory-invariants.test.ts is pruned: the cap test carries a real size pin, so the heuristic no longer fires. [Verified-By: node scripts/run-tests-isolated.mjs -> exit=0, 224 files / 3018 tests; npm run verify:release -> exit=0 (5 ok, no stale baseline); eg check -> PASS 4/4, 12 receipts replayed; build with real WAL mtime unchanged]
…invariant say what it means
`name.endsWith('-other')` skipped a real lesson whose error merely ends
in the word ("could not reach the other") — permanently, with the
invariant green. The guard is now `lessonSlug(error) === 'other'`, the
only slug that can name a bucket, and the test for it is the
project-rename fixture (a bucket carrying `project:new` must not mint
`lesson-new-other`), which is red without the guard; the earlier test
could not fail because a self-move is a no-op.
The invariant had the same confusion: `LIKE '%-other'` flagged a
five-observation lesson named `…-the-other`. When a project tag exists
the name must now be exactly lesson-<project>-other. Every query fetches
one row past the cap, so "(first 8)" is printed only when a ninth
violation exists.
[Verified-By: node scripts/run-tests-isolated.mjs -> exit=0, 224 files /
3021 tests; npm run verify:release -> exit=0 (5 ok); eg check -> PASS
4/4, 12 receipts recorded on this tree; build with real WAL mtime
unchanged]
…shape guess The #241 invariant asked "more than four observations" and then guessed from the name whether an entity was a bucket. The guess went blind on a bucket renamed by `kg rename-project` (name lesson-old-other, tag project:new — tags are rewritten, names never are) and still flagged a tag-less re-learned lesson named `…-the-other`. It now counts distinct `Error:` lines, the same line the repair's groupLessons cuts on, so the detector and the fixer agree by construction. The SQL cap and the stderr bucket count, both previously unpinned, get tests. [Verified-By: node scripts/run-tests-isolated.mjs -> exit=0, 224 files / 3024 tests; npm run verify:release -> exit=0 (5 ok); eg check -> PASS 4/4, 12 receipts recorded on this tree]
…ir do Counting distinct Error lines flagged a legitimate 4.8.2 entity: two different error texts that share one eight-word slug are ONE lesson under learn()'s contract, and when that slug ends in "other" the name matched the bucket shape. The invariant now asks the repair's own question in a post-filter — cut on `Error: ` as groupLessons does, slug as learn() does (function mirrored, like the Bash shapes) — and reports an entity only when it holds more than one slug, or a slug its name does not end with. The two "not a bucket" tests were green only because their fixtures used identical text; a shared-slug-different-text case is added. The repair skips a group whose target is the bucket itself; the stderr spy in its test restores under finally. [Verified-By: node scripts/run-tests-isolated.mjs -> exit=0, 224 files; npm run verify:release -> exit=0 (5 ok); eg check -> PASS 4/4, 12 receipts recorded on this tree; build with real WAL mtime unchanged]
… what the invariant checks The self-move guard in the lesson split was deletable with a green suite; a bucket-shaped name whose own lesson slugs to itself (lesson-proj-the-other with project:proj) now proves it: no "moved" note, rows untouched, no reindex owed. The invariant's `says` claimed "lessons that belong under other names" while its SQL keeps the deliberate single-lesson carve-out; it says "more than one explicit lesson" again and the comment names the carve-out and the test that pins it. The mirrored lessonSlug in the audit script is compared body-for-body with src/core/lesson-slug.ts — the C6 audit hit for that read is triaged as the wiring pin it is. [Verified-By: node scripts/run-tests-isolated.mjs -> exit=0, 224 files; npm run verify:release -> exit=0 (5 ok); eg check -> PASS 4/4, 12 receipts replayed on this tree]
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
v4.8.2's fixes for #240 and #241 stop new damage; they did nothing for the rows already written by 4.8.1 hooks, and every upgraded graph has them. This adds three one-shot repairs that run at the first core open after upgrade (CLI / MCP / HTTP — hooks open through their own wrapper and run no migrations), alongside the existing backfills in
db.ts:scripts/audit/memory-invariants.mjs)dedupeSessionObservationsstop-summary-no-duplicate-observationsretractZeroEditClaimsstop-summary-does-not-assert-zero-edits-for-bash-sessions0 files editedrewritten to say the count was not recordedsplitFusedLessonsexplicit-lessons-not-fused-into-other-bucket-otherbuckets intolesson-<project>-<slug>entitiesDesign choices, each written down in the module header:
created_atsurvive, nothing is re-authored. Every lesson leaves the bucket (keeping the first would leave one lesson whose re-learned copy lands beside its history — review F4); the emptied bucket losessource:explicitand is archived, not deleted, so relations still resolve and a later auto-learned lesson cannot re-trip the invariant (F3).severity:*is carried only when the bucket has exactly one;source:explicitonly when nosource:auto-learnedsits beside it. With several, which lesson wascriticalis recorded nowhere; guessing would write a fact nobody stated.rebuildFtsIndex, same call the segmentation migration makes), not patched per row.entities_ftsis contentless: a delete must repeat the exact indexed text, and a row the hook wrote without indexing has none — deleting it corrupts the index (database disk image is malformed, reproduced in the first version of the tests).entities_vecand no longer claims to (the first version had deadDELETEs; review F2). A reindex is marked owed;memesh doctorreports it,memesh reindexclears it.lessonSlugmoves tosrc/core/lesson-slug.ts(a leaf) sostorage/andlesson-engineshare one definition without an import cycle throughdb.ts.The incident this PR also fixes
npm run buildends withscripts/smoke-test.mjs, whose HTTP-server child inherited no database path. It opened the developer's real~/.memeshand ran every migration in the working tree against it — which is how the repair above ran on the maintainer's real graph from a build, with no pre-run snapshot. The child now gets its own throwaway path (.smoke-test-http.db, cleaned up). Proven: three builds since, real WAL mtime unchanged each time. The repair itself is content-preserving (exact duplicates removed, rows moved), and a post-state backup exists.Evidence (this tree, this session)
node scripts/run-tests-isolated.mjs→ exit=0 on the final tree (see the last commit's Verified-By for the count)npm run verify:release→ exit=0 (5 ✓; C1 hit on the new test file triaged inbaseline.json:.toBe(0)are exit codes, each preceded by atoBe(1)on the seeded defect)eg check→ PASS 4/4 CODE_COMPLETE, 12 receipts; failure paths: dedupe query pointed at a non-existent prefix → tests red; lesson move loop emptied → tests red; group loop emptied indist→ real-graph copy keeps its 24 bucket observations; CHANGELOG entry renamed → docs check red. All restored (no unstaged diff).…bak-2026-08-30-0322, markers cleared): one open → 24 bucket observations → 0, 3 markers set, invariants exit 0; second open → identical row count (9236).node -e '… 1<<32 …'— a JS shift, no file write — so its0 files editedwas true. The first version's substring match would have falsified it; the anchored regex leaves it alone.Simplification
Read-only pass (sonnet,
code-simplifier) on the full diff. Applied:lastInsertRowidinstead of a re-SELECT after INSERT;removeTempDirfromtests/helpersinstead of a hand-rolledrmSync; atitleassertion the test selected but never checked (the whole title-plumbing path was unprotected). Also removed an unusedleadarray fromgroupLessonsand the?? 1.0onconfidence(nullable column, copied as-is now). Declined, with reasons in the review: collapsing the dedupe loop into one full-tableGROUP BY(worse on large graphs); extractingrunInvariants()to a shared helper (second occurrence, not third). Correctly flagged as not-safe and left alone: the rawembedding_dimensionread indb.ts(the singleton helper is null during open).Adversarial review (opus, fresh context, read-only) — verdict FAIL on the first version, all eight findings fixed
LIKE '%0 files edited%'also matched10 files editedand would have rewritten a true sentence into1files edited through Bash…, irreversibly, 0 files editedin both the repair and the invariantsession-ten-summarystays10 files editedhasVectorIndexis always false during open (vec loads later) → the vectorDELETEs were dead code and the docs claimed otherwise-otherbucket and it keptsource:explicitsource:explicit; auto-learned path already tagssource:auto-learned, which the invariant ignoresobservations(bucket) == [], statusarchivedforget-archived entity would swallow the moved lesson out of searchSELECT id, status; archived target revived (same asremember)nameArevived, found by search<</teesubstring marks →python3 - <<'PY',| tee /tmp/t.logcounted as writesbashEditedPathsregexes (/dev/,/tmp/excluded), shared with the invariantbashWritesFilespositive/negative table;session-stdin-summaryuntouchedproject:tag skipped by the repair but flagged by the invariant foreverlesson-my-app-othersplitAlso from the review: smoke-test cleanup moved into
finally;.smoke-test*.db-*gitignored. Checked and confirmed sound by the reviewer: atomicity (everything insiderunOnceMigration's IMMEDIATE transaction), dedupe deletes only exact duplicates, tags vs the unique index, FTS status filter, relations left on the archived bucket, the rawembedding_dimensionread during open, the smoke-test env reachingstartServer.Real-graph check after the review fixes: the retraction pass had not run on the real graph (marker absent) and no rewritten row exists there or on the copy — F1 was a defect of the branch, not of the data.
Second adversarial round (opus, fresh context) on the F1–F8 fixes — verdict FAIL, all eight closed
LIMIT 8stayed in the SQL, bounding candidates instead of violations — 8 honest sessions sorting first hid a real one (reproduced)bashWritesFilestook the first match only;| tee /tmp/t.log && … | tee CHANGELOG.mdwas "no write"gregex per call (hook parity)source:explicitdropped even when a stray row kept the bucket non-empty → invisible to the invariantNote:row: tag kept, status activedist/db.jsand dopending_reindex: vectors-missingon a vec-less machine → doctor says "run reindex" forever, reindex refuseshasVectorIndexis false; dimension-change still showntests/core/doctor-unpayable-reindex.test.ts(mockshasVectorIndex)signal_scoreand dropped the bucket's metadata (trust/provenance)signal_scorecomputed at inserttrust: untrustedtravels; score is a numbertry, exit 2Also from that round: the metadata parse guard in the split is break-tested (catch → rethrow makes the unreadable-metadata test red). Re-check of the real graph with the fixed detector (no limit, every candidate examined): all three invariants
ok, exit 0.Third adversarial round (opus, scoped to the round-2 fixes) — verdict FAIL on one finding, all closed
guardobject onto every split-out lesson — N identical warnings, N× fires, one acceptance asserted N times, and not re-runnableguard,evidence_for,previous_namespaceare deleted from the copy (per-entity facts)truststill travelsothertargets a bucket (its own, or another after a project rename) — rows "moved" into a bucket,movedinflatedError: othergroup stays; the other lesson still splitsmemory-invariants.mjsmissed the new exit-2 casedb.prepareinside the post-filter loop, now unbounded(first 8)Cleared by the reviewer with evidence: the
payableDebtbranch matrix (no unintended change;hasVectorIndex()===false⇔ sqlite-vec did not load, by construction ofensureVecTable); regex sources byte-identical to the hook,gthe only flag, no backtracking risk; slug collisions merge exactly as livelearn()does (same contract, not a new merge); both changed doc claims verified.Fourth adversarial round (opus, scoped to the round-3 fixes) — verdict FAIL on one finding, closed
name.endsWith('-other')also skipped a real lesson whose error merely ends in the word ("could not reach the other") — permanently, with the invariant greenlessonSlug(error) === 'other', the only slug that can name a bucketlesson-old-othercarryingproject:newmust not mintlesson-new-other) — red without the guardLIKE '%-other')lesson-<project>-other…-the-otheris not a violationReviewer-confirmed: the three metadata deletes are the complete per-entity set (with
compacted_intonoted as the next one iflesson_learnedever becomes compactable); bucket logic when every group is skipped is correct; the round-3 metadata-delete test is a real break-test.Fifth adversarial round (opus, scoped to the round-4 fixes) — verdict FAIL on the invariant clause, closed
project:tag clause made the invariant blind to a real fused bucket afterkg rename-project(tags are rewritten, names are not) — a regression against the previous predicateError:lines (HAVING lessons > 1) — the same linegroupLessonscuts on, so detector and fixer agree by construction; the name-shape clause is gonelesson-old-other+project:new, 2 lessons → flagged(2 lessons)…-the-otherLIMIT MAX_ROWS+1was unpinned (silent under-report if reverted)(first 8)bucketsTouchedguard was unpinnedout of 1 "-other" bucket(s)with a second, skipped-only bucket presentStated trade-off (reviewer's own words adopted): a bucket holding two lessons whose errors both slug to exactly
otherstays permanently red — visible-and-unfixable beats invisible-and-unfixable.Sixth adversarial round (opus, scoped to the invariant rewrite) — verdict FAIL on one finding, closed
Error:lines flagged a legitimate 4.8.2 entity: two different error texts sharing one 8-word slug that ends in "other" are ONE lesson bylearn()'s contract; the two "not a bucket" tests were green only because their fixtures used byte-identical textError:exactly asgroupLessonsdoes, slug exactly aslearn()does (function mirrored, like the Bash shapes), fused ⇔ more than one slug or a slug the name does not end withlesson-proj-the-agent-could-not-talk-to-the-otherwith port 3000/4000 texts → not flaggedsaysstill said "(4 fields each)" from the old row-count proxyLIKEis case-insensitive,startsWithis notstartsWith, the SQL only narrowstry/finallytry/finallyname === bucket.nameguardDecision recorded here (reviewer asked that it be explicit): a
-otherbucket whose lessons all have the error text "other" is now consistent with its own name and not reported — the repair leaves such rows in place either way.Seventh adversarial round (opus, scoped to the post-filter and self-move guard) — PASS_WITH_CONCERNS, both concerns landed
lesson-proj-the-other+project:proj, whose own lesson slugs tothe-other)pending_reindex; also recorded as the eg failure pathsaysclaimed "lessons that belong under other names" while the SQL keeps the deliberate single-lesson carve-out (> 1)lessonSlughad no drift detector (differential run over 20,025 inputs: 0 divergences today)Reviewer cleared:
endsWithcannot let a fused bucket through (the slug count fires first, and names are minted with one slug); post-filter bounded (46 ms on 104k observations); audit baselinenew=0×7. Disclosed limitation, unchanged: two unrelated incidents whose error texts both slug tootherstay on one entity, unreported — thesaystext tells the reader how to escape it.No eighth round: this last commit changes a comment and two tests only, and the round-7 reviewer had already produced the red/green pair for the self-move fixture that was adopted verbatim.
Not in this PR
pending_reindexis not set when a graph hasentities_vecbut noembedding_dimensionrow.ensureVecTablealways writes the row when it creates the table, so this looks unreachable; noted rather than guarded.