fix: the full-codebase review findings — data loss, a CSRF boundary, secrets in the graph, and 19 root causes - #200
Merged
Conversation
…cence to destroy
Three independent reviewers found this on three surfaces in one pass, which is
what makes it one root cause rather than three bugs: "absent" and "empty" were
the same thing to the code, and "absent" selected the DESTRUCTIVE branch.
core operations.ts `if (args.observation)` — truthiness, so "" read as
"no selector" and archived the whole memory
MCP handlers.ts stripNullProps ran BEFORE safeParse, so a
null-valued UNKNOWN key was deleted rather than
rejected by .strict(). forget({name,
observations: null}) — plural, the word `remember`
uses — arrived as {name} alone
CLI --observation "" which an unset shell variable produces
All three end identically: a request scoped to ONE observation archives the
entire memory and returns {archived:true}, without mentioning the observation
the caller named. schemas.ts already documents .strict() as the fix for the
wrong-KEY door; this closes the empty-VALUE and null-VALUE doors beside it.
Three changes, one per layer, none of them a guard bolted onto the symptom:
- `ForgetSchema.observation` gains `.min(1)`. An empty selector now fails at
the boundary with "expected string to have >=1 characters" instead of
travelling inward as a falsy value.
- `operations.ts` branches on `!== undefined`. Below the schema there are now
exactly two states — a selector was given, or it was not — and no third
state that looks like the second.
- `handlers.ts` runs the strict check against the RAW input first and rejects
unrecognised keys whatever their value, then strips nulls. The premise
stripNullProps was written for is untouched and still tested: for a KNOWN
optional field, a null from a client that fills blanks with null means
"left blank". That premise never said anything about a field the schema
does not declare, which is why the check is split rather than removed.
Break-tested, all three reverted individually in an isolated copy: 3/3 KILLED,
each by the assertion naming its own surface. Restoration verified green.
Verified-By: npx vitest run tests/transports/forget-selector-safety.test.ts → "Tests 9 passed (9)", exit=0
Verified-By: break-test 3 mutants (truthiness / drop .min(1) / remove strict pre-check) → 3/3 KILLED, restored exit=0
Verified-By: npm run lint → exit=0
Verified-By: npm run build → exit=0
Verified-By: node scripts/run-tests-isolated.mjs → "Test Files 163 passed (163) / Tests 2378 passed (2378)", exit=0
Verified-By: node scripts/audit/verification-audit.mjs → exit=0
…e/delete became atomic
`hasVectorIndex` read `sqlite_master`. That row lives in the FILE, so a graph
created where sqlite-vec loaded and later opened where the platform binary is
missing — musl, an unusual arch, `npm ci --omit=optional`, a container image —
passed the check and threw `no such module: vec0` on first touch.
`conflict-candidates.ts:129` documents that exact trap, in those words, and
catches it. It was caught at one of six call sites. The two left unguarded are
the two that mutate user rows:
archiveEntity removeFromFts committed → DELETE FROM entities_vec threw →
UPDATE … SET status='archived' never ran
The memory was then stranded in the worst reachable state: `status='active'`,
so the archived-supplement branch (which filters on 'archived') never saw it;
absent from `entities_fts`, so keyword search never saw it; and
`includeArchived: true` returned nothing either. Retrying threw again forever.
`reindex --fts` recovered it, and nothing told the user it existed.
`deleteEntity` had the identical shape, leaving a row nothing could reach.
Two changes, at the two different levels the defect actually has:
- `hasVectorIndex` touches the table instead of asking the catalogue, so it
answers the question its own docstring always claimed to answer. Only
`no such module: vec0` and `no such table` count as absence; anything else —
a corrupt shadow table, a locked database — rethrows, because reporting that
as "no index" would silently downgrade recall to keyword-only and look like
a configuration choice. This removes the trap for all six call sites instead
of repeating the catch at each one.
- `archiveEntity` and `deleteEntity` each run their FTS delete, vector delete
and row mutation inside one immediate transaction. The probe fix should stop
the throw recurring; the atomicity is what makes any FUTURE throw survivable
instead of data-destroying, which is worth having on its own.
Break-tested: reverting the probe to the catalogue query, and making it swallow
every error as absence — 2/2 KILLED, restored green.
Verified-By: npx vitest run tests/storage/vector-index-process-fact.test.ts → "Tests 7 passed (7)", exit=0
Verified-By: break-test 2 mutants (catalogue probe / swallow-all) → 2/2 KILLED, restored exit=0
Verified-By: npm run lint → exit=0
Verified-By: npm run build → exit=0
Verified-By: node scripts/run-tests-isolated.mjs → see below, exit=0
Verified-By: node scripts/audit/verification-audit.mjs → exit=0
Test Files 164 passed (164)
Tests 2385 passed (2385)
`rankEntities` reads `entity.recall_hits` / `recall_misses`. The hydrator behind every recall — `getEntitiesByIds`, used by search, listRecent and the vector supplement — did not select those columns. The scorer received `undefined` for both, `impactScore(0, 0)` returned 0.5 for every row, and 10% of the ranking was a constant. The signal was alive the whole time. The Stop hook writes `recall_hits` from `[mem:id]` citations, and on a real graph 83 entities carry accounting spanning impactScore 0.037 (retrieved 25 times, never cited) to 0.750. That range was computed and discarded on every single recall. `briefing.ts` hydrates the columns and got the real values, so one scoring function behaved differently depending on which caller reached it. `scoring.ts:9` records that `temporalValidity` was deleted in 2026-05 for being "a constant 1.0 for every entity — a no-op factor". This was the same thing, in a factor nobody had checked. The fix is the whole chain, not just the SELECT — the columns were missing from the row type too, so adding them to one place would have left the type asserting a field the query never produced: - `EntityRow` gains `recall_hits` / `recall_misses` (required — the query selects them) - `Entity` gains them as optional, because catalogue reads (`listByType`, exports) legitimately do not hydrate them - both `getEntity` and `getEntitiesByIds` select them, and both assembly sites carry them through Left alone deliberately: `dreamer.ts:419` casts a five-column SELECT to `EntityRow[]`. That assertion was already wider than its query before this change and the code only reads those five columns; widening the type does not make it worse, and rewriting that query would be scope this change has not earned. Break-tested by reverting the hydrator's SELECT: 2 tests red — the column assertion AND the ranking-order assertion, which is the one that matters. Verified-By: npx vitest run tests/core/impact-factor-reaches-ranking.test.ts → "Tests 5 passed (5)", exit=0 Verified-By: break-test (hydrator drops the two columns) → exit=1, "× returns the citation history that is in the database", "× separates two memories that differ ONLY in citation history" Verified-By: npm run lint → exit=0 Verified-By: npm run build → exit=0 Verified-By: node scripts/run-tests-isolated.mjs → "Test Files 165 passed (165) / Tests 2390 passed (2390)", exit=0 Verified-By: node scripts/audit/verification-audit.mjs → exit=0 (C5 re-keyed +9 after the added comment; all three lines verified byte-identical against HEAD before re-keying)
…fusal refuse Three findings that share one shape: a boundary that was assumed rather than checked. **The origin boundary (R5-a, R5-b, R4-c).** The default listener binds to loopback and `bearerAuth` returns immediately for it — so "only this machine can reach it" was the whole boundary. A browser is on this machine. A page on any site the user visits while `memesh serve` runs could auto-submit a form to `http://127.0.0.1:3737/v1/demo/reset`; that is a CORS simple request, so no preflight, and the handler ran. `POST /v1/dream/run` and the proposal accept/reject routes had the same reach. The browser blocks the page from reading the reply, which hides the result rather than preventing it. `sameSiteOnly` now runs before everything else on `/v1/*`, checking three things the browser sets and page script cannot: `Sec-Fetch-Site`, `Origin` against the arriving `Host`, and — first, because it is what defeats the other two — a loopback `Host` on the unauthenticated listener, which is how DNS rebinding turns cross-site into same-origin. Non-browser clients (CLI, MCP, curl) send none of these and are unaffected. Safe methods are not exempt: `GET /v1/export` runs `kg.search` and bumps ranking state. Measured scope for demo/reset while fixing it: the delete is keyed on `metadata.demo = 1`, so only seeded tour rows were ever reachable, and the dashboard already confirms. The missing piece was the boundary, not a prompt. **A refusal that modified anyway (R4-a).** `uninstallHooks` deleted the citation rule file and only then parsed `settings.json`. On unparseable JSON `readSettings` throws "refusing to modify", so the command printed that, exited 1, and left `.claude/rules/` empty. Settings are now parsed first; the both-exits property (a plugin install has the rule file and no settings.json) is kept and tested. **A file no grep could read (new).** The prune key's `(event, matcher)` separator was a literal NUL byte. A text file containing NUL is a binary file to `grep` and `rg` — both suppress every match and exit exactly as they would for a clean file. So the 485-line file that edits the user's `settings.json` answered "no matches" to every pattern any grep-based reviewer ran against it. It is now written as the U+0000 escape: identical runtime value, and the file is text again. Swept the rest of the repo — one file affected; both doc and audit gates read via node `fs`, so no gate was silently passing. **Reject asks first (R4-d).** Rejecting a dream proposal was one click and permanent — the dreamer never re-proposes a rejected cluster and no surface un-rejects. Confirmed now, in all 11 locales. Accept stays unconfirmed on purpose: an accepted memory can be forgotten. R4-b closed as not-a-defect: the missing backup on the rule file is the correct asymmetry. `settings.json` is user-owned content edited in place; the rule file is memesh-owned end to end, and `session-start` rewrites it every session, so a backup would preserve — once — content the next session re-clobbers. Verification: - lint 0, typecheck 0, check-doc-claims 0 - new: origin-boundary 12 passed, uninstall-refusal 7 passed, reject-is-confirmed 3 passed - regression: tests/transports + install-hooks + installation 170 passed; dashboard 344 passed - break-tests: remove `app.use('/v1/', sameSiteOnly)` -> 6 of 12 fail; remove the reject confirm -> 1 of 3 fails; both files restored and checksum-verified
…budget **Secrets (R6-a, R6-b, R6-c).** The Stop hook copies two kinds of transcript text straight into the knowledge graph: the bash command lines it saw, and the text of every failed tool result. Both are the likeliest place a credential appears in a session — `export ANTHROPIC_API_KEY=sk-...` on a command line, an auth error echoing back the `Authorization: Bearer ...` it was sent — and neither was redacted. The secret became a permanent observation: searchable, exportable, and part of the payload `analyzeFailure` sends to whatever LLM provider the user configured. `redactSecrets` already existed and was already exported from the hooks' own `_generated/core-paths.js`. Nothing called it. It is called now at the point the text ENTERS the process — one line each — rather than at the three places it leaves, because the entry points are countable and the exits are not. Redaction runs before truncation: slicing first would cut a token in half and leave the fragment matching nothing. **R6-d.** `captureEntity`'s metadata-healing notice wrote the entity NAME to stderr. The id is what a maintainer needs to look the row up; the name is user-authored content on a stream the user may paste anywhere. Dropped. **Time budgets (R7-a, R7-b, R7-c).** `MemeshDatabase` waits 30s for a held write lock, and for the CLI, MCP and HTTP servers that is correct — a 30k-vector `swapVectorGeneration` holds the lock ~9s and those writers should wait. A hook cannot. Its budget is 3s to 10s, so a 30s wait has exactly one ending: the harness kills the hook. The capture is lost either way; the difference is the user also gets a hook-timeout error, which is the failure mode that gets memesh switched off. `openHookDb` now caps the lock wait at 2s, which fits inside every declared budget — contention becomes a quietly skipped capture that `doctor` reports honestly. Two hooks declared no timeout at all (SessionStart, and PostToolUse — which fires on every Bash call). They do now: 10 and 5. `pre-compact.js`'s own 10s guard is deleted. It could not fire: everything after stdin's `end` there is one synchronous block, so the event loop never gets a turn between the handler starting and the process exiting, and a JS timer cannot interrupt a blocking SQLite call. The timeout that works is the manifest's, enforced on the process from outside. Its removal is deliberately not pinned by a test — the only available assertion is on source text, and a timer that can never fire has no observable behaviour in either direction; the test says so and pins the external timeout instead. R8-a closed as not-a-defect: `recordGuardFires` writing while `autoCapture` is false is correct. The flag gates hooks that CREATE memories (post-commit, pre-compact, session-summary, user-prompt-intent all check it); guard-check and pre-edit-recall are read-path hooks that must keep warning the user either way, and the fire counter is that read feature's own accounting. `autoCapture: false` is documented as "auto-recall only", not "no writes". Verification: - lint 0, typecheck 0, check-doc-claims 0, verification-audit 0 - new: secrets-do-not-enter-the-graph 3 passed, hook-time-budgets 3 passed - regression: tests/hooks 20 files / 328 passed - break-tests: drop both `redactSecrets` calls -> 2 of 3 fail; drop the `busy_timeout` pragma -> 1 of 3 fails; both files restored and checksum-verified - baseline: one C5 entry re-keyed 433 -> 454 (same statement, unchanged classification) after the uninstall reorder in the previous commit
…an unmeasured index Five things write entity text. The vector index was left out of four of them, and the diagnostic that should have said so was reading a marker instead of counting. **A cleared entity kept its vector (R8-d).** `clearEntityData` deleted the observations, the tags and the FTS text and left the vector alone. Both callers — `--merge overwrite` on import, and the memory tool's `rewriteObservations` — therefore left the entity semantically matching its OLD text: a memory edited to say the opposite of what it used to say still came back for the old query, with the new text attached. The vector is DELETED, not re-embedded. Embedding is a network call and this is a synchronous graph mutation. "No vector" is a state the system can already see and already knows how to fix (`memesh reindex`); "wrong vector" is neither. The same three-line delete now appears at its third call site, so it became one private method that archive, delete and clear all use. `clearEntityData` also gained the transaction archive and delete already have, for the same reason: a throw between the observation delete and the FTS rebuild leaves indexed text for content nobody can read. **Doctor measured nothing (R8-e).** The Vector Index row read `pending_reindex`, a marker whose only writer is `reindex()`. Nothing else that creates a vector-less entity sets it — not the seven capture hooks (they never embed, and should not: a 2s hook budget cannot hold a network call), not import. Measured on a real graph on 2026-08-24: 344 of 499 active memories had no vector, the marker was unset, and doctor reported the database healthy. Semantic recall could not see 69% of it and nothing said so. The row now counts the gap with `countMissingVectors` and names the number. The marker still speaks for the case a count cannot express — a width change, where the vectors that exist are the wrong shape — and still leads when both are true. **`memesh task` never landed its embedding (R8-f).** `setTaskState` goes through `remember`, which SCHEDULES the embedding and returns. `remember` and `dream accept` await `flushPendingEmbeddings`; `task` did not, so the CLI process exited before the write every time. **A capture could be half-written (R8-c).** `captureEntity` performs six writes that only mean anything together — entity row, observations, tags, FTS delete + insert. In autocommit a throw in the middle committed the prefix. The two likely resting places are both invisible: observations with no FTS row (a memory that exists and can never be recalled), or the old FTS row deleted and the new one not written. Neither is ever retried, because every caller dedupes on the entity NAME — `INSERT OR IGNORE` says "already there" next session and the state is permanent. One transaction. **The fire counter fails out loud now (R8-b).** `recordGuardFires` swallowed every error to avoid blocking the user, and still does — but writes one line to stderr. Guard ROI is judged on exactly that number, and a guard that fires often looked identical to a guard whose counter never landed. Verification: - lint 0, typecheck 0, check-doc-claims 0, verification-audit 0 - new: stale-vectors 6 passed, capture-is-all-or-nothing 4 passed - regression: tests/hooks 20 files / 328 passed - break-tests: drop `removeVectorRow` from `clearEntityData` -> 2 of 6 fail; unwrap `captureEntity`'s transaction -> 3 of 4 fail; both files restored and checksum-verified - baseline: four C5 entries re-keyed for a two-line import shift in doctor.ts, each statement confirmed byte-identical against HEAD first
…elds that were never filled
**Two reads that changed the ranking (R9-a, R9-b).** `access_count` and
`last_accessed_at` are 20% of the score, and every recall bumps them — correct,
a recall is a use. These two were not recalls.
`exportMemories` goes through `kg.search`, so taking a backup bumped the
counter and stamped "used just now" on up to a thousand memories: the act of
copying the graph re-sorted it. `search` now takes `countAsAccess`, default
true, and export is the one caller that says false. `listByType` already drew
this line by simply never calling `trackAccess`.
`createLesson` asked "does a lesson with this exact name exist?" with
`recall({ query: name, limit: 1 })` — a fuzzy search against an exact key. When
the lesson did not exist it matched some OTHER memory (the old `existing[0]
.name !== name` clause is its author's evidence for that) and bumped it. Every
LLM-generated lesson therefore manufactured one "memory reused this week",
which is the dashboard's headline number. It is a `getEntity` by name now.
**A commit score that was exactly backwards (R10-c).** `computeSignalScore`
read `observations.join(' ')` and reasoned about the result as if it were a raw
commit message. `post-commit` stores three observations — the message, `Branch:
x`, `Diff stats: y` — joined by a SPACE, so the flattened text has no newline
unless the message itself had one:
no body -> nothing to split on, `firstLine` was the whole 80-character
joined string: too long for every demotion branch, fell through
to "substantive commit body", 0.6
a body -> `firstLine` was just the subject, "fix: thing", 10 chars with a
colon: 0.3
Measured on the live graph: 459 commits at 0.6, exactly one below 0.4. The rule
written to demote mechanical commits was promoting them. It now reads
`observations[0]` — the message — and splits that; the length rules apply only
when there is no body, because a commit that wrote one has earned the benefit
of the doubt whatever its subject.
The existing tests passed a single observation containing just the message,
which is a shape the product never writes. That fixture is why the defect
survived. Three tests now use the real three-observation shape.
**Two fields nothing ever filled (R10-a, R10-b, R10-d).** See the CHANGELOG:
`toolPreferences` and `workflow.avgSessionMinutes` were parsed from observation
formats that appear nowhere else in the repository, so they were permanently
`[]` and `0` across the MCP tool, the dashboard and the docs. Their test
asserted that deadness. Removed rather than implemented, along with
`DEFAULT_SIGNAL_THRESHOLD`, whose docstring described a Settings filter that
was never built.
**A diagnostic that could crash (found while fixing R8-e).** The measured
Vector Index row calls `hasVectorIndex`, which deliberately rethrows anything
that is not "the module or table is absent" — swallowing a real fault there
would report a broken index as a configuration choice. But a diagnostic must
not die on the thing it is diagnosing, and it must not answer 0 either:
"measured none missing" and "could not measure" are different reports. Three
states now, with its own sentence for the third.
Verification:
- lint 0, typecheck 0, check-doc-claims 0, verification-audit 0
- new: reads-that-were-writes 5 passed; signal-scorer 13 passed (3 new)
- regression: doctor 92 passed, patterns 5 passed, dashboard 345 passed
- break-tests: drop `countAsAccess: false` from export AND restore the fuzzy
lesson lookup -> 2 of 5 fail; restore the joined-text commit split -> 2 of 13
fail; every file restored and checksum-verified
- baseline: three C5 entries re-keyed for line shifts (statements confirmed
byte-identical against HEAD), one new C5 triaged SAFE-BY-CONSTRUCTION with
its reason
…s with no reader **A string comparison for versions (R11-e).** `packageVersion < update .latestVersion`, with a comment conceding a semantic compare "would be more accurate" and claiming the string form "catches 99% of cases". It stops working at the first two-digit component: `'4.6.9' < '4.6.10'` is false. At 4.6.10 doctor would announce "Running pre-release version (4.6.9), npm latest is 4.6.10" — telling the user they are ahead of a release they are behind — while the session banner, which compares differently, urged them to upgrade. `classifyBump` is that comparison and already existed. **The citation rule read at the wrong scope (R11-b).** doctor hardcoded `'user'` while both writers resolve the scope from the install marker. On a `--scope project` install it therefore reported the contract missing and pointed its fix at a path nothing would ever write. **The numerator and denominator of the compliance rate counted different things (R11-d).** `citation_sessions_total` counts sessions that RECEIVED an injection; the numerator was `cited.size > 0` — true for any `[mem:N]` in the transcript, including an id this session never injected. Three lines above, `recall_hits` already had it right. The numerator is now the count that loop produces, so the two halves are one measurement. **An unreadable validator answer counted as approval (R11-h).** The caller thirty lines above explains why that is wrong — "'pass' means I checked and every claim is supported; this is 'I could not check at all'. Reporting the second as the first is how a validation gate becomes decoration" — and applied it only to the LLM being unreachable, not to the LLM answering with something unreadable. The second is the likelier of the two, because a model that drifts off the JSON format does so silently. Both report `unavailable` now, and neither blocks: `dreamer` skips only on `reject`. **A release gate that read the report and not the run (R11-a).** The doctor JSON is the verdict on the CHECKS; the exit code is the verdict on the RUN. Only the first was read, so doctor printing a clean report and then dying reported PASS. **Reinstall advice for an install method the user does not have (R11-g).** Four fix strings hardcoded `npm install -g @pcircle/memesh`. That is wrong for three of the four channels memesh ships through, and for a plugin user it does not repair the install — it creates a second one beside it, on a different code path, sharing one database. `getInstallChannelSupport` already knows the right sentence per channel. **`unknown` install channel when npm is not on PATH (R11-f).** `npm root -g` is authoritative when it works — it honours `prefix` from `.npmrc` — but it fails whenever npm is unreachable, which is ordinary: Claude Code launched from a GUI app, or a shell where a version manager's shim was never sourced, gives a process `node` without `npm`. Detection fell through to `unknown` and `memesh update` refused to run on a genuine npm-global install. The spawn still wins; the layout under `process.execPath` is the fallback. **"Refusing to repair" advice that could not repair (R11-c).** A corrupt `settings.json` was diagnosed correctly and then answered with "re-create with `memesh install-hooks`" — a command that parses the file first and throws `refusing to modify`. The fix now names the step that has to come first. **Two writes nobody could read (R11-i, R11-j).** `compressWeeklyNoise` archives at least twenty memories per week processed and its only caller discarded the count, so the one operation that removes things from view was also the only one that left no trace anywhere. It now says what it archived and how to get it back. And `metadata.guard.fires` — incremented on every guard match, initialised by `applyProposal` with a comment saying escalation waits on "measured fire accuracy" — had no command, route or panel that showed it. There is a `guard_activity` doctor row now, informational, absent when there are no guards. **dist.** This rebuild also carries the compiled output of the four preceding commits on this branch, which changed `src/` without it. Nothing here is pushed, so the omission never left this machine; folding it into one rebuild is cleaner than four amendments. Verification: - lint 0, typecheck 0, build 0, check-doc-claims 0, verification-audit 0 - new: doctor-guard-activity 3 passed, doctor-reads-what-was-written 9 passed, citation-compliance-numerator 3 passed - regression: 6 files / 131 passed across doctor, digest-validator, install-channel and the citation hooks - break-tests: revert the numerator to `cited.size > 0` AND drop the unconditional `cited` initialiser -> 2 of 3 fail; file restored and checksum-verified - baseline: six C5/C4 entries re-keyed for line shifts, each statement confirmed byte-identical against HEAD; one sibling entry restored after a re-key collision inside this session lost it
… destructive
SQLite stores what it is given. `CURRENT_TIMESTAMP` writes
`'YYYY-MM-DD HH:MM:SS'`; `toISOString()` writes
`'YYYY-MM-DDTHH:MM:SS.sssZ'`. Both live in this database. Compared as TEXT
they first differ at index 10, and that one character decides the whole
comparison:
' ' is 0x20 'T' is 0x54 so ' ' sorts BEFORE 'T'
Same instant, opposite verdicts — but only for values sharing a date, which is
why none of this was visible except exactly on the cutoff day.
**R14-a / R14-b — telemetry pruning deleted rows it was supposed to keep.**
`ts` is `DEFAULT CURRENT_TIMESTAMP`; the cutoff is an ISO string. Every stored
row from the cutoff DAY sorted before the cutoff whatever its time, so a
180-day prune took the whole of day 180, including rows newer than the cutoff
by hours. Both copies of the query — `src/db.ts`'s throttled prune and
`pruneTelemetry` — now normalise the parameter with `datetime(?)`. The column
is untouched, so `idx_llm_telemetry_ts` still covers the scan.
**R14-c — the scorecard silently dropped its own first day.** `WHERE ts >= ?`,
the mirror image: a 30-day window reported 29.
**R14-d — a stale plan was never stale on the day it mattered.**
`last_accessed_at` is written by `trackAccess` as an ISO string and compared
against `datetime('now','-30 days')`. 'T' sorts AFTER ' ', so a plan untouched
for 30 days and 20 hours still read as fresh. Four sibling queries in the same
file already wrapped the column in `datetime()`; this one did not.
**R14-e — the newest anchor was picked by string order.** `kg-backfill`'s
Rule 2 chose a project's most recent release/feature with
`created_at.localeCompare`, so a value written in one format always beat a
value written in the other from the same day. Rule 5 already used
`parseSqliteUtcMs`; Rule 2 was the one left behind. It uses it now, which also
means an untrusted stamp sorts LAST instead of first — the policy Rule 5 set.
**And the writer that made the two formats coexist at all.** `demo.ts` put a
full ISO string into `created_at`, and it was the only place in the codebase
that did. The repo has already decided what an unrecognised timestamp means:
`parseSqliteUtcMs` anchors both ends and returns null, and Rule 5 refuses to
anchor on a value it cannot trust — there is a test named for it. So every
demo-tour entity was invisible to the relation backfill and out of order in
every TEXT comparison, in the one dataset a new user's first impressions are
built from. Fixed at the writer rather than by widening the parser: the
parser's strictness is load-bearing (a `+08:00` suffix read as UTC is eight
hours wrong and looks fine), and only this one function disagreed with it.
Verification:
- lint 0, typecheck 0, build 0, check-doc-claims 0, verification-audit 0
- new: timestamp-format-comparisons 10 passed; kg-backfill 56 passed (1 new)
- break-test: all five comparisons reverted at once -> 5 of 66 fail, one per
fix, including the new Rule 2 case; files restored and checksum-verified
A note on that break-test, because it nearly shipped a mutation: the first
attempt built its backup list from an unquoted `$FILES` inside a zsh `for`.
zsh does not word-split unquoted parameters, so nothing was backed up, the
restore was a no-op, and the "restored" check compared two identical error
messages and passed. The mutation was undone by re-applying its exact
inverse, then the break-test was re-run with a literal file list. `shasum`
on a failing path is not evidence — the guard has to fail loudly.
- baseline: eight C5 entries re-keyed for line shifts, each statement
confirmed byte-identical against HEAD first
…tion that could not resume
**A numeric flag that is not a number (R12-b, R12-c).** `parseInt('abc')` is
`NaN`, and `NaN` went straight into whatever the flag fed. Each site failed
differently and none of them named the flag:
recall --limit abc NaN into a SQL LIMIT — a raw ERR_SQLITE_ERROR stack
trace carrying the absolute install path
export --limit abc silently ignored; the default was used and the user
was told nothing
why --line abc NaN reached `git blame -L NaN,NaN`, the failure was
caught, and the user was told "That line does not
exist in the tracked file" — a false statement from
the one command whose contract is to abstain
`why` had grown a hand-written guard with a comment describing exactly this,
and `telemetry` had grown a second one. They were the only two commands that
had. Both are gone, replaced by one commander coercion applied at all
fourteen numeric options, so a flag added tomorrow inherits the guard instead
of re-deriving it. It refuses zero and negatives too: `LIMIT 0` returns
nothing and `LIMIT -1` means "no limit", and both are answers to a question
nobody asked.
**A database that will not open (R12-a).** Every command opens the database
through one helper, and the helper let the throw through — a fifteen-line Node
stack with the install path, from `recall`, from `remember`, from all of them.
`memesh doctor` handles the identical state and says what to do about it; it
was simply the only command that did. The open is now wrapped (the command's
own throws are still its own business) and answers a sentence plus the next
step.
**No timeout on an LLM request (R12-d).** The three `fetch` calls in
`llm-client` had none. `embedder.ts` had already fixed this on its own path
and written down what it measured — a provider that accepts the connection and
never answers hangs the caller indefinitely. Here the callers are the Stop
hook (a 10-second budget before the harness kills it), `memesh dream run`, and
the HTTP server's dream route, where a hang holds a connection open forever on
a single-threaded event loop. 30s, matching the embedder deliberately: two
ceilings for the same provider on the same machine would be a number nobody
could explain. No retry layer is added — `callLLM` already owns failover, and
a second one underneath it would multiply a wait the hook cannot afford.
`AbortSignal.timeout` rejects with a message `classifyError` already reads as
`network`, which is the class that lets failover try the next provider
(verified against a real timeout, not assumed).
**A migration that could not resume (R13-a, R13-b).** Five `ALTER TABLE`
statements sat behind `if (!entityColumns.has('access_count'))` and two behind
`has('recall_hits')`. A group is only idempotent if it is also atomic, and it
is not — each ALTER commits on its own. So a `SQLITE_BUSY` from any of the
seven hooks on the second statement left `access_count` added and its four
siblings missing, and every open after that read the guard as satisfied and
skipped the block. The database was permanently half-migrated and `getEntity`
— whose SELECT names `last_accessed_at`, `confidence`, `recall_hits` — failed
forever with no way to heal short of deleting the file. Each column answers
for itself now. The two `CREATE INDEX IF NOT EXISTS` calls moved out of the
conditionals for the same reason: an index created inside a branch a partial
failure skipped never got a second chance.
Verification:
- lint 0, typecheck 0, build 0, check-doc-claims 0, verification-audit 0
- new: bad-input-and-broken-db 8 passed, migration-resumes-per-column 5 passed
- regression: 22 files / 156 passed across tests/cli, tests/storage and the
llm-client
- break-test: restore the grouped `access_count` guard -> 1 of 5 fails, on the
half-migrated case; file restored and checksum-verified
- baseline: seven C5 entries re-keyed for line shifts, each statement
confirmed byte-identical against HEAD first
… rows, metadata **Relations were dropped on import, and it was the ordinary case (R3-b).** They were created inside the per-entity loop, with a comment saying the target "may not have been imported yet — skip silently". That reads like an edge case. It is not: `export` writes newest-first (`ORDER BY id DESC`) and a relation almost always points from a newer memory to an older one, so the target was still further down the file nearly every time. A backup of a graph with relations restored with NONE of them and reported "Imported: N". Relations are collected during the entity pass and created afterwards, once every entity in the bundle exists. A relation that still cannot be created is genuinely pointing outside the bundle — real information loss — and it is reported in `errors` instead of swallowed. The old code could not tell the two cases apart, which is why it had to swallow both. This is the finding I could not reproduce for four attempts and marked UNVERIFIED. Every attempt failed on my own fixture: `createEntity` does not take a `relations` option, so the source graph I kept building had zero relations in it and the round trip trivially "passed". Two agents had reported it with output. Reading the loop settled it in a minute — the reproduction was the wrong tool for a defect visible in the code. **Three things a restore needs, missing from the bundle (R3-c).** - `created_at` — a restore stamped every memory with the day of the restore. Creation time drives recency in ranking, the dreamer's weekly clustering, `memesh why`, and every "what was I doing then" question; flattening the timeline into one instant is not a cosmetic loss. Restored only for entities the import CREATES, and only when `parseSqliteUtcMs` vouches for the value — the same parser policy `kg-backfill` Rule 5 already applies, which also stops a hand-edited bundle stamping a memory in the future where a negative age passes every recency check. - **archived entities were skipped entirely** — so `memesh forget`, export, restore brought the memory back to life. The one operation whose purpose is to take something out of circulation, undone by the one whose purpose is to preserve state faithfully. - `metadata` — `signal_score`, `task_state`, the demo marker and provenance were all lost. It round-trips now, minus three fields: `trust` and `provenance` because the import rebuilds them, and `guard` because that one is different in kind. Every other metadata field describes the memory; `guard` describes memesh's BEHAVIOUR — an enabled guard matches a regex against the user's Bash commands and prints a message of the guard author's choosing before the tool runs. A JSON file someone was sent must be able to bring memories, not to change what memesh does. A denylist of one rather than an allowlist, because the rest of the shape is open-ended by design and an allowlist would silently drop whatever a future release adds; the cost — that a new authority-carrying field has to be added here too — is written down at the site rather than left implicit. Bundle version `3.1.0`. Bundles written by earlier versions import unchanged: every added field is optional, and there is a test that reads a `3.0.0` bundle. Verification: - lint 0, typecheck 0, build 0, check-doc-claims 0, verification-audit 0 - export-import 28 passed (5 new, covering the later-target relation, the reported orphan relation, created_at, archived status, and the metadata round trip including the refused guard) - break-tests, run one at a time: relations back inside the loop -> the later-target and the reported-orphan tests fail; `created_at` dropped from the bundle -> its test fails; `includeArchived: false` -> the archived test fails. File restored and checksum-verified after each. - The 42 failures in a bare `vitest run tests/core tests/transports` are pre-existing and unrelated: the same 42 fail on a stashed working tree. They are the files that need the isolated HOME `run-tests-isolated.mjs` provides. - baseline: four C5 entries re-keyed (statements byte-identical), one new entry triaged with its reason - docs: API_REFERENCE gains a table of what the bundle carries and what import does with each field
…three states that all looked like nothing **A digest could merge into a memory the user wrote (R15).** `applyProposal` called `createEntity` with the model's chosen name and no collision check. `createEntity` uses `INSERT OR IGNORE`, so a taken name meant the insert was skipped, none of the digest metadata was written, and the LLM's observations appended to the user's own memory — and then the same transaction archived up to five of their memories under it and reported success. The extraction prompt asks for short slug names, which is exactly the shape that collides. `applyTranscriptProposal` has carried this guard the whole time, with the reasoning spelled out. The digest path is the one where the consequences are worse, because it archives. It has the guard now, and the returned `digestEntityName` is the name actually written — printing a name that is not in the database is how a user goes looking for a memory that does not exist. **Three outcomes that all rendered nothing (R16-a).** `PmAnalyticsPanel` returned `null` for a failed request, a request still in flight, and a reply this bundle cannot read. All three looked like an absence, so the user could not tell "still loading" from "the server is down" from "your dashboard is older than your server", and there was nothing to click or report. The sibling on the same tab already renders a spinner and a `role="alert"` box for exactly these cases. The unreadable-reply message says to reload rather than blaming the server, because the request succeeded — the cause is version skew. **A node could not be opened from the keyboard (R16-b).** The click handler was the ONLY path into ego mode and the evidence drill-down. The canvas is focusable and carries an aria-label with the counts, and search highlighted matches — but a keyboard user could not open a single node. Enter in the search box now opens the sole match, and the hint saying so appears exactly when the action is available. Deliberately narrow: full node-to-node traversal is still deferred, as the canvas comment says, but "find it and open it" is the thing the mouse could do that the keyboard could not do at all. **A budget spent on characters nobody reads (R17-a).** `chunkTurns` charged `turn.text.length`; `buildExtractionPrompt` sends `.slice(0, 4000)`. One pasted file could spend a whole 48,000-character budget while contributing 4,000 characters of prompt, and the turns that would have fit beside it were dropped from the NEWEST end — where a reversal is likeliest. The two now share one constant, `TURN_CHAR_CAP`, and the budget counts what is sent. **And the truncation that was silent (R17-b).** The module header promises a drop is "never a silent 0", and `truncatedTurns` delivered that for tail turns. A turn cut from 40,000 characters to 4,000 was still reported as fully analysed, so a session whose one decision lived in the tail of a long paste came back empty with nothing saying why. `cappedTurns` counts them, per session and per run, and `dream run --from-transcripts` prints it. Verification: - lint 0, typecheck 0, build 0, check-doc-claims 0, verification-audit 0 - new: pm-panel-states 4 passed; graph-two-layer 9 passed (3 new); dreamer 41 passed (2 new); transcript-extractor 33 passed (2 new) - regression: dashboard 349 passed - break-tests: digest name back to `digest.name` -> 1 of 41 fails; drop the Enter handler AND the loading state -> 2 of 13 fail; charge `turn.text.length` again and hardwire `cappedTurns = 0` -> 1 of 33 fails. Every file restored and checksum-verified.
…registry could override
**`autoUpdate: off` was overridable from the npm registry (R19).**
`decideAutoUpdateHook` runs unattended from the Stop hook, and `run: true`
makes `session-summary.js` spawn a DETACHED `npm install -g`. It carried a
deprecation override that fired only for a `patch` bump — and every policy
above `off` already permits a patch, so `off` was the only setting the override
could ever change. Its trigger is `currentVersionDeprecation`, a string the
PUBLISHER writes into the registry. Anyone able to publish the package could
therefore make every user who had explicitly turned auto-update OFF run a
global install, with no prompt and no session in which to object.
`off` means off now. A deprecated version is still said out loud: `memesh
doctor` escalates its update-status row to FAIL, and the session banner reports
it. The user decides. The same override in `src/core/updater.ts` — which has no
caller outside its own test today — is fixed too, so wiring it up later cannot
reintroduce the hazard.
**Build steps that could not fail (R18-a, R18-b, R18-c).**
- `generate-skills-manifest`'s `walk()` returned `[]` for a directory it could
not read, so a rename or a bad `files` entry shipped a manifest with zero
skill entries — and `memesh doctor` then verified what remained and reported
"Skills + hooks integrity PASS". The same file already hard-fails for a
missing single-file artefact and says why; the directories were the half left
out.
- `copy-cli-assets` asserted nothing. Copying zero files and exiting 0 is
indistinguishable from working, and the CLI reads what lands in
`dist/cli/assets` at runtime.
- `set-executable-bits` swallowed every chmod failure with the comment
"Windows ignores POSIX executable bits". Everywhere else a chmod failure is
real — a read-only mount, wrong ownership — and it produces a package whose
`memesh` binary is not executable, which the user meets as "permission
denied" on first run with nothing connecting it back to the build.
**A version read with no guard (R18-d).** `upgrade-plugin.sh` reads the
installed version through `node -e`, and its sibling twelve lines up guards
that pattern. This one did not, so an unreadable `installed_plugins.json` made
`CURRENT_VERSION` the empty string — which compares unequal to every target, so
the script reported an upgrade from "" and carried on against a registry it had
just failed to parse.
**A smoke test that borrowed the dev tree (R18-e).** The dashboard e2e
symlinked this repo's whole `node_modules` — vitest, the compiler, every
transitive dev dependency — into the extracted tarball. An import that resolved
there resolved for a user only if the package happened to be a runtime
dependency too, so a `dependencies` entry moved to `devDependencies` passed the
smoke test and broke on the first real install. It installs `--omit=dev` now,
the distinction `smoke-packed-artifact.mjs` already makes and explains.
**A leaf-import guard that could not see dynamic imports (R18-f).** The
generated hook modules must import nothing but node builtins and other copied
leaves, because at hook runtime `dist/` and `node_modules` may be absent. The
guard read static `import ... from` only, so `await import('../db.js')` in a
copied module passed and then threw where it mattered. Both forms are checked;
a COMPUTED specifier is refused rather than waved through, because a leaf that
computes its imports is not a leaf.
**Nothing verified npm received the release (R18-g).** `finish-release` ended
by PRINTING "then: npm view …", and a printed instruction is not a check: the
release could finish with the tag written, the Release created, the workflow
red, and the script exiting 0. Nothing else looks — `verify:release` is
satisfied the moment the tag exists, which is exactly the state that makes main
look released while npm does not have it. It polls now, because both ways this
looked like a failure were timing (the registry lags a green publish by
minutes; npm's local metadata cache answers stale, which `--prefer-online` gets
past) and both had been mistaken for a broken publish. A miss is reported as
UNCONFIRMED and exits non-zero — not "failed", since the publish may still land
— and `--no-wait` skips it.
Verification:
- lint 0, typecheck 0, build 0, check-doc-claims 0, verification-audit 0
- new: auto-update-respects-off 4 passed; updater 22 passed (1 rewritten, 1
added)
- the three build scripts run clean individually; `generate-hook-core` still
generates all eleven leaves; `finish-release --dry-run` still refuses on a
branch with all five reasons; `finish-release --help` lists the new flag
- break-test: drop `&& policy !== 'off'` -> 1 of 4 fails; file restored and
checksum-verified
Each of these passed on every machine while asserting nothing about the thing
it was named for. The fix is a test that goes red when the product breaks —
verified by breaking it.
**Semantic provenance had ZERO effective coverage (R20-a).** Two tests claimed
it. `recall-provenance.test.ts:58` asserts inside `for (const e of nonsense)`,
and `nonsense` is the vector supplement's output — on a throwaway HOME no
embedder is configured, the supplement never runs, the array is empty, and the
loop body never executes. `recall-presentation.test.ts:75` early-returns on
`if (stdout.includes('No results found.'))`, which is what that same machine
prints. Both are honest about the branch they take; both are incapable of
failing. Nothing asserted that a vector-surfaced row is LABELLED semantic —
which is the entire point of `Entity.match`, because a semantic-only hit cannot
be certified relevant and every consumer has to be told how the row was found.
The new test stubs `embedText` — the one network call — and runs everything
else for real: real `vec0` rows at the database's real width, the real KNN
search, the real distance-to-relevance conversion, the real merge. Relabel the
hit `keyword` in `operations.ts` and it goes red.
It also corrected an assertion the old test could never have run:
`relevance < 1` fails on an exact vector match, which legitimately scores 1.
The fixture uses a near-but-not-identical vector, and says why.
**The LLM fallback chain was forwarded at eight sites and asserted at zero
(R20-b).** `callLLM`'s own tests cover the walk. Nothing covered the WIRING —
delete `fallbacks: opts.fallbacks` at all eight product sites and the suite
stays green, while a user who configured `llmFallbacks` gets no failover and
the outage reads as designed degradation ("Smart Mode is unavailable") rather
than as a bug. The new test drives three of the four leaf functions with a
primary that 500s and a fallback that answers, asserting the fallback's HOST
was reached and its answer used. Deleting the forwarding at those three sites
turns four of five tests red.
**A hook that crashed before printing passed the contract test (R20-c).** The
harness caught the spawn failure and validated `err.stdout`, which is `''` for
a hook that dies at module scope — and `validateHookOutput('')` answers
`{ valid: true, kind: 'empty' }`, because emitting nothing IS how a hook opts
out. The contract test could not tell "declined to speak" from "died before it
could". It reads the exit code now: injecting a throw at the top of
`guard-check.js` turns it red. The coverage test also gained the size pin it
was missing — an unparseable manifest yields an empty `declared` set, and an
empty set has no uncovered members.
**`npm ls` could lose its `-g` (R20-v).** The mock called `handlers.ls()` with
no arguments, so nothing could see which command npm had been asked to run.
Dropping `-g` makes the updater read the LOCAL tree and report a
globally-installed memesh as absent — then refuse to update it — with every
assertion green. The args are forwarded and asserted now.
**`/2/` matched the year, not the count (R20-y).** Every rendered date carries
a 2, so the roadmap header test passed whatever the component drew, including
zero memories. It asserts the rendered summary, built from the same i18n
template the component uses, so it works in whatever locale the suite runs
under. Setting `count: 0` turns it red.
Verification:
- lint 0, typecheck 0, verification-audit 0
- new: semantic-provenance 3 passed, fallback-chain-is-wired 5 passed
- updated: hook-output-contract 16 passed, updater 22 passed,
ProjectRoadmap 10 passed
- break-tests, each restored and checksum-verified:
relabel the semantic hit `keyword` -> 1 of 3 fails
drop `fallbacks:` at the three leaf sites -> 4 of 5 fail
throw at the top of `guard-check.js` -> 1 of 16 fails
drop `-g` from `npm ls` -> 1 of 22 fails
render `count: 0` in the roadmap header -> 1 of 10 fails
…r, a prompt, and eleven identical locales
**A throttle that had nothing to suppress (R20-g).** "should skip if last
decay was less than 24h ago" asserted `decayed === 0` against a table with
nothing left to decay — `openDatabase` had already decayed it. Delete the
throttle entirely and the test stays green, while confidence decay runs on
every `openDatabase`: seven hooks plus the CLI plus two servers, on every
invocation. It seeds a decayable row first now, and asserts the row's
confidence is untouched. The positive case is there too, so a `runAutoDecay`
that simply stopped working cannot pass instead.
**Two states that looked identical (R20-s).** "openDatabase auto-prune is
throttled" seeds an old telemetry row, forges a recent marker, reopens, and
asserts the row survived — which is equally true if `openDatabase` never
prunes at all. The positive case (marker 48h old, row gone) is what tells them
apart, and without it the whole auto-prune could be deleted with the suite
green.
**A marker that was written and never read (R20-r).** The backfill test proved
`signal_score_backfill_v2` lands in `memesh_metadata`. Nothing proved anything
READS it. Delete `if (done) return` and the marker still lands, the suite stays
green, and every database open runs a full table scan over every entity, for
the life of the database. The observable difference is a row inserted AFTER the
pass recorded itself: honoured marker, still unscored. Commenting the guard out
turns it red.
**The judge prompt was never inspected (R20-t).** Twenty tests exercise
`judgeConflicts` and none of them looked at what it sends. The wording does not
need pinning; the A/B correspondence does — the verdict returns
`direction: 'a_supersedes_b' | 'b_supersedes_a'` and the caller records it
against the `(a, b)` it passed in. Swap the two sides in the prompt and every
SUPERSEDES verdict names the wrong survivor, written into
`conflict_judged_pairs`, which is never re-judged: permanent, and it cost an
LLM call to get wrong. `buildPrompt` is exported for this and three properties
are pinned — each side under its own marker, each side's own observations, and
the untrusted-block declaration the prompt already documents. Swapping A and B
turns two of three red.
**Eleven identical locale blocks would have passed (R20-m).** Parity compared
KEYS. Paste the English block under every locale name and you get 11 blocks, 0
missing keys, green — which is exactly what a half-finished translation looks
like. The new check compares VALUES, with a majority threshold rather than
"every value must differ", because some legitimately do not: a product name, a
command, a technical term. Measured break-test: replacing zh-TW's values with
English reports "zh-TW repeats the English string for 685 of 685 keys".
Verification:
- lint 0, typecheck 0, verification-audit 0
- lifecycle 17 passed (1 rewritten, 1 added), llm-telemetry 10 passed (1
added), signal-score-backfill 5 passed (1 added), conflict-judge prompt
suite 3 passed, dashboard-i18n 21 passed (1 added)
- break-tests, each restored and checksum-verified:
remove the decay throttle -> 1 fails
neutralise the telemetry auto-prune -> 1 fails
comment out `if (done) return` -> 1 fails
swap A and B in the judge prompt -> 2 of 3 fail
paste English over zh-TW's values -> 1 fails
- The 19 unrelated failures in a bare `vitest run tests/core/conflict-judge`
are pre-existing: that file needs the isolated HOME `run-tests-isolated.mjs`
provides (the real `~/.memesh` index is 768-dim, the fixture is 384).
**A metric nobody ever asserted (R20-f).** `healthScore` is computed from four
factors, published on `/v1/analytics`, and rendered as the dashboard's Health
Score card. The test named for it ran its own `COUNT(*)` and asserted the
FIXTURE was empty — a statement about the test, not about `computeAnalytics`.
The whole computation could return a constant and nothing in the suite would
notice. It is asserted now, in both directions: 0 on an empty graph, and rising
when the factor that feeds it does. Hardwiring `healthScore = 0` turns the
second one red.
**A test that asserted its own argument (R20-u).** "remember with tags skips
auto-tagging" checked `result.tags === 1` — and `result.tags` is the count of
tags the CALLER passed on the line above. Auto-tagging could not have moved it
in either direction. What the test's name means is that the stored entity
carries the caller's tag and nothing else, which is now read back from the
graph.
**A stack-trace check pointed at the wrong stream (R20-x).** `runCli` used
`execFileSync`, which only surfaces stderr when the process throws, so the
success path hardcoded `stderr: ''`. Every "no stack trace reached the user"
assertion in the file therefore examined stdout — where a trace never goes —
and passed whatever the command printed. `spawnSync` returns both streams on
every outcome, and the assertion reads both.
**`indexOf` ordering that a deletion satisfies (R20-w).** "refuses before it
acts" compared `code.indexOf('checkReleasePreconditions(')` against the release
call. `indexOf` answers -1 for something that is not there, and -1 is less than
every real index — so DELETING the precondition call satisfied the ordering
assertion, which is the one thing the test exists to prevent. Both indices are
asserted to exist first.
**A 400 that proved nothing (R20-d).** "returns 400 for non-boolean validate"
accepted any 400 the route produced, including one from a schema that had
dropped `validate` entirely — the regression the file says it exists to catch.
The error text has to name the field.
**One grep for three title sites (R20-p).** `toMatch(/truncateTitle\(/)` is
satisfied by one match, and `session-summary.js` writes THREE titles — two
could lose the cap with this green. The count is pinned per hook, written down
rather than re-derived from the same file by a second regex, so a hook that
grows a fourth title is a visible edit in the test. And the structural check
cannot prove the function still truncates, so a behavioural half was added with
a genuinely long title — the file's own comment noted that the per-hook row
assertions miss the cap "unless the fixture text happens to be long", and none
of theirs are.
Verification:
- lint 0, typecheck 0, verification-audit 0
- analytics 28 passed (1 rewritten, 1 added), auto-tagger 8 passed,
why 15 passed, release-preconditions 16 passed, dream-http 8 passed,
write-hook-invariants 15 passed (1 rewritten, 1 added)
- break-tests, each restored and checksum-verified:
hardwire `healthScore = 0` -> 1 fails
drop `truncateTitle` from one session-summary
title site -> 1 fails
…ed to me first
**A banner the test never mounted (R20-n).** "names the focused node by its
headline" typed into the search box and asserted `not.toContain(name)` — but
typing only HIGHLIGHTS, and the focus banner is mounted by SELECTION. Nothing
rendered the banner, so the only assertion was an absence a blank page
satisfies. It presses Enter now (the keyboard path added earlier on this
branch), waits for the headline, and then checks the machine key is absent.
**A `waitFor` satisfied by a spinner (R20-o).** `waitFor` resolves on its first
synchronous check, and at that moment the tab is still loading — so "does not
say the project list is empty" was true of a loading state, and a component
that spun forever passed. It waits for the error box now, a positive signal,
then makes the negative assertion.
**Weights checked for presence, not for the term they multiply (R20-j).** Three
`toContain('* 0.4167')`-style checks. Every constant appears twice in that SQL
(once per branch of the pinned/unpinned union), so swapping recency's ratio
onto the confidence term and back left all three strings in the file and the
test green — while session-start ranked memories by a formula core ranking does
not use. Each weight is now bound to its own term by shape. The swap turns it
red and names which factor drifted.
**A slice window spanning two retirement blocks (R20-l).** `consolidate` and
`verify`/`patterns` both live between the same two markers and both say "has
been retired" with their own `process.exitCode = 1`, so every needle survived
deleting either command — which is the single thing the tests exist to prevent.
Both files run the commands now and read the real output and exit code, and
also assert Commander did not answer "unknown command".
**An identifier scan that an import line satisfies (R20-q).** "is attached by
every capture writer" searched the WHOLE FILE for `AUTO_CAPTURE_TAG`. A writer
could keep the import, stop passing the tag, and pass — while `memesh doctor`
counts that tag to answer "is the auto-capture loop alive" and would report a
dead loop as healthy. The check now requires it inside a `tags:` array.
**A behavioural half for the title cap (R20-p).** The structural check proves
each site calls `truncateTitle`; it cannot prove the function still truncates.
The file's own comment noted the per-hook row assertions miss the cap "unless
the fixture text happens to be long", and none of theirs are. One is now.
**Three questions, one canned answer (R20-i).** The doctor fixture returned the
same number for every query containing `COUNT(` — so `captured` (auto-capture
tag, last 24h), `legacyCaptured` (same tag, since tracking began) and the total
entity count were indistinguishable, and a row consulting the wrong one
produced the right verdict for the wrong reason. Each has its own value now,
defaulting to the old shared one so nothing that predates the split changed —
plus a test where the two counts deliberately disagree, without which the
split alone still let the mutation through.
**My own census test asserted `length > 3` (R20-z).** Three of the six probes
were never named by any test: a probe that stopped emitting — a renamed column,
a query falling into its `?? {}` — would take its line out of the census with
the suite green. That is exactly the silence the tool exists to expose, and it
could happen to the tool. Every probe is named now, and three of them are given
values only the database could supply.
**A break-test that passed and should not have.** The first mutation pair for
the graph banner and the project-tab error hit the wrong lines — I removed
`setEvidenceNode` when the banner is driven by `setEgoNodeId`, and replaced the
first of two `role="alert"` occurrences rather than the one under test. Both
mutants survived, which reads as "these tests are weak" when it actually meant
"this mutation is wrong". The same thing happened on the doctor fixture: the
split alone did not kill the mutant until a test made the two counts disagree.
Recorded because a surviving mutant is evidence about the MUTATION until the
mutation itself has been checked.
`dist/` was rebuilt after the consolidate mutation and verified to carry the
restored command again.
Verified-By: npx vitest run doctor.test.ts scoring.test.ts graph-two-layer.test.tsx review-fixes.test.tsx consolidate-retired.test.ts verify-retired.test.ts auto-capture-provenance.test.ts measure-signals.test.ts write-hook-invariants.test.ts -> "Test Files 9 passed (9) / Tests 164 passed (164)", exit=0
Verified-By: break-tests, each restored and checksum-verified — Enter selects nothing -> 2 fail; a dead /v1/projects renders null -> 1 fails; swap recency and confidence weights -> 1 fails; delete the consolidate registration -> 1 fails; keep the import, drop the tag from the call site -> 1 fails; the 24h window becomes an all-time count -> 1 fails; the recall-hits probe reports a constant -> 1 fails
Verified-By: npm run lint -> exit=0; npm run typecheck -> exit=0; node scripts/audit/verification-audit.mjs -> exit=0
`decideAutoUpdate` and `decideAutoUpdateHook` no longer let a registry deprecation string override an explicit `autoUpdate: off`. This fixture pins that the TWO copies agree, and it still does — but it also encoded the expected outcome, which changed. Both now refuse. Caught by the full isolated suite, not by the targeted runs: this file compares the two implementations and neither of them is named in the paths I had been re-running. Verified-By: node scripts/run-tests-isolated.mjs -> "Test Files 1 failed | 182 passed (183) / Tests 1 failed | 2515 passed (2516)", this file being the one failure Verified-By: npx vitest run tests/updater-drift.test.ts -> "Tests 25 passed (25)", exit=0
A four-angle review of this branch's own diff (reuse, simplification,
efficiency, altitude). Most of it is cleanup. Five of the findings were not.
## Defects the review found
**`createEntity` was the one writer left in autocommit.** This branch gave
`archiveEntity`, `deleteEntity`, `clearEntityData` and the hooks'
`captureEntity` transactions — the four lowest-traffic writers — and left the
one every other surface uses (`remember`, `import`, `dream accept`, the weekly
summary, the HTTP and MCP routes) unprotected. It performs the same six-write
sequence, and the two likely resting places after a mid-way throw are both
invisible: observations with no FTS row, or the old FTS row deleted and the new
one not written. `INSERT OR IGNORE` then makes it permanent. Wrapped; nesting is
safe because `MemeshDatabase` turns an inner transaction into a SAVEPOINT.
**Three `created_at` cutoffs the R14 sweep missed.** Same defect, same file
neighbourhood: `toISOString()` compared against a column SQLite writes with a
space separator, where `' '` sorts before `'T'`. `lifecycle.ts` archives noise
memories a day early — and this branch had just added a line announcing that
count, so the number would have been visibly wrong. `dreamer.ts` drops the
cutoff day from clustering, twice.
**And the writer that would have kept recreating the split.** `import` bound
the bundle's `created_at` verbatim. `parseSqliteUtcMs` accepts either
separator, so an ISO value validated and was written back — undoing the
`demo.ts` fix in the same breath as making it. Normalised at the writer, which
is the whole argument that fix rests on.
**The Browse tab re-ranked up to 5000 memories per page load.** `countAsAccess`
was added and then used at one of the three call sites that need it.
`GET /v1/entities` without a filter still counted, and its schema caps `limit`
at 5000 because "Browse legitimately fetches the full set" — the export defect,
five times over, triggered by looking rather than by backing up. The two
branches of that one route disagreed depending on whether a filter was set.
**`memesh learn` never landed its embedding.** `flushPendingEmbeddings` was
added to `task` by hand; three commands remembered it and `learn` did not. It
belongs in `withDatabase`, which already owns the close it races — close and
flush are ordered against each other, so one owner holds both. The three
hand-rolled calls are gone.
**A numeric guard that stopped at the flags.** `wholeNumber` covered fourteen
options and not `dream accept <id>`, where a positional `parseInt('abc')` put
NaN straight into `applyProposal` — which archives source memories. Also
`--port`, `config set sessionLimit` (the site the helper's own docblock names
as motivation), and the two `parseFloat` thresholds, where NaN made every
comparison false and the filter silently matched nothing.
**An npm spawn that cannot work on Windows.** The e2e smoke's new
`--omit=dev` install used `execFileSync('npm.cmd')`, which fails with EINVAL
since the CVE-2024-27980 fix — `scripts/lib/npm-bin.mjs` exists for exactly
this and says so ("one owner, so a third caller cannot get it wrong a third
way"). CI runs that job on ubuntu only, so it was green and still broken. Both
npm calls in the file now go through `npmSync`, including the pre-existing
`npm pack` that had the same bug.
**The guard-activity row counted a wider set than the hooks load.**
`json_extract(metadata,'$.guard.enabled') = 1` has no type filter and does not
require `tool`/`pattern`/`message`, while `loadActiveGuards` requires all four.
Doctor could report "3 active guards, 0 have ever fired" about a set the hooks
draw one guard from — and that count is precisely the number the block
escalation is supposed to wait on. It calls `guardFromMetadata` now, the
existing owner of the predicate.
## Cleanup
- `PmAnalyticsPanel` uses the dashboard's shared `classifyLoadError` /
`failureMessage` instead of a raw `String(e)` and two hand-written sentences.
It gains the `ratelimited` state it could not express, and the
`analytics.unreadable` key added earlier on this branch is deleted from all
eleven locales — reusing the shared message made it dead.
- The name-collision rule, copied between the two dreamer apply paths
(including a subtle "no status filter, deliberately" invariant documented
twice), is one `collisionSafeName`.
- `selectSoleMatch` looks up the real node instead of fabricating
`{ id, display } as GNode` with twelve fields undefined; `searchMatches` is
memoized (it scans every node on every render); the `useCallback` that could
never memoize is a plain function.
- The stale `no such module: vec0` catch in `conflict-candidates.ts` is gone —
`hasVectorIndex` absorbs that error now, and the comment still described the
mechanism it replaced.
- `redactSecrets` compiles its eighteen patterns once instead of per call. The
Stop hook calls it per bash block and per errored tool result, inside a
10-second budget.
- The `UPDATE created_at` statement is compiled once, not once per imported
entity.
- Smaller: a ternary whose two arms rendered identical text; a denylist of
three that its own comment called "a denylist of one" (`trust` and
`provenance` are re-set by later keys in the same literal); an
`instanceof ZodError` that is always true when `success` is false; a
`string | null` return that could no longer be null; a doctor parameter
optional for a caller that always passes it; two `CREATE INDEX` execs merged
into one on the `openDatabase` path; a poll that slept a full interval after
its last failed attempt.
- `--no-wait` removed from `finish-release`: nothing calls it, and what it
restored was the printed instruction the same change deleted on the grounds
that a printed instruction is not a check.
## Skipped, with reasons
- **Memoizing `hasVectorIndex` per handle.** Needs a WeakMap plus a rule about
which error may be cached (`no such module` yes, `no such table` no, or a
later `reindex` is invisible). New machinery with a correctness trap, to save
one prepared statement.
- **Avoiding the second schema parse per MCP call.** Needs a recursive
null-probe to stay correct (`title` is `.nullable().optional()`, so a blanket
early return changes behaviour). Microseconds, once per user-initiated tool
call.
- **Batching the archived-entity writes on import.** Real, but a restore is a
rare operation and the change is invasive.
- **Collapsing the two hand-maintained copies of the auto-update policy.** The
altitude finding is right that `decideAutoUpdate` has no production caller
and the pair must be edited twice. But `tests/updater-drift.test.ts` exists
to catch exactly that drift and currently does; restructuring the
generated-mirror boundary is its own change, not a cleanup.
Verified-By: node scripts/run-tests-isolated.mjs -> "Test Files 183 passed (183) / Tests 2516 passed (2516)", exit=0
Verified-By: npx vitest run create-entity-is-all-or-nothing + export-import + dreamer + tests/dashboard -> "Test Files 26 passed (26) / Tests 405 passed (405)", exit=0
Verified-By: npm run lint 0; npm run typecheck 0; npm run build 0; check-doc-claims 0; verification-audit 0
Verified-By: break-test — revert createEntity to autocommit -> 3 of 4 fail in create-entity-is-all-or-nothing; file restored and checksum-verified
Verified-By: baseline — 23 entries re-keyed by locating each statement in file order (a block shift moved neighbours onto each other's keys, which the stale-entry report only shows at the edges); 1 re-triaged because its statement was reshaped rather than moved
…g entity Found by the pre-release review, running `forget` against a read-only copy of the real graph rather than a fixture. `forget --name X --observation ""` did not destroy anything — core already distinguishes an absent selector from an empty one, which is the R1 fix. But the CLI's MESSAGE branch was still truthiness-based, so `''` fell past `opts.observation && result.entity_found` into the final else and printed `Entity "X" not found` about an entity sitting right there. That is the exact false statement the neighbouring test exists to prevent, for a different input, and it sends the user to re-create a memory that already exists — the one action guaranteed to make it worse. `ForgetSchema` rejects `observation: ''` with `.min(1)`, but that schema guards the MCP and HTTP boundaries; the CLI calls core `forget()` directly. Two surfaces should not disagree about the same input, so the CLI refuses it too, with a `nonEmpty` coercion alongside the fourteen `wholeNumber` ones. The message branch tests `!== undefined`, which is the question it was always asking. Verified-By: node scripts/run-tests-isolated.mjs -> "Test Files 184 passed (184) / Tests 2521 passed (2521)", exit=0 Verified-By: manual, all four outcomes on a real entity — absent selector archives; empty selector exits 1 naming the flag; non-matching text reports the entity found; matching text removes the observation Verified-By: break-test — revert both halves (coercion + `!== undefined`) -> 1 of 16 fails in tests/cli/flag-validation.test.ts; restored and checksum-verified Verified-By: npm run build 0; npm run lint 0; npm run typecheck 0; verification-audit 0; check-doc-claims 0 Verified-By: baseline — 7 entries re-keyed by line shift in cli.ts; audit back to 0 new
… failure
Two defects in opposite directions, both in the export/import round trip, both
found by the pre-release review running the real commands against a read-only
copy of a 1272-memory graph. Neither was reachable from a fixture: a fixture is
never larger than the default limit, and its relations never point outside it.
## `export` truncated in silence
`--limit` defaults to 1000, so the bundle carried 1000 of 1272 and the CLI
printed `✅ Exported 1000 entities`. A backup was missing 21% of the thing it
was taken to preserve, and nothing said so — not the CLI, not the MCP tool an
agent calls on the user's behalf. `entity_count` cannot carry that signal: it
is indistinguishable from a graph that happens to be exactly 1000.
The query asks for one MORE row than the caller wanted, so "there is more" is a
fact rather than an inference — `entities.length === limit` would call an
exactly-full bundle short — and the extra row costs one query, not a second
copy of the filter. `truncated` is in the RESULT, so HTTP and MCP callers see
it too; the CLI's warning goes to stderr, so `memesh export > b.json` still
writes a clean bundle. The MCP `limit` description says it as well, because
that string is the only text a model reads at call time.
## `import` then failed on that bundle
Nine relations pointed at entities the limit had cut off. They went into
`errors`, and `errors` sets exit 1 — so `memesh export > b.json && memesh
import b.json`, the round trip this project's own help text recommends, was a
failing command on a restore that did exactly what it should.
A relation leaving the bundle is a property of the bundle, not a failure of the
import: every bundle narrowed by `--tag`, `--namespace` or `--limit` has them.
They are still real information loss, so they are still reported — by name, in
a new `skipped_relations` field and on stderr, with what to do about it.
`errors` keeps its meaning: an entry that genuinely failed, and still exit 1.
This half is a regression from earlier on this branch. Before the two-pass fix
the same relations were swallowed by `catch { /* skip silently */ }` — worse,
but exit 0. Reporting them was right; counting them as errors was not.
Verified-By: node scripts/run-tests-isolated.mjs -> "Test Files 185 passed (185) / Tests 2528 passed (2528)", exit=0
Verified-By: real data, full export — 1272 entities, truncated:false, no warning; restore imports 1272 entities / 743 archived / all 165 relations / 0 T-separator timestamps, exit=0
Verified-By: real data, default export — 1000 entities, truncated:true, stderr warns and names --limit; restore imports 1000, names the 9 lost relations, exit=0
Verified-By: break-test — `truncated = false` -> 3 of 35 fail; dangling relations back into `errors` -> 2 of 35 fail; src/core/serializer.ts restored and checksum-verified (da69c5ff), no MUTANT left
Verified-By: npm run verify:release -> exit=0
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.
Fixes the findings from a full-codebase review, grouped by root cause rather
than by symptom. 18 commits, each with its own reasoning and its own
break-test.
The defects below are not a list of edge cases. Most of them are the same
shape: something was written correctly and then read by nothing, or read by
something asking a different question. Several had been live for months while
the suite stayed green.
Data loss and destruction
forgetwith an empty or unknown selector archived the whole memory.if (args.observation)treated""as "no selector", which selects thedestructive branch. Three surfaces reached it: the CLI (
--observation "",which an unset shell variable produces), the core, and MCP — where
stripNullPropsran before.strict(), soforget({name, observations: null})(plural, the wordrememberuses) arrived as{name}alone andarchived the memory, reporting
{archived: true}without mentioning theobservation the caller asked about. Fixed at all three layers.
A backup was not a backup.
export→importrestored with none ofits relations — they were created inside the per-entity loop and skipped
when the target "may not have been imported yet", which is the ordinary case
because export writes newest-first and relations point newer → older. It also
dropped
created_at(so a restore stamped every memory with the day of therestore, flattening the timeline that drives ranking, clustering and
memesh why), skipped archived entities entirely (soforget→ export → restorebrought the memory back to life), and lost
metadata. Bundle version 3.1.0;older bundles import unchanged.
A dream digest could overwrite a memory you wrote.
applyProposalcalledcreateEntitywith the model's chosen name and no collision check.INSERT OR IGNOREmeans a taken name skips the insert — so the LLM'sobservations appended to your memory, none of the digest metadata was
written, and the same transaction archived up to five more of your memories
under it and reported success. The sibling transcript path has carried this
guard the whole time.
A half-applied schema migration was permanent. Five
ALTERs behind oneguard; a
SQLITE_BUSYon the second left the database permanentlyhalf-migrated with
getEntitybroken and no way to heal.Telemetry pruning deleted rows it was meant to keep.
tsis SQLite'sformat, the cutoff was ISO — they differ at the separator, where
' 'sortsbefore
'T', so every row from the cutoff day was deleted regardless ofits time. Four more comparisons had the same defect.
Security
Any website you visited could drive the local API. The loopback listener
has no authentication by design, so "only this machine" was the whole
boundary — and a browser is on this machine. A page could auto-submit a form
to
http://127.0.0.1:3737/v1/demo/reset(a CORS simple request: nopreflight) and the handler ran. Same reach for
POST /v1/dream/runand theproposal accept/reject routes. Now checked on every
/v1/*request:Sec-Fetch-Site,Originagainst the arrivingHost, and — first, becauseit defeats the other two — a loopback
Host, which is what DNS rebindingforges. Non-browser clients send none of these and are unaffected.
API keys were stored as memories and sent to your LLM provider. The Stop
hook copied bash command lines and failed-tool error text into the graph
verbatim — the two likeliest places a credential appears in a session.
redactSecretsalready existed, already exported to the hooks. Nothingcalled it.
autoUpdate: offwas overridable from the npm registry. The deprecationoverride fired only for a patch bump, and every policy above
offalreadypermits one — so
offwas the only setting it could change, triggered by astring the publisher writes. A remote switch on a user's explicit refusal,
in a hook that spawns a detached
npm install -g.Things that were silently not working
344 of 499 memories had no vector, and
doctorreported the databasehealthy. The Vector Index row read
pending_reindex, a marker whose onlywriter is
reindex()— the seven capture hooks,import, none of them setit. Semantic recall could not see 69% of the graph and nothing said so.
Doctor now counts.
Editing a memory left the old vector in place.
clearEntityDataclearedthe observations, the tags and the keyword index and left the vector — so a
memory edited to say the opposite of what it used to say still came back for
the old query, with the new text attached.
The impact factor was a constant. 10% of ranking. The hydrator behind
every recall did not
SELECT recall_hits/recall_misses, soimpactScore(0, 0) = 0.5for every row, forever. On a real graph thosecounters span 0.037 to 0.750.
Commit signal scoring was inverted. A commit with a real body scored 0.3;
one with no body scored 0.6. Measured on the live graph: 459 commits at 0.6,
exactly one below 0.4. The rule written to demote mechanical commits was
promoting them.
exportre-sorted your graph. Taking a backup bumpedaccess_countandstamped
last_accessed_aton up to a thousand memories — 20% of the ranking.Two
user_patternsfields were parsed from a format nothing writes.toolPreferencesandavgSessionMinuteswere permanently[]and0across the MCP tool, the dashboard and the docs. Removed rather than
implemented; their test asserted the deadness. See CHANGELOG → Removed.
Diagnostics and messages
command.
doctorhandled the same state perfectly and was the only one thatdid.
recall --limit abc→ rawERR_SQLITE_ERROR;export --limit abcsilentlyignored the flag. One coercion now covers all fourteen numeric flags.
"running pre-release 4.6.9" while the banner urged an upgrade.
npm install -gfor all four install channels —for a plugin user that does not repair the install, it creates a second one
beside it sharing one database.
install-channelansweredunknownwhenevernpmwas not on PATH (ClaudeCode launched from a GUI app), and
memesh updatethen refused a genuinenpm-global install.
embedder.tshad already fixed this on itsown path and written down what it measured.
Two that were invisible to tooling
src/core/install-hooks.tswas a binary file togrepandrg. Itsprune key used a literal NUL byte as a separator, and both tools suppress
every match in a file containing one — exiting exactly as they do for a
clean file. So the 485-line file that edits your
~/.claude/settings.jsonanswered "no matches" to every pattern any grep-based reviewer ran against
it. Now written as the
\u0000escape: identical runtime value, and the fileis text again. Swept the repo — one file affected, and no gate was passing
because of it.
uninstall-hooksdeleted the citation rule and then checkedsettings.json. On unparseable JSON it printed "refusing to modify", exited
1, and left
.claude/rules/empty.Tests
26 tests were green and could not fail. The two that mattered most:
tests that claimed it are structural no-ops — one asserts inside a loop over
an array that is always empty without a configured embedder, the other
early-returns on the output that same machine produces.
Delete the forwarding everywhere and the suite stays green, while a user who
configured
llmFallbacksgets no failover and the outage reads as designeddegradation.
Also: a hook that crashed before printing anything passed the hook-output
contract (empty stdout is a valid payload — the exit code was discarded), and
eleven identical locale blocks would have passed i18n parity, which compared
keys only.
Verification
Every fix was break-tested: the fix reverted, the test confirmed red, the file
restored and checksum-verified. Each commit message records its own mutations
and their results.
One break-test result is worth repeating because it nearly produced a false
report: two mutants survived and read as "these tests are weak" when they
actually meant "this mutation is wrong" — I had removed
setEvidenceNodewhenthe banner is driven by
setEgoNodeId, and replaced the wrong one of tworole="alert"occurrences. A surviving mutant is evidence about the mutationuntil the mutation itself has been checked.
Not in this PR
22 minor CLI and doctor friction items from the same review remain open — a
memesh statusthat never probes the database,config set autoCapture yessilently becoming false,
--obs " "stored as whitespace, a maintainer-onlyrow shown in a user support report. All real, all small, none blocking. They
are recorded and can be picked off by priority rather than in bulk.