// direct-ask provenance auto-injection. // // MCP tool wrappers that write to ACTION_PROVENANCE_LABELS call this helper // before `writeNodeWithEdges`. When the admin server has stamped // `SESSION_NODE_ID` in the MCP spawn env (the `sessionId` UUID of // the active `:AdminConversation` — name is historical, value is the UUID // property, not the Neo4j elementId, so it can be threaded through spawn // without an extra Neo4j round-trip at session-start), the helper: // - verifies a `:Conversation` exists with that sessionId AND the // write's accountId (account isolation is enforced inside the MATCH, // not as a separate gate) // - verifies the node carries one of PROVENANCE_SOURCE_LABELS // - prepends a synthetic inbound `:PRODUCED` edge to the relationships array // so the gate inside `writeNodeWithEdges` accepts the write // // The injection is a no-op when: // - the env var is unset (typical for autonomous workflow flows that pass an // explicit `producedByTaskId` instead) // - the labels being written do not require action provenance // - the relationships array already carries an inbound `:PRODUCED` edge from // any source (idempotency — explicit `producedByTaskId` already prepended // one; a second source is redundant noise. If the caller's PRODUCED edge // resolves to a node NOT in PROVENANCE_SOURCE_LABELS, the downstream gate // rejects with `missing-provenance` — defence-in-depth covers that case) // // Loud-failure cases emit `[] [provenance-missing] reason=` // to stderr and return the original relationships array unchanged — the // downstream gate then fires `missing-provenance` so both observability // signals land in server.log. import type { Session } from "neo4j-driver"; import { ACTION_PROVENANCE_LABELS, PROVENANCE_SOURCE_LABELS, } from "./index.js"; import type { GraphRelationship } from "./index.js"; export interface InjectConversationProvenanceParams { session: Session; /** The relationships array the caller is about to pass to `writeNodeWithEdges`. */ relationships: readonly GraphRelationship[]; /** The accountId of the write — must match the source node's accountId. */ accountId: string; /** Labels of the node about to be written. Used to skip injection when provenance is not required. */ writeLabels: readonly string[]; /** * Value of `process.env.SESSION_NODE_ID` at call time. Passed as a * parameter so tests can supply it without mutating process.env. The * value is the `sessionId` UUID property of the active * `:AdminConversation`, not its Neo4j elementId — the env-var name is * historical. The lookup MATCHes `(c:Conversation {sessionId, accountId})` * and reads `elementId(c)` itself to compose the synthetic edge. */ conversationNodeId: string | undefined; /** * Log namespace prefix — typically `"mcp:contacts"` or `"mcp:memory"`. Used * to format the `[provenance-inject]` / `[provenance-missing]` lines so the * operator can attribute the signal to a plugin. */ logNamespace: string; /** Tool name (e.g. "contact-create") for the inject/missing log lines. */ tool: string; } export async function injectConversationProvenance( params: InjectConversationProvenanceParams, ): Promise { const { session, relationships, accountId, writeLabels, conversationNodeId, logNamespace, tool, } = params; const original = [...relationships]; if (!writeLabels.some((l) => ACTION_PROVENANCE_LABELS.has(l))) return original; if (!conversationNodeId) return original; if (hasInboundProducedEdge(original)) return original; let lookup; try { // MATCH on (sessionId, accountId) — the account filter is part of // the natural key, so a cross-account env-stamp produces zero rows // (a clean `node-not-found`, not a silent leak). `elementId(n)` is // still returned because `writeNodeWithEdges` composes the synthetic // edge by elementId, not by sessionId — Neo4j's edge composition // is internal-id based. lookup = await session.run( `MATCH (c:Conversation {sessionId: $cid, accountId: $accountId}) RETURN elementId(c) AS elementId, labels(c) AS labels LIMIT 1`, { cid: conversationNodeId, accountId }, ); } catch (err) { process.stderr.write( `[${logNamespace}] [provenance-missing] tool=${tool} reason=driver-error message=${err instanceof Error ? err.message : String(err)} conversationNodeId=${conversationNodeId}\n`, ); return original; } if (lookup.records.length === 0) { process.stderr.write( `[${logNamespace}] [provenance-missing] tool=${tool} reason=node-not-found conversationNodeId=${conversationNodeId}\n`, ); return original; } const elementId = lookup.records[0].get("elementId") as string; const labels = lookup.records[0].get("labels") as string[]; const sourceLabel = labels.find((l) => PROVENANCE_SOURCE_LABELS.has(l)); if (!sourceLabel) { process.stderr.write( `[${logNamespace}] [provenance-missing] tool=${tool} reason=wrong-source-label labels=${labels.join(",")} conversationNodeId=${conversationNodeId}\n`, ); return original; } process.stderr.write( `[${logNamespace}] [provenance-inject] tool=${tool} from=${sourceLabel}:${conversationNodeId}\n`, ); return [ { type: "PRODUCED", direction: "incoming", targetNodeId: elementId, }, ...original, ]; } /** * Detect any pre-existing inbound `:PRODUCED` edge in the relationships * array. Source-label validation is the downstream gate's job; here we only * need to know whether the caller already provided one — if so, skip * injection to avoid duplicate edges. */ function hasInboundProducedEdge( relationships: readonly GraphRelationship[], ): boolean { return relationships.some( (r) => r.type === "PRODUCED" && r.direction === "incoming", ); }