Single-node deletion: full death by node id (node interlude, owner-locked)

The remedy for undeletable nodes (preserve-graph orphans from session
deletion; notes-import nodes with no chat behind them): DELETE
/api/graph/nodes/[id], full-death semantics — the user pointing at the
node IS the intent; references from other sessions do NOT preserve it.

- core/graph-deletion.ts deleteNodeTraces: the node's every line, every
  edge touching it, every quiz/observation event naming it, its mapping
  lines, and its resting position — one synchronous atomic rewriteLedgers
  call; only the ledgers that changed rewrite (mtimes stay honest). Blast
  radius is exactly the rows naming the node id; notes source records stay
  (deleting a proposition is not deleting the file). Derived data
  self-heals on read: the birth cluster shrinks below MIN_CLUSTER_SIZE and
  stops rendering.
- nodeDeletionImpact (GET /api/graph/nodes/[id]): the confirm dialog's
  pre-flight facts — edge count, quiz/observation history, referencing
  sessions. The snapshot's edges + nodeSessions cover part of this, but
  the raw event history was nowhere on the client — hence the read.
- Guards (server/graph.ts deleteGraphNode), decided + documented: NO
  turn/jobs gates (they never write graph ledgers; the orchestration is
  synchronous end to end, nothing interleaves). THE EXTRACTION GATE: 409
  while ANY pipeline run is registered — a mid-flight run holds
  pre-deletion findings and could re-reference the dying node. The run is
  never aborted (its spend belongs to another session — outside this
  deletion's blast radius; the UI retries once the dock drains. A run
  started after the deletion is legitimate re-learning: fresh ledgers,
  fresh node id, and mergeFindings' existingIds guard ignores linkTo ids
  that no longer exist — no dangling edges, no resurrection.

Live-verified on dev data: impact read, full-death stats, honest 404 on
repeat, and the file cluster fell 3→2 and unrendered itself.

859/859 tests, typecheck/lint/build green.
EOF
)
This commit is contained in:
amosblomqvist
2026-09-23 16:28:24 +03:00
parent 7479dce8ef
commit 2d133f1133
4 changed files with 406 additions and 1 deletions
+121 -1
View File
@@ -250,4 +250,124 @@ export function deleteChatTraces(graphStore: GraphStore, chatId: string, mode: C
}
return stats;
}
}
// ─── single-node deletion (node interlude, owner-locked full-death) ───────────
/**
* FULL DEATH for one node, by id (owner ruling 2026-09-23): the user
* pointing at the node IS the intent — references from other sessions do
* NOT preserve it. The remedy for undeletable nodes (preserve-graph
* orphans from session deletion; notes-import nodes with no chat behind
* them), and the deliberate inverse of the chat cascade above: that one
* deletes a SESSION and lets nodes survive on foreign references; this
* one deletes a NODE and takes its every trace —
*
* - every line of the node (create + link lines)
* - every EDGE touching it (from or to), whatever session asserted it
* - every EVENT naming it (quiz outcomes + observations), from every
* session — the evidence history dies with the thing it was about
* - every mapping line that anchored it (the audit trail; for a
* notes-born node the file IS the anchor — its mapping line is the
* only one)
* - its resting position
*
* The blast radius is exactly the rows naming the node id: other nodes'
* provenance, aliases, and sessions are untouched (full death is
* NODE-scoped, not session-scoped), and the source records of notes
* imports stay (the import's bookkeeping; deleting one proposition is not
* deleting the file). Derived data needs no pruning: the birth partition
* derives on read (the cluster shrinks below MIN_CLUSTER_SIZE and stops
* rendering — fine), and no other consumer indexes nodes by id across
* ledgers.
*
* Synchronous, one rewriteLedgers call, no awaits (the same atomicity
* argument as the chat cascade); idempotent in the wave-6 sense — a
* second run finds no lines and changes nothing.
*/
export interface NodeDeletionStats {
/** Edge assertion LINES removed (every edge touching the node). */
edgesRemoved: number;
/** Events removed (quiz outcomes + observations naming the node). */
eventsRemoved: number;
/** Mapping lines removed (the node's audit trail). */
mappingsRemoved: number;
}
/** The confirm dialog's pre-flight facts (the GET on the same node id):
* what full death would take with it. sessions is the deduped set of
* chats/notes sources that reference the node — provenance points,
* mapping lines, and events all count. */
export interface NodeDeletionImpact {
edges: number;
quizEvents: number;
observations: number;
sessions: string[];
}
/** A node exists iff a ledger line carries its id. */
function nodeLinesOf(graphStore: GraphStore, nodeId: string): GraphNode[] {
return graphStore.readNodes().filter((line) => line.id === nodeId);
}
/** The impact facts for one node, or null when no such node exists. */
export function nodeDeletionImpact(graphStore: GraphStore, nodeId: string): NodeDeletionImpact | null {
if (nodeLinesOf(graphStore, nodeId).length === 0) return null;
const edges = graphStore.readEdges().filter((line) => line.from === nodeId || line.to === nodeId);
const events = graphStore.readEvents().filter((event) => event.nodeId === nodeId);
const sessions = new Set<string>();
for (const line of nodeLinesOf(graphStore, nodeId)) {
for (const pt of line.provenance) sessions.add(pt.chatId);
}
for (const mapping of graphStore.readMappings()) {
if (mapping.graphNodeId === nodeId) sessions.add(mapping.chatId);
}
for (const event of events) sessions.add(event.chatId);
return {
edges: edges.length,
quizEvents: events.filter((event) => event.kind !== "observation").length,
observations: events.filter((event) => event.kind === "observation").length,
sessions: [...sessions].sort(),
};
}
/**
* Full-death one node. Returns null when no such node exists (the
* orchestration maps that to 404); rewrites only the ledgers that
* changed (unchanged files keep their mtimes — the version stamp moves
* only when something really did).
*/
export function deleteNodeTraces(graphStore: GraphStore, nodeId: string): NodeDeletionStats | null {
const nodeLines = nodeLinesOf(graphStore, nodeId);
if (nodeLines.length === 0) return null;
const edgeLines = graphStore.readEdges();
const events = graphStore.readEvents();
const mappings = graphStore.readMappings();
const outEdges = edgeLines.filter((line) => line.from !== nodeId && line.to !== nodeId);
const outEvents = events.filter((event) => event.nodeId !== nodeId);
const outMappings = mappings.filter((m) => m.graphNodeId !== nodeId);
// Only the touched ledgers: the node's own lines always die (its file
// must move), everything else rewrites only when rows actually drop.
const outNodes = graphStore.readNodes().filter((line) => line.id !== nodeId);
graphStore.rewriteLedgers({
nodes: outNodes,
...(outEdges.length !== edgeLines.length ? { edges: outEdges } : {}),
...(outEvents.length !== events.length ? { events: outEvents } : {}),
...(outMappings.length !== mappings.length ? { mappings: outMappings } : {}),
});
// The resting position dies with the node.
const positions = graphStore.readPositions();
if (positions[nodeId] !== undefined) {
const { [nodeId]: _dead, ...rest } = positions;
graphStore.writePositions(rest);
}
return {
edgesRemoved: edgeLines.length - outEdges.length,
eventsRemoved: events.length - outEvents.length,
mappingsRemoved: mappings.length - outMappings.length,
};
}
+63
View File
@@ -4,6 +4,13 @@ import type { GraphSnapshot } from "../core/contracts.ts";
import type { GraphCostSummary } from "../core/cost-summary.ts";
import { graphCostSummary } from "../core/cost-summary.ts";
import { graphSnapshot } from "../core/graph.ts";
import {
deleteNodeTraces,
nodeDeletionImpact,
type NodeDeletionImpact,
type NodeDeletionStats,
} from "../core/graph-deletion.ts";
import { extractionStatuses } from "../agent/subagents/extraction.ts";
import { stores } from "./stores.ts";
/**
@@ -65,3 +72,59 @@ export function graphRevision(): number {
}
return revision;
}
// ─── single-node deletion (node interlude, owner-locked full-death) ──────────
/** What deleteGraphNode did — the route maps it straight to HTTP. */
export type NodeDeleteOutcome =
| { ok: false; status: 404; code: "not_found"; error: string }
| { ok: false; status: 409; code: "extraction_running"; error: string }
| { ok: true; stats: NodeDeletionStats; graphRevision: number };
/**
* Delete one node by id — FULL DEATH (core/graph-deletion.ts for the
* ledger semantics). The guard family is deliberately NOT the chat
* one, and the reasoning is worth writing down:
*
* - No turn/jobs gates. Turns and background jobs never write the
* graph ledgers; a turn's follow-up extraction registers as a run,
* and every code path here is synchronous — nothing can register
* or merge between the gate and the ledger rewrite (the event loop
* never yields inside this function).
* - THE EXTRACTION GATE — 409 while ANY pipeline run is registered,
* anywhere. A mid-flight run holds findings extracted from
* transcripts that predate the deletion; its merges could re-add
* aliases, provenance, or events to the dying node (the
* re-reference risk the dispatch flags). Unlike chat deletion we
* do NOT abort the run: it belongs to some session, and killing
* that session's spend is outside this deletion's blast radius —
* the UI retries once the dock drains. A run that starts AFTER
* the deletion is legitimate re-learning: it reads post-deletion
* ledgers, mints a fresh node for a re-taught topic, and
* mergeFindings ignores linkTo ids that no longer exist (the
* existingIds guard) — no dangling edges, no resurrection.
*/
export function deleteGraphNode(nodeId: string): NodeDeleteOutcome {
if (extractionStatuses().length > 0) {
return {
ok: false,
status: 409,
code: "extraction_running",
error: "An extraction is running — try again when it finishes",
};
}
// THE SYNC BLOCK — no awaits below (the doc above): the gate and the
// rewrite are one atomic stretch of the event loop.
const stats = deleteNodeTraces(stores.graphStore, nodeId);
if (stats === null) {
return { ok: false, status: 404, code: "not_found", error: "No such node" };
}
return { ok: true, stats, graphRevision: graphRevision() };
}
/** The confirm dialog's pre-flight read (GET /api/graph/nodes/[id]):
* what full death would take with it. 404-shaped null when the node
* does not exist — the UI shows the dialog only for nodes it can see. */
export function graphNodeImpact(nodeId: string): NodeDeletionImpact | null {
return nodeDeletionImpact(stores.graphStore, nodeId);
}