Skip to content

upsertNode selects graph nodes by name alone (globally UNIQUE), so two projects sharing a node name (e.g. a main branch) cross-link records to the wrong project's node #78

Description

@Deepam02

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions