Problem
Graph nodes are project-scoped data, but their uniqueness is enforced globally on name, and upsertNode fetches by name with no project filter. When two projects produce a node with the same name, the second project silently reuses the first project's node id — so its records get linked to another project's entity.
- Schema (global UNIQUE on name):
packages/agentctx/src/storage/schema.ts:54-59
CREATE TABLE nodes (
id TEXT PRIMARY KEY,
project_id TEXT,
kind TEXT,
name TEXT NOT NULL UNIQUE -- ← unique across ALL projects, not per-project
);
- Upsert (insert scoped, select unscoped):
packages/agentctx/src/hooks/entities.ts:12-26
db.prepare("INSERT OR IGNORE INTO nodes (id, project_id, kind, name) VALUES (?, ?, ?, ?)").run(
ulid(), projectId, kind, name,
);
const row = db.prepare("SELECT id FROM nodes WHERE name = ?").get(name) as ...; // ← no project_id
For project B, when project A already has a node with that name: the INSERT OR IGNORE is ignored (global UNIQUE), and SELECT … WHERE name = ? returns project A's node id. linkRecordToEntity (entities.ts:28-33) then links project B's record to project A's node.
This is reached in practice, not just theory — name is often not project-unique:
- Branch nodes use the bare branch name:
upsertNode(db, projectId, "branch", branch) (packages/agentctx/src/hooks/post-tool-use.ts:83). Branch names like main / master / develop are universal, so essentially every multi-project install collides here.
- (File nodes use
resolve(cwd, …) absolute paths — post-tool-use.ts:71,172 — so they collide less often, but the branch case is common.)
That nodes are meant to be project-scoped is confirmed by deleteProjectData, which deletes nodes WHERE project_id = ? per project (packages/agentctx/src/storage/maintenance.ts:53). It also means a reset of project A deletes the shared main node that project B's record_entities rows still point at, leaving project B with dangling entity links. This is the same cross-project leakage class as #50/#72, in the graph layer.
What done looks like
- Node uniqueness becomes per project: change the
nodes constraint from a global name … UNIQUE to UNIQUE(project_id, name), and add AND project_id = ? to the SELECT in upsertNode (passing projectId). Both halves are needed — the SELECT fix alone can't work while the global UNIQUE still blocks the second project's insert.
- Because the schema is versioned, this lands as a new append-only entry in
MIGRATIONS (storage/schema.ts:90, the documented user_version scaffold — "Bootstrap and upgrade are the same idempotent code path") that recreates nodes with the composite unique. The repo is pre-release, so no production data-dedup handling is required; record_entities/edges reference nodes(id) (the unchanged primary key), so links survive the table rebuild.
- A test in
test/hooks/ (or test/storage/) upserts a branch node named main under two different projectIds and asserts they get distinct node ids (and that a record in project B links to project B's node, not project A's).
- No change to single-project behavior.
Problem
Graph
nodesare project-scoped data, but their uniqueness is enforced globally onname, andupsertNodefetches bynamewith no project filter. When two projects produce a node with the same name, the second project silently reuses the first project's node id — so its records get linked to another project's entity.packages/agentctx/src/storage/schema.ts:54-59packages/agentctx/src/hooks/entities.ts:12-26For project B, when project A already has a node with that
name: theINSERT OR IGNOREis ignored (global UNIQUE), andSELECT … WHERE name = ?returns project A's node id.linkRecordToEntity(entities.ts:28-33) then links project B's record to project A's node.This is reached in practice, not just theory —
nameis often not project-unique:upsertNode(db, projectId, "branch", branch)(packages/agentctx/src/hooks/post-tool-use.ts:83). Branch names likemain/master/developare universal, so essentially every multi-project install collides here.resolve(cwd, …)absolute paths —post-tool-use.ts:71,172— so they collide less often, but the branch case is common.)That nodes are meant to be project-scoped is confirmed by
deleteProjectData, which deletesnodes WHERE project_id = ?per project (packages/agentctx/src/storage/maintenance.ts:53). It also means aresetof project A deletes the sharedmainnode that project B'srecord_entitiesrows still point at, leaving project B with dangling entity links. This is the same cross-project leakage class as #50/#72, in the graph layer.What done looks like
nodesconstraint from a globalname … UNIQUEtoUNIQUE(project_id, name), and addAND project_id = ?to theSELECTinupsertNode(passingprojectId). Both halves are needed — theSELECTfix alone can't work while the global UNIQUE still blocks the second project's insert.MIGRATIONS(storage/schema.ts:90, the documenteduser_versionscaffold — "Bootstrap and upgrade are the same idempotent code path") that recreatesnodeswith the composite unique. The repo is pre-release, so no production data-dedup handling is required;record_entities/edgesreferencenodes(id)(the unchanged primary key), so links survive the table rebuild.test/hooks/(ortest/storage/) upserts abranchnode namedmainunder two differentprojectIds and asserts they get distinct node ids (and that a record in project B links to project B's node, not project A's).