Problem
verifyNodes only recomputes each node's output_key from its stored (input_key, content):
// src/commands/verify.ts
const recomputed = computeOutputKey(n.input_key, content);
if (recomputed !== n.output_key) { … }
It never reads analysis_edges. So a graph whose evidence trails are broken verifies as clean.
Observed concretely: after nodes were removed out of band, the database held 43,094 dangling consumes edges across 689 session-overview nodes — every one pointing at an output_key that no longer exists — and verification still reported:
✓ 32562 node(s) verified: every output_key is consistent with its content.
Why this matters
DESIGN.md makes verification the mechanism for catching exactly this class of damage:
Verification — recomputing every node's output key from its stored content and confirming it matches. Because identities are content-addressed, any drift reveals out-of-band tampering or corruption.
But the invariant most worth protecting is a relationship invariant, not a content one:
A proposal can always be traced, via edges, back to the conversation evidence that justifies it.
A dangling consumes edge breaks precisely that, and it is currently invisible. prospect show will fail to walk the trail, but nothing reports the graph as damaged — the one tool whose stated job is detecting corruption says everything is fine.
Note the asymmetry that makes this subtle: a node whose consumers disappear is harmless (append-only lineage does this routinely), whereas a node whose targets disappear is a broken trail. Only the second is a defect.
How it arose
Manual DELETE against the DB, not a code path — grep confirms nothing in src/ deletes nodes or edges, and the graph is append-only by design. That is not a mitigation though: out-of-band modification is the stated reason verify exists.
Proposed change
Extend verifyNodes (or add a sibling check) to validate referential integrity per edge kind, using the vocabulary already in edge-kinds.ts:
| edge kind |
target must exist as |
consumes, revises |
analysis_nodes.output_key |
anchors |
sessions.id or messages.id |
uses_prompt |
prompt_versions.hash |
uses_config |
analyzer_configs.id |
produces |
proposals.id |
contrasts_with |
sessions.id |
Report dangling edges grouped by owning analyzer and edge kind, so the output points at what to recompute. validateEdge already checks that a kind pairing is legal; this extends the same idea from "is this edge well-typed" to "does its target exist".
Worth considering alongside: a prospect gc <analyzer> that deletes an analyzer's nodes and the edges referencing them, so the manual cleanup that caused this has a safe supported path instead of raw SQL.
Repro
- Delete any analyzer's nodes directly (
DELETE FROM analysis_nodes WHERE analyzer_id = …) without deleting edges pointing at their output_key.
- Run
prospect verify — it reports a clean graph.
- Query for edges whose
to_ref_kind='analysis_node' and whose to_ref_id matches no output_key — they exist.
Problem
verifyNodesonly recomputes each node'soutput_keyfrom its stored(input_key, content):It never reads
analysis_edges. So a graph whose evidence trails are broken verifies as clean.Observed concretely: after nodes were removed out of band, the database held 43,094 dangling
consumesedges across 689session-overviewnodes — every one pointing at anoutput_keythat no longer exists — and verification still reported:Why this matters
DESIGN.md makes verification the mechanism for catching exactly this class of damage:
But the invariant most worth protecting is a relationship invariant, not a content one:
A dangling
consumesedge breaks precisely that, and it is currently invisible.prospect showwill fail to walk the trail, but nothing reports the graph as damaged — the one tool whose stated job is detecting corruption says everything is fine.Note the asymmetry that makes this subtle: a node whose consumers disappear is harmless (append-only lineage does this routinely), whereas a node whose targets disappear is a broken trail. Only the second is a defect.
How it arose
Manual
DELETEagainst the DB, not a code path —grepconfirms nothing insrc/deletes nodes or edges, and the graph is append-only by design. That is not a mitigation though: out-of-band modification is the stated reasonverifyexists.Proposed change
Extend
verifyNodes(or add a sibling check) to validate referential integrity per edge kind, using the vocabulary already inedge-kinds.ts:consumes,revisesanalysis_nodes.output_keyanchorssessions.idormessages.iduses_promptprompt_versions.hashuses_configanalyzer_configs.idproducesproposals.idcontrasts_withsessions.idReport dangling edges grouped by owning analyzer and edge kind, so the output points at what to recompute.
validateEdgealready checks that a kind pairing is legal; this extends the same idea from "is this edge well-typed" to "does its target exist".Worth considering alongside: a
prospect gc <analyzer>that deletes an analyzer's nodes and the edges referencing them, so the manual cleanup that caused this has a safe supported path instead of raw SQL.Repro
DELETE FROM analysis_nodes WHERE analyzer_id = …) without deleting edges pointing at theiroutput_key.prospect verify— it reports a clean graph.to_ref_kind='analysis_node'and whoseto_ref_idmatches nooutput_key— they exist.