feat: UX-5 Home metrics + the UX-1..UX-4 review fixes (20 defects) - #184
Merged
Conversation
…t have `computedFrom` describes the numbers in `loopMetric`, and both queries that produce them read `last_accessed_at` — nothing there reads `recall_hits`. The dashboard uses this field to decide whether to SHOW the "this is an approximation" caveat, so a wrong value here does not mislabel a number: it hides the sentence that explains it. It flipped to 'recall_hits' whenever the column existed and any knowledge entity had a hit inside the 30-day window. The comment on that gate named the exact failure it was written to prevent — "the badge would lie: say precise mode while the rendered numbers still come from the approximation" — and it did not prevent it, because a hit in the window is not evidence that these queries read hits. Measured on a real graph today: 21 knowledge entities carried in-window `recall_hits`, every one written by the literal-content matching that R1 retired at 0% measured signal, and `recall_accounting_mode` — the stamp that exists to keep the two accounting eras apart, which nothing read — was absent. The caveat was hidden on that graph. Earning the other value takes a schema change, not a probe: `recall_hits` is a per-entity running total, so a per-DAY reuse series cannot be derived from it. The comment says where to set the field when that query lands. [Verified-By: npx vitest run tests/transports/analytics.test.ts exit=0 — 'Tests 24 passed (24)'; npx tsc -p tsconfig.check.json --noEmit exit=0. Break-test: restoring the historical flip turns the new test red (1 failed), restored green]
…or that was blind to one class
The C5 'optimistic default' detector could not see `|| []` or `|| {}`.
Its regex ended in `\b`, and a word boundary cannot match after `]` or
`}` — so `|| true` was caught and the two collection forms never were,
for the detector's whole existence. Fixing it surfaced 10 occurrences it
had never once reported. Nine are guarded (view-live checks
`!data.success` before every one; serializer's field is genuinely
optional) and are baselined with that reason. The tenth was real:
- MemoriesTab's ranked search did `data.entities || []`, so an unreadable
recall payload rendered as a successful search that found nothing. The
load() path 100 lines above refuses that exact masquerade by name.
Three more, all in code merged earlier today:
- kg-backfill Rule 5 parsed SQLite timestamps with `new Date(v)`. The repo
already owns that parse — time-utils.ts says so, and warns that engines
accepting the format read it as LOCAL time. A uniform offset would cancel
in a same-column comparison, but it is not uniform: measured under
TZ=America/New_York, '2026-03-08 02:30:00' parses to 07:30Z and
'2026-03-08 03:00:00' to 07:00Z, so the fallback's ordering INVERTS across
the spring-forward hour. The same column can also hold a real ISO value
(demo.ts writes one), read as true UTC beside its local-read siblings — an
8-hour skew between two rows of one table. Now routed through
parseSqliteUtcMs, whose null means 'untrusted' and never sorts or anchors.
- Rule 5's project fallback ignored maxEdgesPerSource, and `added === 0` is
exactly what a cap of 0 produces — so `--max-per-source 0` wrote one edge
per evidence entity while every other rule wrote none.
- `memesh why` did not validate its numeric flags. `--limit abc` put NaN in
a SQL LIMIT and crashed with a raw ERR_SQLITE_ERROR stack trace carrying
the absolute install path; `--line abc` was worse than a crash — NaN
reached git blame, the failure was caught, and the user was told 'That
line does not exist in the tracked file', an affirmatively false statement
from the one command whose contract is that it abstains rather than guesses.
[Verified-By: node scripts/run-tests-isolated.mjs exit=0 — 'Test Files 150 passed (150)' / 'Tests 2193 passed (2193)', no Errors line; npm run verify:release exit=0. Break-test: reverting the timestamp parse and the cap guard together turns exactly 2 of the new tests red, restored green. CLI flags re-checked against a throwaway HOME: --limit abc / --line abc / --limit 0 each exit 1 with a sentence and no stack trace; --limit 3 exits 0]
…m a provenance hijack **why.ts: a stored entity name was used as a SQL LIKE pattern.** The join read `? LIKE substr(name, 8) || '%'`, which makes the NAME the pattern — and `%` and `_` are wildcards there, on data any caller can write: `remember --name 'commit-%%%…' --type commit` passes schema validation (the name field strips only control characters), and so does an import bundle. One such entity answered for EVERY hash, and `ORDER BY length(name) DESC` made it win deterministically, because real abbreviations are 7-40 characters and a name may be 255. Two independent reviewers reproduced it end-to-end through the public API, on the CLI and on the pure-DB `/v1/why` route: `memesh why` returned planted text as a commit's provenance and the abstention flipped from `no_commit_entity` to an asserted memory — the one thing this module exists not to do. The parameters were always bound correctly; binding does not constrain LIKE semantics over attacker-writable data. Now a substring comparison plus a hex guard, so a stored `%` matches only a literal `%`. **kg-backfill Rule 5 linked evidence across projects, two ways.** `--project alpha` scoped the evidence query and not the work query, so a run scoped to alpha wrote edges into bravo's decisions (measured on a seeded graph: 3 edges, 2 of them across the boundary). And on the default unscoped run, a shared `session:` tag was enough on its own — one Claude Code session routinely touches two repos and both get that tag, so a commit in one became evidence for a decision in the other. In UX-4 that renders as bravo's badge counting alpha's work. The session path now requires project agreement when both sides carry project tags, and stays permissive when either does not. [Verified-By: node scripts/run-tests-isolated.mjs exit=0 — 'Test Files 150 passed (150)' / 'Tests 2196 passed (2196)', no Errors line; npm run verify:release exit=0. Break-tests, each restored and re-verified green: reverting the LIKE join turns the wildcard test red (1 failed); reverting the two project guards turns the cross-project tests red (2 failed). Direct probe: the fixed join returns [] for an unrelated hash against seeded 'commit-%%%…' and 'commit-_______' rows, and still returns the real 'commit-abc1234' for its own hash]
…comparison `last_accessed_at` is written as `new Date().toISOString()` (storage/conflicts.ts) and `created_at` is SQLite's own CURRENT_TIMESTAMP. Comparing them as strings works until the date prefixes match, and then it compares 'T' (0x54) against ' ' (0x20) — so among entities touched on the SAME DAY, every recalled one sorts above every never-recalled one whatever the real times were. In the work-layer graph that is the exact inversion the view exists to prevent: a decision made at 23:00 and never recalled ranked below one last read at 09:00. Fixed by normalising the ISO column with SQLite's `datetime()` at both graph queries and at analytics' four window filters. Scoped honestly: across different days the date prefix already decided correctly, so this was same-day ties, not a total inversion. Deliberately NOT migrating the column to one format. Measured on a real graph: 781 rows ISO, 0 rows in SQLite's format — the column is internally consistent, and its readers are built for ISO. `recencyScore` parses it with `new Date()`, which is correct for ISO and would silently shift by the UTC offset if the stored form changed; `lifecycle.ts` compares it against a `.toISOString()` threshold, and under the other format ' ' < 'T' would make every memory look stale and decay its confidence. The cross-column comparison was the defect, not the storage. Two more in the same file: the work-layer relation query filtered status on neither endpoint while the entity query filtered both, so the payload carried edges to archived nodes the client then hid — leaving the live node drawn as connected with no visible edge. And the evidence drill-down ordered by `created_at` alone, which is second-granular, so a truncated page could differ between identical requests; it now breaks ties on id. The C1 detector could not see `toHaveLength(N)` as a size pin — it recognised only the `length).toBe(N)` spelling. Same blind-spot class as the C5 `\b` fixed in the previous commit. Correcting it cleared 12 long-standing baseline entries that were false positives all along. [Verified-By: node scripts/run-tests-isolated.mjs exit=0 — 'Test Files 150 passed (150)' / 'Tests 2199 passed (2199)', no Errors line; verification-audit exit=0. Break-tests, each restored and re-verified green: reverting the datetime() normalisation and the endpoint status filter turns 2 new tests red; reverting LIMIT cap+1 to cap turns the truncation-boundary test red]
…nters decision Three agents fixed disjoint file scopes; every claim was re-verified against the files before landing, and each agent reported honestly that its own fixes were unpinned. The tests here are that debt paid. **Dashboard.** The graph drew `entity.name` on the canvas, in the focus banner and in the drill-down heading — the machine dedup key (`pre-compact-<sessionId>`) that UX-1's chain exists so a human never has to read. Graph search matched only that key, so typing the headline you can see in the tooltip found nothing. The badge's draw and hit-test each computed their own geometry from a different radius, so on a hovered node the visible badge and its clickable circle were in different places; the draw now publishes where it drew and the hit-test reads that, deleting the second copy rather than syncing it. Toggling Signal Mode refetched the whole graph and closed an open drill-down, because the loader depended on a callback memoised on that flag. Memories' ranked search had no generation ticket: clear the box mid-search and the stale answer flipped the view back to ranked mode with an empty input. Its truncation notice read `health?.entity_count ?? 0`, so a health request that never landed silenced the notice entirely — a 12,000-memory library showed "2,000 active" and said nothing about the rest. Its sort compared an ISO timestamp against SQLite's space format, which ranked a recalled memory above a fresher never-recalled one within any single day. Project swallowed a failed `/v1/projects` into an empty array and told an established user they had no project memories; `fetchProjects` now throws on an unreadable payload instead of manufacturing one, and readmitted archived chain targets are filtered by project like the active side. **why.** Two new typed abstentions. `history_unreadable` replaces the `abstention: null` that made a git-log failure — output past execFileSync's 1 MB buffer, the 5s timeout, an unborn branch — print "No commits touch this file." `no_commits_supplied` separates "you sent no hashes" from "there are none", which `(data.commits ?? [])` had merged into one silent success. The session-entity query was unbounded and ran once per commit; it now caps in SQL and reports `truncated`, and the CLI prints that rather than showing 200 rows as if they were the session. **Export lost every title.** The bundle omitted the field and import never read it, so a backup taken after UX-1 restored a library of machine keys. Both halves are fixed, including the Zod schema that was silently stripping it on the MCP and HTTP paths while the CLI worked. **Lessons counters (KT's call).** Of the four the retired tab carried, only `severity:critical` is a number a reader acts on; it moves to Home and the other three are dropped, with the reason in CHANGELOG rather than left as an unexplained absence. It ships with its denominators — measured on a real graph, 29 lessons are active, 12 carry any severity tag and 5 are critical, so "5" alone would overstate what was classified. Citation compliance joins it as a tri-state whose `null` is today's honest answer: the counters do not exist until the release carrying them lands, and rendering that as 0% would report perfect non-compliance from an instrument never switched on. Still unpinned, deliberately: the ego banner and the badge hit-test both need canvas pointer geometry off a seeded layout, and a brittle test that passes for the wrong reason is worse than a stated gap. [Verified-By: node scripts/run-tests-isolated.mjs exit=0 — 'Test Files 151 passed (151)' / 'Tests 2211 passed (2211)', no Errors line; npm run verify:release exit=0. Break-tests, each restored and re-verified green: search-by-headline reverted → 1 red; recall generation ticket removed (all three guards) → 1 red; export title omitted → 1 red; git-log abstention reverted → 1 red (agent-run). The recall-race test took four attempts to make real — the first three passed against the defect because the query never fired, the finally-guard masked the removal, and the assertion ran before the stale write landed]
Four tiles above the fold: health, critical lessons, citation compliance, reuse. Two rules decide the whole component, and both exist because the alternative is a confident lie on the first screen anyone reads. **Not measured is not zero.** A tile whose instrument has never run says so, in words. It cannot render 0, because 0 is a measurement — "we looked and found none" — and printing it from an absence claims an observation nobody made. Citation compliance is the live example: the counters do not exist until the release carrying R1 lands, so on every install today it reads "tracking starts after your next session", not "0%". Measured-at-zero is a different tile with a different sentence. **A number arrives with its denominator.** Measured on a real graph: 29 lessons active, 12 carrying any severity tag, 5 critical. "5 critical" alone rounds the 17 nobody classified up into evidence that they are not critical, so the tile says "5 · of 12 classified · 29 lessons total". When nothing has been classified the value is not-measured, not zero, and an empty library says it has no lessons rather than that none are critical. The reuse tile keeps the approximation caveat its numbers earn — they come from last_accessed_at, which is what `computedFrom` has said since this morning's fix. The contract suite caught a real defect while this landed: `buildTiles` read `data.criticalLessons.severityTagged` unguarded, so a server one release behind threw during render — a white screen, since this app ships no error boundary. `isMetricsRenderable` now rejects a payload missing the groups the row reads and the row reports skew, rather than defaulting the groups into fabricated zeroes. `citationCompliance: null` passes that guard on purpose: null IS its answer. C1 also caught this file's own test asserting "zero tiles on failure" with no positive size pin — a component that rendered nothing would have satisfied it. The four-tile happy path is now asserted alongside. [Verified-By: node scripts/run-tests-isolated.mjs exit=0 — 'Test Files 152 passed (152)' / 'Tests 2220 passed (2220)', no Errors line; npm run verify:release exit=0. Break-test: folding both not-measured states into '0' / '0%' turns 3 of the 9 new tests red, restored green]
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.
UX-5 — Home leads with numbers that are true, and the review that got us there
Two things in one branch, because the second is what made the first
possible: a seven-way review of everything UX-1..UX-4 shipped, and the Home
metrics row those findings taught us how to build.
The review
Seven independent passes over PRs #157, #176, #181 and #183 — one per PR, one
cross-cutting on the seams, and two adversarial (one of them on a different
model, standing in for a Codex pass that hit its usage limit). Every finding
had to quote the source line that motivated it; anything unquotable was
forced to low confidence and kept out of the report.
Twenty defects were found and fixed. The ones worth reading about:
memesh whycould be made to lie about provenance. The commit-hashjoin used the stored entity NAME as a SQL LIKE pattern, where
%and_are wildcards — and the name is writable through the ordinary public API.
One entity named
commit-%%%…answered for every hash and, becauseORDER BY length(name) DESCprefers the longest name, won deterministically.Two independent reviewers reproduced it end to end:
whyreturned plantedtext as a commit's provenance and its abstention flipped from
no_commit_entityto an asserted memory. Parameters were always boundcorrectly; binding does not constrain LIKE semantics over data an attacker
can write. Now a substring comparison plus a hex guard.
The C5 detector was blind to half of what it was written to catch. Its
regex ended in
\b, and a word boundary cannot match after]or}— so|| truewas caught and|| []/|| {}never were, for the detector'swhole existence. Fixing it surfaced 10 occurrences it had never reported,
one of them a real defect (Memories' ranked search turning an unreadable
payload into "no results"). C1 had the same disease in a different spelling:
it could not see
toHaveLength(N)as a size pin, and correcting thatcleared 12 baseline entries that were false positives all along.
Rule 5 linked evidence across projects, two ways.
--project alphascoped the evidence query and not the work query; and on the default run a
shared
session:tag was enough on its own, which matters because oneClaude Code session routinely touches two repos. Measured on a seeded
graph: 3 edges written, 2 across the boundary.
Two timestamp columns, two formats, one string comparison.
last_accessed_atis ISO-8601 andcreated_atis SQLite's space format;comparing them as text ranked a recalled memory above a fresher
never-recalled one within any single day. Fixed at every cross-column site.
Deliberately NOT fixed by migrating the column — 781 rows are ISO, 0 are
not, and its readers are built for ISO:
lifecycle.tscompares it againsta
.toISOString()threshold, and under the other format every memory wouldread as stale and have its confidence decayed.
The loop metric claimed a precision it never had.
computedFromflippedto
recall_hitswhenever any in-window hit existed, which HID the "this isan approximation" caveat — while the numbers stayed the approximation. On
the graph this was measured against, all 21 qualifying hits came from the
literal-matching accounting R1 retired at 0% signal.
Export lost every title. The bundle omitted the field and import never
read it, so a backup taken after UX-1 restored a library of machine keys.
Both halves fixed, including the Zod schema that was silently stripping it
on MCP and HTTP while the CLI worked.
Plus:
whycrashing with a stack trace on--limit abcand lying with"that line does not exist" on
--line abc; a git-log failure reported as"No commits touch this file"; the graph drawing machine dedup keys where
UX-1's chain forbids it; graph search unable to find a node by the headline
it displays; the badge's draw and hit-test computing different geometry;
Signal Mode refetching the whole graph; Memories' ranked search with no
generation ticket; a truncation notice silenced by a health request that
never landed; Project reporting "no project memories yet" from a failed
fetch.
The metrics row
Two rules, both learned from the above:
Not measured is not zero. Citation compliance is the live example — the
counters do not exist until the release carrying R1 lands, so today every
install reads "tracking starts after your next session", not "0%".
Measured-at-zero is a different tile with a different sentence.
A number arrives with its denominator. Measured on a real graph: 29
lessons active, 12 classified, 5 critical. "5 critical" alone rounds the 17
nobody classified up into evidence they are not critical.
This is also where the retired Lessons tab's counters landed. Of its four,
only
severity:criticalis a number a reader acts on; the other three aredropped, with the reason recorded in CHANGELOG rather than left as an
unexplained absence — and the recall total specifically because it was built
on the accounting since proven to carry no signal.
Verification
Every fix that could be pinned was, and each pin was mutation-verified —
revert the fix, watch a specific test go red, restore, re-verify green.
Two fixes are deliberately unpinned and said so rather than faked: the
graph's ego banner and its badge hit-test both need canvas pointer geometry
off a seeded layout, and a brittle test that passes for the wrong reason is
worse than a stated gap.
One test took four attempts to make real. Each earlier version passed
against the defect for a different reason: the mutation script's pattern
never matched; the query never fired because Preact had not flushed the
input's state; the
finally-clause guard masked the removal of its twosiblings; and the assertion ran before the stale write could land. That
sequence is now recorded as the order to suspect when a mutant survives.
Follow-ups (not in this PR)
memesh kg backfillmust run once for the evidence badges to populate onan existing graph; the empty state and the docs both say so.
measurements that do not exist yet — which is the point.