Skip to content

fix: the full-codebase review findings — data loss, a CSRF boundary, secrets in the graph, and 19 root causes - #200

Merged
kevintseng merged 21 commits into
mainfrom
fix/review-r1-destructive-selectors
Aug 23, 2026
Merged

fix: the full-codebase review findings — data loss, a CSRF boundary, secrets in the graph, and 19 root causes#200
kevintseng merged 21 commits into
mainfrom
fix/review-r1-destructive-selectors

Conversation

@kevintseng

Copy link
Copy Markdown
Contributor

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

  • forget with an empty or unknown selector archived the whole memory.
    if (args.observation) treated "" as "no selector", which selects the
    destructive branch. Three surfaces reached it: the CLI (--observation "",
    which an unset shell variable produces), the core, and MCP — where
    stripNullProps ran before .strict(), so forget({name, observations: null}) (plural, the word remember uses) arrived as {name} alone and
    archived the memory, reporting {archived: true} without mentioning the
    observation the caller asked about. Fixed at all three layers.

  • A backup was not a backup. exportimport restored with none of
    its 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 the
    restore, flattening the timeline that drives ranking, clustering and memesh why), skipped archived entities entirely (so forget → export → restore
    brought 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. applyProposal called
    createEntity with the model's chosen name and no collision check.
    INSERT OR IGNORE means a taken name skips the insert — so the LLM's
    observations 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 one
    guard; a SQLITE_BUSY on the second left the database permanently
    half-migrated with getEntity broken and no way to heal.

  • Telemetry pruning deleted rows it was meant to keep. ts is SQLite's
    format, the cutoff was ISO — they differ at the separator, where ' ' sorts
    before 'T', so every row from the cutoff day was deleted regardless of
    its 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: no
    preflight) and the handler ran. Same reach for POST /v1/dream/run and the
    proposal accept/reject routes. Now checked on every /v1/* request:
    Sec-Fetch-Site, Origin against the arriving Host, and — first, because
    it defeats the other two — a loopback Host, which is what DNS rebinding
    forges. 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.
    redactSecrets already existed, already exported to the hooks. Nothing
    called it.

  • autoUpdate: off was overridable from the npm registry. The deprecation
    override fired only for a patch bump, and every policy above off already
    permits one — so off was the only setting it could change, triggered by a
    string 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 doctor reported the database
    healthy.
    The Vector Index row read pending_reindex, a marker whose only
    writer is reindex() — the seven capture hooks, import, none of them set
    it. 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. clearEntityData cleared
    the 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, so
    impactScore(0, 0) = 0.5 for every row, forever. On a real graph those
    counters 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.

  • export re-sorted your graph. Taking a backup bumped access_count and
    stamped last_accessed_at on up to a thousand memories — 20% of the ranking.

  • Two user_patterns fields were parsed from a format nothing writes.
    toolPreferences and avgSessionMinutes were permanently [] and 0
    across the MCP tool, the dashboard and the docs. Removed rather than
    implemented; their test asserted the deadness. See CHANGELOG → Removed.

Diagnostics and messages

  • Corrupt database → a 15-line stack trace with your install path, from every
    command. doctor handled the same state perfectly and was the only one that
    did.
  • recall --limit abc → raw ERR_SQLITE_ERROR; export --limit abc silently
    ignored the flag. One coercion now covers all fourteen numeric flags.
  • Version comparison was a string compare: at 4.6.10 doctor would say
    "running pre-release 4.6.9" while the banner urged an upgrade.
  • Reinstall advice hardcoded npm install -g for 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-channel answered unknown whenever npm was not on PATH (Claude
    Code launched from a GUI app), and memesh update then refused a genuine
    npm-global install.
  • No timeout on any LLM request. embedder.ts had already fixed this on its
    own path and written down what it measured.

Two that were invisible to tooling

  • src/core/install-hooks.ts was a binary file to grep and rg. Its
    prune 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.json
    answered "no matches" to every pattern any grep-based reviewer ran against
    it. Now written as the \u0000 escape: identical runtime value, and the file
    is text again. Swept the repo — one file affected, and no gate was passing
    because of it.

  • uninstall-hooks deleted the citation rule and then checked
    settings.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:

  • Semantic provenance had zero effective coverage on every machine. Both
    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.
  • The LLM fallback chain was forwarded at eight sites and asserted at zero.
    Delete the forwarding everywhere and the suite stays green, while a user who
    configured llmFallbacks gets no failover and the outage reads as designed
    degradation.

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

node scripts/run-tests-isolated.mjs
  Test Files  183 passed (183)
  Tests       2516 passed (2516)          exit=0

npm run lint                exit=0
npm run typecheck           exit=0
npm run build               exit=0
node scripts/check-doc-claims.mjs        exit=0
node scripts/audit/verification-audit.mjs exit=0

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 setEvidenceNode when
the banner is driven by setEgoNodeId, and replaced the wrong one of two
role="alert" occurrences. A surviving mutant is evidence about the mutation
until 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 status that never probes the database, config set autoCapture yes
silently becoming false, --obs " " stored as whitespace, a maintainer-only
row 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.

…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
@kevintseng
kevintseng merged commit 1401fe0 into main Aug 23, 2026
13 checks passed
@kevintseng
kevintseng deleted the fix/review-r1-destructive-selectors branch August 23, 2026 23:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant