/** * Post-write audit for the database-operator's raw `write_neo4j_cypher` tool *. Sibling to `[graph-write] accepted` for the wrapped writers * (`memory-write`, `contact-create`, etc.) — same module so the * `[graph-*]` log family stays uniform across both write surfaces. * * Design posture: * - Static parse first. Pre-flight regex on the cypher text catches missing * provenance stamps and unknown edge types cheaply, no driver round-trip. * - Dynamic orphan detection is the caller's responsibility — the graph-mcp * shim runs the detection query against Neo4j after the upstream commits * and passes the resulting elementId list as `orphanIds`. Keeping the * module pure (no neo4j-driver import) makes it unit-testable without a * live database. * - Audit is observational. Warnings never block the write. The * prompt-level Graph Stewardship Doctrine in * [database-operator.md](../../../templates/specialists/agents/database-operator.md) * names the discipline; the audit is the verification stream. * * String literals are stripped before pattern checks so that property values * containing words like `'createdByAgent in my bio'` cannot trip a false * provenance count, and edge-like patterns inside quoted strings cannot * trip a false unknown-type warning. The regex shape is duplicated from * [cypher-validate.ts](../../graph-mcp/src/cypher-validate.ts) intentionally: * graph-write does not depend on graph-mcp, and a one-line regex helper * doesn't justify a third package. Drift in either copy is a regression * surfaced by both the validator tests and the audit tests. */ export interface SchemaSnapshot { readonly labels: ReadonlySet; readonly relationshipTypes: ReadonlySet; } export interface CypherWriteAuditInput { cypher: string; schema: SchemaSnapshot; agentName: string; sessionId: string; /** Counter from upstream response — drives missing-provenance arithmetic. */ nodesCreated: number; /** Counter from upstream response — informational only. */ relsCreated: number; /** * elementIds of nodes the post-write orphan query identified. Caller scopes * the query to (createdBySession, createdAt >= writeStartTimestamp) so this * list cannot include nodes from prior writes in the same session. Empty * array = no orphans (or no scope to check). */ orphanIds: string[]; } export type AuditWarning = | { kind: "orphan-warning"; orphanIds: string[] } | { kind: "unknown-type-warning"; type: string } | { kind: "missing-provenance-warning"; created: number; stamped: number }; export type AuditLine = | { kind: "accepted"; cypherPrefix: string; nodesCreated: number; relsCreated: number; agentName: string; sessionId: string; } | { kind: "orphan-warning"; cypherPrefix: string; orphanIds: string[] } | { kind: "unknown-type-warning"; cypherPrefix: string; type: string } | { kind: "missing-provenance-warning"; cypherPrefix: string; created: number; stamped: number; }; // Mirrors cypher-validate's pattern. Captures TYPE(|TYPE)* alternation; the // audit splits on `|` to enumerate every referenced type. const EDGE_PATTERN = /\[[^\]]*?:([A-Z_][A-Za-z0-9_]*(?:\|[A-Z_][A-Za-z0-9_]*)*)[^\]]*?\]/g; // Counts CREATE (n:Label) and MERGE (n:Label) statements that introduce a // node. The trailing `[A-Z]` requires at least one label (so `CREATE INDEX` // and bare `CREATE (a)-[:R]->(b)` shapes don't trip this — bare patterns // reference a previously-MATCHed alias and don't introduce new nodes). const CREATE_OR_MERGE_NODE = /\b(?:CREATE|MERGE)\s*\(\s*[A-Za-z_][A-Za-z0-9_]*\s*:\s*[A-Z]/g; // Stamp tokens — at least one of these MUST appear per created node for the // write to satisfy provenance discipline. The audit counts occurrences; // stamp-count >= node-count is the pass criterion. const PROVENANCE_TOKEN = /\bcreatedBy(?:Agent|Tool|Session|Source)\b/g; function stripStringLiterals(cypher: string): string { // Identical regex to cypher-validate.ts. Replaces single- and double- // quoted literals with empty quotes so e.g. 'CREATE (n:Person)' inside a // property value cannot inflate the create-count. return cypher.replace(/'[^']*'|"[^"]*"/g, '""'); } function extractEdgeTypes(cleaned: string): Set { const out = new Set(); // Use String.matchAll for stateless iteration — no shared regex lastIndex. for (const m of cleaned.matchAll(EDGE_PATTERN)) { for (const t of m[1].split("|")) { const clean = t.trim(); if (clean) out.add(clean); } } return out; } function countCreateOrMergeNodes(cleaned: string): number { const matches = cleaned.match(CREATE_OR_MERGE_NODE); return matches ? matches.length : 0; } function countProvenanceStamps(cleaned: string): number { const matches = cleaned.match(PROVENANCE_TOKEN); return matches ? matches.length : 0; } export function auditCypherWrite(input: CypherWriteAuditInput): AuditWarning[] { const warnings: AuditWarning[] = []; const cleaned = stripStringLiterals(input.cypher); // 1. Unknown edge type — cypher names a type not in the live ontology. // Enumerate every referenced type; emit one warning per unknown. const referencedTypes = extractEdgeTypes(cleaned); for (const t of referencedTypes) { if (!input.schema.relationshipTypes.has(t)) { warnings.push({ kind: "unknown-type-warning", type: t }); } } // 2. Missing provenance — count CREATE/MERGE-with-label statements vs // `createdBy*` token occurrences. Stamps may live in inline maps // (`{createdByAgent: $agent}`) or SET clauses; both shapes contribute // to the token count. The arithmetic is precision-imprecise — a // multi-stamp single CREATE inflates `stamped`, a multi-CREATE single // SET undercounts — but the directional signal (zero stamps for many // creates) catches the failure mode this audit exists to surface. // // nodesCreated=0 short-circuits the check: the upstream counter is // ground truth for whether any node was actually persisted. An // idempotent MERGE that matched an existing node has a static // create-count of 1 but contributed no new node — no provenance was // needed, and warning here would be a false positive. if (input.nodesCreated > 0) { const createOrMergeNodes = countCreateOrMergeNodes(cleaned); if (createOrMergeNodes > 0) { const stamps = countProvenanceStamps(cleaned); if (stamps < createOrMergeNodes) { warnings.push({ kind: "missing-provenance-warning", created: createOrMergeNodes, stamped: stamps, }); } } } // 3. Orphans — caller-supplied. The dynamic detection happens at the // graph-mcp shim layer (which has the Neo4j driver); the audit module // only renders the warning shape. if (input.orphanIds.length > 0) { warnings.push({ kind: "orphan-warning", orphanIds: input.orphanIds }); } return warnings; } export function formatAuditLine(line: AuditLine): string { const prefixField = `query="${line.cypherPrefix.replace(/"/g, "'")}"`; switch (line.kind) { case "accepted": return `[graph-cypher-write] accepted ${prefixField} nodesCreated=${line.nodesCreated} relsCreated=${line.relsCreated} agentName=${line.agentName} sessionId=${line.sessionId}`; case "orphan-warning": return `[graph-cypher-write] orphan-warning ${prefixField} orphanIds=${line.orphanIds.join(",")}`; case "unknown-type-warning": return `[graph-cypher-write] unknown-type-warning ${prefixField} type=${line.type}`; case "missing-provenance-warning": return `[graph-cypher-write] missing-provenance-warning ${prefixField} created=${line.created} stamped=${line.stamped}`; } }