- Status: Accepted —
feat/v1-m5-m6PR / 2026-05-07. - Authors: Laith Al-Saadoon + Claude.
- Branch:
feat/v1-m5-m6. - Supersedes nothing. Extends ADR 0011 (LadybugDB phase-1) by adding a
new graph-side entity behind the same
IGraphStoreseam, and ADR 0001 (DuckDB backend) by adding the corresponding columns to the polymorphicnodestable without a schema-version bump.
OpenCodeHub's M1 – M5 graph treated each indexed repository as a runtime
detail. The repo handle was the absolute working-tree path stored in
~/.codehub/registry.json, and every per-repo MCP tool keyed off that
on-disk registry rather than off the graph itself. That shape held up
while OCH was a single-repo tool, but the M6 cross-repo federation
surface — group_query, group_status, group_contracts,
group_list, group_cross_repo_links, plus the structured
AMBIGUOUS_REPO envelope — surfaced three specific problems the
runtime-only registry could not solve.
- Cross-repo edges had no typed source/target.
group_cross_repo_links(the AC-M6-3-reframed analysis helper atpackages/analysis/src/group/cross-repo-links.ts) emits{source_repo_uri, target_repo_uri, source_doc_path, target_doc_path, relation}records that the orchestrator embeds into.docmeta.jsonv2. Without a graph-sideRepoentity, those records had no declaration site — they were free-floating tuples that could not be audited, joined toContributorownership, or surfaced throughsql/ Cypher queries. The graph already has typedProcess,Route,Tool,Section,Finding,Operation,Contributor, andProjectProfileentities;Repowas the missing peer. AMBIGUOUS_REPOchoices[]had no graph backing. AC-M6-2 landed the structured_metapayload onstructuredContent.errorcarrying{error_code, jsonrpc_code, choices: [{repo_uri, default_branch, group}], total_matches, hint}. Thechoices[]shape is sourced from the registry today, but the canonical store for those three attributes —repoUri,defaultBranch, andgroup— is the graph itself once a repo is a first-class node. The runtime registry then becomes a session-scoped index over the graph'sRepoNodesingletons, not the source of truth.- The runtime-only registry was not deterministic. The same repo
cloned to two absolute paths produced two registry entries with
different generated IDs, even though the graph contents were
byte-identical. Promoting
Repointo the graph (and computing the id from a stable("Repo", "", "repo")triple) gives every clone the same node identity — the absolute path no longer leaks intographHash, and the same commit on two machines produces the sameRepoNode.id.
The clean fix is a graph-native Repo entity that synthesizes the
Sourcegraph-style repository URI scheme with SCIP Metadata.toolInfo:
a stable cross-repo handle (repoUri) plus the indexer name + version
that produced this graph. The M6 scope adds that entity additively —
the union grows by one kind, the DuckDB nodes table grows by 9
columns, the LadybugDB CodeNode table grows by the same 9 columns,
and graphHash byte-identity holds for every pre-M6 graph because the
new fields are absent on legacy nodes (W-M6-1).
Append Repo to the NodeKind union at packages/core-types/src/nodes.ts
(the file's L41-43 warning mandates appending at the end) and add the
nine attributes mandated by spec 005 §E-M6-1:
originUrl: string | null— canonical remote URL;nullwhen no git remote exists.repoUri: string— Sourcegraph-style host-path key (e.g.github.com/org/repo). WhenoriginUrlis null, this islocal:<sha256(absolute-path)[:12]>per S-M6-1 so the handle remains deterministic and distinguishable.defaultBranch: string | null— default branch at index time.commitSha: string— 40-char commit SHA the index was built against.indexTime: string— RFC-3339 UTC. Sourced fromgit show -s --format=%cI HEAD, not fromnew Date().toISOString().group: string | null— federation-group tag.visibility: "private" | "internal" | "public"— visibility for MCP gating; defaults toprivate.indexer: string— name+version of the indexer, per SCIPMetadata.toolInfo(e.g.opencodehub@0.1.0).languageStats: Readonly<Record<string, number>>— language distribution by fraction; sum bounded at 1.0.
The node is a singleton per graph — constructed via
makeNodeId("Repo", "", "repo") so the id stays stable across clones
of the same repo on different absolute paths (mirroring
ProjectProfileNode). The phase that emits it is
packages/ingestion/src/pipeline/phases/repo-node.ts, run after
profile (so languageStats can inherit the detected-languages list
from ProjectProfileNode.languages) and before scip-ingest.
The repo_uri shape is the on-the-wire canonical form for every M6
MCP surface: the AMBIGUOUS_REPO choices[] array (AC-M6-2), every
group_* tool's response payload (AC-M6-4), and the cross-repo link
emissions surfaced by group_cross_repo_links (AC-M6-3 reframed). All
four MCP tools accept repo_uri as an input alias for the legacy
repo registry-name argument; both inputs resolve through the same
packages/mcp/src/repo-resolver.ts path.
The phased plan, sequenced by milestone:
- M6 (this milestone):
RepoNodeships behind the existingIGraphStoreseam. New repos get aRepoNodeon the nextcodehub analyze. Pre-M6 graphs are not backfilled — see §Migration. The AMBIGUOUS_REPO_meta.choices[]payload, thegroup_*tools' additiverepo_urifields, and the cross-repo link records all sourcerepo_urifrom the new node. - M7 (planned at authoring time; not pursued — see §Edge kinds
deferred below): drop the legacy
reporegistry-name argument across all per-repo and group MCP tools (T-M7-6) and addRepo-rooted edge kinds (T-M7-7). Neither task shipped. The clean-slate v1 release keeps the legacyrepoargument as an accepted alias alongsiderepo_uri, andReporemains an edge-less singleton node.
The serialized shape of NODE_KINDS is load-bearing. graphHash
(packages/core-types/src/graph-hash.ts, 45 LOC) computes the
SHA-256 of the canonical-JSON projection {edges, nodes} with every
object's keys sorted, and the kind discriminator is part of every node
payload. The file's own comment at L41-43 captures the constraint:
Insertion order is load-bearing: any reorder of NODE_KINDS changes the serialized payload hashed by graphHash. New kinds must be APPENDED at the end to preserve stability of existing graph hashes across schema minor bumps.
Repo is appended at the end of both NodeKind and the runtime
NODE_KINDS array (packages/core-types/src/nodes.ts:40,82). The
discriminated GraphNode union is extended in the same file at
L591 with RepoNode appended at the end. Pre-M6 graphs read back
without any Repo node, so their canonical-JSON projection is
byte-identical to the M5 projection — graphHash is preserved.
The DuckDB schema does not need a version bump. The polymorphic
nodes table absorbs the 9 new attributes as additional nullable
columns (the storage adapter already serializes per-kind property
sets through this single table). The LadybugDB CodeNode table at
packages/storage/src/graphdb-schema.ts:101-176 is updated with the
same 9 columns: origin_url, repo_uri, default_branch,
commit_sha, index_time, repo_group, visibility, indexer,
language_stats_json. Both backends serialise the Repo node behind
the existing kind discriminator — no per-kind table partitioning is
needed for a singleton.
Rejected alternative: a separate repos (DuckDB) /
Repo (LadybugDB) table dedicated to repo-level metadata. Reasons for
rejection:
- The graph already has one polymorphic node table by design (ADR 0001's column-store choice). Splitting per kind for a singleton adds DDL surface without paying off — the table would always have exactly one row per indexed repo.
- Cross-table joins would have to be added to every
sqlMCP query that wants the indexer or commit SHA, defeating the whole point of keepingRepoNodefirst-class. - The LadybugDB rel-table-per-kind shape (ADR 0011 §Schema choice)
is for edges, not nodes. Splitting nodes by kind is not the
idiomatic Cypher pattern; LadybugDB's
MATCH (r:CodeNode {kind: "Repo"})is the canonical lookup.
graphHash is store-agnostic by construction (ADR 0011 §graphHash
invariant). The W-M6-1 invariant adds three guarantees specific to
the M6 schema bump:
- Appending
RepotoNodeKindMUST NOT changegraphHashfor any pre-M6 graph. The append-only ordering atpackages/core-types/src/nodes.ts:41-43,82is the mechanical guarantee. The parity test atpackages/storage/src/graphdb-adapter.test.tscovers a fixture that has noReponode and asserts the round-tripgraphHash(fixture) === graphHash(rebuildFromGraphDb(...)). indexTimeMUST come fromgit show -s --format=%cI HEAD, not from wall-clocknew Date().toISOString(). Thepackages/ingestion/src/pipeline/phases/repo-node.ts:121-125probeCommitTimehelper enforces this. Wall-clock noise would poisongraphHashon every pipeline run; pinning to the HEAD commit time gives "stable per commit" without excluding the field fromgraphHash.- Existing graphs are NOT backfilled. Pre-M6 graphs read back
without a
RepoNode, and the engine tolerates the absence (nofor-each-nodeloop assumes aRepois present). The firstcodehub analyzeafter upgrading to M6 is the only path that adds the node — and that run produces a brand-new graph anyway, so byte-identity is moot for it.
The fallback sentinel 1970-01-01T00:00:00Z (set by
probeCommitTime when git is unavailable or the repo is not a
working tree) carries no run-to-run variance and is the core of
W-M6-1's determinism guarantee for non-git inputs. The injected now
override is reserved for tests and reproducible-build paths — the
production phase never uses it.
The reframed AC-M6-3 work landed as commit 86e295b (the
computeCrossRepoLinks analysis helper plus the
group_cross_repo_links MCP tool) and the orchestrator-side
.docmeta.json v2 schema. The orchestrator Sonnet writes
.docmeta.json at runtime — no engine TS writer exists, by design.
There is no backfill. Pre-M6 graphs on disk continue to read back
without a RepoNode. Three rules govern the migration:
- Lazy population. The
Reponode is added on the nextcodehub analyzeagainst the repo. Until that runs, the registry resolver inpackages/mcp/src/repo-resolver.tsfalls back to the on-disk~/.codehub/registry.jsonfor theAMBIGUOUS_REPO.choices[]payload — the structured envelope still works, just without graph-sourced provenance. - Engine tolerance. Every consumer of
RepoNodechecks for its presence and degrades gracefully when it's missing. Thegroup_cross_repo_linkstool, for instance, readsrepoUrifrom arepo → repo_urimap computed from the persisted ContractRegistry — when the graph has noRepoNode, the map is built from registry entries directly. Thelocal:<hash>form is the canonical fallback for repos with no git remote (S-M6-1). - No mass re-analyze runbook. Users do not need to run
codehub analyze --forceacross their entire indexed corpus to pick up M6. The change is opt-in by activity: as repos are re-analyzed in the normal course of work, they pick upRepoNodeone at a time. The runbook for AMBIGUOUS_REPO retries (cited inAGENTS.mdandCLAUDE.md) works regardless of whether the graph has the node yet.
Repo ships without new edge kinds, and that stayed true for v1.
At authoring time this section sketched four Repo-rooted edges —
Repo HAS_FILE File, Repo HAS_DEPENDENCY Dependency,
Repo OWNED_BY Contributor, Repo IN_GROUP Community (or similar) —
to land in M7 under tasks T-M7-6 / T-M7-7. None of them shipped.
Resolution (v1 clean-slate, 2026-06): won't-do. The four
Repo-rooted edge kinds were never added. The v1 release does not carry the M7 edge-schema extension;RelationType/RELATION_TYPESinpackages/core-types/src/edges.tshas 25 members (CONTAINS…TYPE_OF), none of themRepo-rooted, andReporemains an edge-less singleton.OWNED_BYdoes exist in that enum, but it is a blame-level edge from a symbol/file to aContributor(itsconfidencecarries the normalized blame-line share, perCodeRelation's doc comment) — it is not theRepo OWNED_BY Contributorrepo-level edge sketched above. The federation surface (AMBIGUOUS_REPO, thegroup_*tools, cross-repo links) readsrepo_uristraight off theRepoNodeand from the persisted ContractRegistry, so noRepo-rooted edge was needed to ship it.
The original deferral rationale (left for the record): every new edge
kind is a new physical rel table on the LadybugDB backend
(rel-table-per-kind shape, ADR 0011 §Schema choice), so each new kind
costs one DDL update plus one parity-test fixture. The cost never paid
off — the v1 surface ships without these edges, and any future
Repo-rooted edge work would land under its own ADR.
NodeKindunion grows non-additively in a future change. If a future contributor reordersNODE_KINDSor inserts a new kind in the middle of the array,graphHashwill drift across the entire indexed corpus. The L41-43 warning is the documented guardrail; the parity test atpackages/storage/src/graphdb-adapter.test.tsis the mechanical guardrail. We accept this risk because the alternative — a schema-version bump on every union extension — would force every user to re-index their corpus on every minor release.local:<hash>collisions. The S-M6-1 fallback hashes the absolute path with SHA-256 truncated to 12 hex chars (48 bits). The collision probability at 1k repos is < 2^-22 (negligible), but two clones of the same repo at different absolute paths will produce differentlocal:<hash>URIs. This is intentional: when a repo has no git remote, the absolute path is the only stable handle we have. Once the repo gets a git remote, the next analyze replaces thelocal:<hash>URI with the canonicalhost/pathform.indexTimepoisoning if a writer ever uses wall clock. Therepo-nodephase pinsindexTimetogit show -s --format=%cI HEAD, but a future contributor adding a different writer (e.g. a migration that synthesizes aRepoNodepost-hoc) could accidentally usenew Date().toISOString(), breaking determinism. The mechanical guardrail is the parity test; the prose guardrail is this ADR plus the inline doc comment atpackages/ingestion/src/pipeline/phases/repo-node.ts:241-246.- SCIP boundary off-by-one bugs. SCIP is 0-indexed at the symbol
boundary, the OCH graph is 1-indexed at the file-line boundary
(
.erpaval/solutions/conventions/scip-0-indexed-vs-graph-1-indexed.md). TheRepoNodeitself does not carry line numbers, so this risk is indirect — but if a future edge kind (sayRepo CONTAINS_SYMBOL Symbol) is added in M7 without the boundary normalisation, it could driftgraphHashon every existing graph. The M7 ADR is the right place to encode that constraint. - Visibility default may leak data.
RepoNode.visibilitydefaults toprivate. The MCP gating layer atpackages/mcp/src/repo-resolver.tschecks this field before returning a repo inAMBIGUOUS_REPO.choices[]for a caller that has not authenticated to that visibility tier. If a future writer forgets to set the field, the default is the conservativeprivatevalue — failing closed rather than open. The runtime default is intentional defensive depth, not coincidence.
- Proposed: 2026-05-07 (M6 ADR commit).
- Accepted: on merge of
feat/v1-m5-m6→main. The status flips to Accepted in the same commit that ships AC-M6-5 (this ADR plus the AGENTS.md / CLAUDE.md cross-references plus the synthetic 2-repo quickcheck) — see §References below. - Superseded: no. The planned M7 follow-up (drop the legacy
repoargument, addRepo-rooted edge kinds) was not pursued — see §Edge kinds deferred → not pursued. TheRepoNodeshape this ADR introduced stands as-is in v1.
- Spec:
.erpaval/specs/005-m5-m6/spec.md§AC-M6-1 (RepoNode in graph), §AC-M6-2 (AMBIGUOUS_REPOchoices[]), §AC-M6-3 (reframed —group_cross_repo_linksMCP tool +.docmeta.jsonv2 schema), §AC-M6-4 (group_*tools emitrepo_uri), §AC-M6-5 (regression + this ADR), §S-M6-1 (local:<hash>fallback), §W-M6-1 (graphHash byte-identity). - Commits:
9ee6a96— feat(core-types): first-classRepoNodein graph (AC-M6-1).26e507b— feat(mcp): structured AMBIGUOUS_REPO withchoices[]repo_urialias (AC-M6-2).
f9fdde2— feat(mcp):group_*tools emitrepo_uriadditively (AC-M6-4).86e295b— feat(analysis):group_cross_repo_linksMCP tool + v2 docmeta spec (AC-M6-3 reframed).
- Code:
packages/core-types/src/nodes.ts:40,82,524-552,591—NodeKindunion,NODE_KINDSarray,RepoNodeinterface,GraphNodeunion extension.packages/ingestion/src/pipeline/phases/repo-node.ts— phase implementation (329 LOC),deriveRepoUriURL normaliser,deriveLocalRepoUriSHA-256 fallback,probeCommitTimegit HEAD reader.packages/storage/src/graphdb-schema.ts:101-176— LadybugDBCodeNodetable with the 9 RepoNode columns appended.packages/mcp/src/repo-resolver.ts—AMBIGUOUS_REPO.choices[]construction,repo_urialias resolution.packages/analysis/src/group/cross-repo-links.ts— pure helper that emits{source_repo_uri, target_repo_uri, source_doc_path, target_doc_path, relation}records (AC-M6-3 reframed).packages/mcp/src/tools/group-cross-repo-links.ts— the MCP surface for the helper.
- Tests:
packages/storage/src/graphdb-adapter.test.ts— graphHash parity on the round-trip through both backends (ADR 0011's W-M3-1 and this ADR's W-M6-1 share the same gate).packages/ingestion/src/pipeline/phases/repo-node.test.ts— git-probe injection covers HTTPS, SSH, no-remote, andlocal:fallback shapes.packages/analysis/src/group/cross-repo-links.test.ts— determinism + 5-tuple alpha-sort coverage.packages/analysis/src/group/cross-repo-links-quickcheck.test.ts— synthetic 2-repo populated-case fixture (this ADR's commit).
- Related ADRs:
- ADR 0001 — DuckDB backend;
RepoNodeadds 9 nullable columns to the polymorphicnodestable without a schema-version bump. - ADR 0011 — LadybugDB phase-1; this ADR's
RepoNodeadds the same 9 columns to the LadybugDBCodeNodetable behind the samekinddiscriminator. The W-M6-1 parity gate piggybacks on the W-M3-1 round-trip fixture coverage.
- ADR 0001 — DuckDB backend;
- Conventions:
.erpaval/solutions/conventions/scip-0-indexed-vs-graph-1-indexed.md— boundary normalisation rule. TheRepoNodeitself is line-number-free, but any future M7 edge kind that joinsRepoNodeto a symbol must respect this boundary.
The Sourcegraph-style host/path URI scheme is the de-facto cross-repo
handle in code-search literature; we adopt it because every Sourcegraph
client and every CodeHub-style federation tool already speaks it. The
local:<sha256(path)[:12]> fallback is OCH-original — Sourcegraph's
public surface has no equivalent, because Sourcegraph hosts are
remote-first. Our embedded-use posture (ADR 0001's self-hosted-OSS
rail) means many user repos have no remote, and the fallback has to
be deterministic without one.
The 9-attribute RepoNode shape is the union of Sourcegraph's repo
metadata fields and SCIP's Metadata.toolInfo shape. We chose to
synthesise both rather than pick one because the Sourcegraph fields
(URI, default branch, group) are the cross-repo handle, while the
SCIP fields (indexer name + version, language stats) are the
provenance trail — and OCH needs both to surface a coherent
AMBIGUOUS_REPO.choices[] payload AND a coherent .docmeta.json v2
cross-repo-links graph. Splitting them across two node kinds would
defeat the singleton-per-graph property.
The indexTime field is the one place this ADR diverges from both
Sourcegraph and SCIP. Sourcegraph stores indexedAt as a wall-clock
timestamp; SCIP does not record an index time at all (the SCIP
document is the source of truth). We chose git show -s --format=%cI HEAD for the third option: stable per commit, deterministic across
machines, and not subject to clock skew or wall-clock noise. The
fallback sentinel 1970-01-01T00:00:00Z is the documented signal
for "no git working tree" and never appears for a valid index.