---
id: 12040
title: 'Tenant-repo ingestion: add branchRef to tenantRepos[] schema'
state: CLOSED
labels:
  - enhancement
  - ai
  - architecture
assignees:
  - neo-opus-ada
createdAt: '2026-05-26T19:21:19Z'
updatedAt: '2026-05-26T20:46:33Z'
githubUrl: 'https://github.com/neomjs/neo/issues/12040'
author: neo-opus-ada
commentsCount: 0
parentIssue: null
subIssues: []
subIssuesCompleted: 0
subIssuesTotal: 0
blockedBy: []
blocking: []
closedAt: '2026-05-26T20:46:33Z'
---
# Tenant-repo ingestion: add branchRef to tenantRepos[] schema

## Context

Surfaced during the same first-real-world cloud-deployment exercise that produced [#12036](https://github.com/neomjs/neo/issues/12036) and [#12032](https://github.com/neomjs/neo/issues/12032). The tenant-repo polling path clones the mirror with `--mirror` (full ref set) but then resolves ingestion-target HEAD via `gitMirror.resolveHead({..., ref: 'HEAD'})` — which on a typical GitLab/GitHub repo resolves to the *default* branch only. There is no way to direct a tenant to ingest a non-default branch (e.g., a `dev` integration branch when the default is `main`).

## The Problem

For deployments where the canonical product-source-of-truth branch differs from the repo's default branch (e.g., trunk-based teams using `dev` as integration line + `main` as release-tag-only), the current tenant-ingestion path can only ingest the default branch. Empirically, in the R3 smoke, the operator's tenant repo had `main` (older legacy state) as default and `dev` as the active development line — ingestion picked up the stale `main` content.

The repo-side workaround is "change the default branch on the GitLab/GitHub remote" — but that's an upstream operational change with broader implications (CI, PR target branches, deploy hooks). The substrate-side fix is to let `tenantRepos[]` declare which branch each tenant ingests from.

## The Architectural Reality

- [`tenantRepos[]` entry shape](https://github.com/neomjs/neo/blob/dev/ai/services/knowledge-base/helpers/TenantRepoAccessContract.mjs#L194-L220) currently: `{tenantId, repoSlug, cloneUrl, credentialRef, mirrorRoot?, cadenceMs?}`. No `branchRef`.
- [`GitMirror.cloneIfMissing`](https://github.com/neomjs/neo/blob/dev/ai/services/knowledge-base/helpers/GitMirror.mjs#L381) uses `git clone --mirror <url> <path>` — fetches all refs (good — no branch-restriction at clone time).
- [`GitMirror.resolveHead`](https://github.com/neomjs/neo/blob/dev/ai/services/knowledge-base/helpers/GitMirror.mjs#L443) signature: `({mirrorRoot, tenantId, repoSlug, ref = 'HEAD'})`. The `ref` parameter exists but the upstream call chain in `TenantRepoIngestEnvelopeBuilder` always passes `'HEAD'` (or a sha) — no plumbing for a per-tenant branch override.
- [`TenantRepoIngestEnvelopeBuilder` `resolveRevision`](https://github.com/neomjs/neo/blob/dev/ai/services/knowledge-base/helpers/TenantRepoIngestEnvelopeBuilder.mjs#L114-L120) is the chokepoint where `ref` flows into `gitMirror.resolveHead`. New branchRef from tenant config would land here.

## The Fix

Schema + plumbing:

1. **Schema**: add optional `branchRef` field to tenant-repo entry. `normalizeTenantRepoEntry` ([`TenantRepoAccessContract.mjs:195-218`](https://github.com/neomjs/neo/blob/dev/ai/services/knowledge-base/helpers/TenantRepoAccessContract.mjs#L195-L218)) preserves via `...entry` spread already; just declare in JSDoc + validate (must be string, non-empty if present).
2. **Plumbing**: in `TenantRepoSyncService` ([line 419-434](https://github.com/neomjs/neo/blob/dev/ai/daemons/orchestrator/services/TenantRepoSyncService.mjs#L419-L434)), pass `branchRef: repo.branchRef || 'HEAD'` through to `envelopeBuilder`. The envelope builder then passes it to `gitMirror.resolveHead({..., ref: branchRef})`.
3. **Default**: if absent, continue to use `'HEAD'` (default branch). Backward-compatible.

## Contract Ledger

| Target Surface | Source of Authority | Proposed Behavior | Fallback | Docs | Evidence |
| --- | --- | --- | --- | --- | --- |
| `tenantRepos[].branchRef` (new optional string field) | `ai/services/knowledge-base/helpers/TenantRepoAccessContract.mjs` `normalizeTenantRepoEntry` | Resolved ref name (e.g., `'dev'`) passed downstream to `gitMirror.resolveHead({ref: branchRef})` | Absent → `'HEAD'` (default branch) | Cloud-deployment guide tenant-repo config section | Unit test for normalize-with-branchRef + integration test ingesting non-default branch |

## Decision Record impact

`aligned-with ADR 0014` — extends tenant-ingestion schema within existing topology.

## Acceptance Criteria

- [ ] `normalizeTenantRepoEntry` accepts optional `branchRef` (string, non-empty if present) and preserves it on the normalized entry.
- [ ] `TenantRepoSyncService` passes `branchRef` (default `'HEAD'`) through to the envelope builder.
- [ ] `TenantRepoIngestEnvelopeBuilder` uses the passed `branchRef` when resolving `newHead` revision instead of hardcoded `'HEAD'`.
- [ ] When `branchRef` is omitted, behavior is identical to current (`'HEAD'` = default branch).
- [ ] Invalid `branchRef` (non-string, empty) errors at config-load with `KB_TENANT_REPO_ENTRY_INVALID`.
- [ ] Unit test: tenant entry with `branchRef: 'dev'` resolves to the `dev` branch's HEAD.
- [ ] Cloud-deployment guide updated to document the new field with a worked example.

## Out of Scope

- Multi-branch ingestion per tenant — single-branch-per-tenant covers the immediate need. If a future deployment needs to ingest multiple branches from one repo, that's a separate ticket (likely `branchRefs: string[]` extension).
- Tag/sha-pinned ingestion — `branchRef` accepts any git ref (branch, tag, sha) since it flows through `git rev-parse`. Document with examples.
- Branch-deletion handling — if the configured `branchRef` is later deleted on the remote, the next fetch surfaces an error; downstream consumers handle via existing `KB_GITMIRROR_REF_NOT_FOUND` path.

## Avoided Traps

- **Coupling to `--single-branch` clone**: the current `git clone --mirror` fetches all refs. Sticking with mirror clone + per-ingest `ref` selection means tenant operators can switch `branchRef` without re-cloning.
- **Hardcoding the default to `dev`**: keeping the default as `'HEAD'` (whatever the remote's default-branch is) preserves backward compat and matches operator expectation that omitted config = "do what git defaults do."

## Related

- Sibling: #12036 (3 substrate gaps from R3), #12032 (credentialRef `file:` scheme)
- Parent epic: #11731 (Server-side tenant-repo ingestion)
- Empirical anchor: first cloud-deployment tenant-ingestion attempt, 2026-05-26 — ingested default-branch content when operator's active integration line was a different branch

## Timeline

- 2026-05-26T19:21:21Z @neo-opus-ada added the `enhancement` label
- 2026-05-26T19:21:21Z @neo-opus-ada added the `ai` label
- 2026-05-26T19:21:21Z @neo-opus-ada added the `architecture` label
- 2026-05-26T19:31:25Z @neo-opus-ada cross-referenced by PR #12041
- 2026-05-26T19:42:12Z @neo-opus-ada assigned to @neo-opus-ada
- 2026-05-26T19:44:36Z @neo-opus-ada cross-referenced by PR #12043
- 2026-05-26T19:48:04Z @neo-opus-ada cross-referenced by PR #12044
- 2026-05-26T19:51:45Z @neo-opus-ada cross-referenced by PR #12045
- 2026-05-26T19:53:07Z @neo-gpt cross-referenced by PR #12046
- 2026-05-26T20:39:59Z @neo-opus-ada referenced in commit `0c2216e` - "feat(kb): add branchRef to tenantRepos[] schema (#12040)

Adds optional `branchRef` field to tenant-repo config entries. When
omitted, the downstream envelope builder uses its existing
'HEAD' default (= remote default branch) — backward-compatible.
When set, the value flows through TenantRepoSyncService -> envelope
builder's `newHead` parameter -> gitMirror.resolveHead({ref: branchRef}).

Empirical anchor: first cloud tenant-ingestion smoke ingested
default-branch content when the operator's active integration line
was a different branch; no substrate-side knob to redirect.

Schema validation rejects non-string and empty/whitespace-only values
with KB_TENANT_REPO_ENTRY_INVALID; valid branchRef values (any git
ref name — branch, tag, sha) pass through via `...entry` spread and
plumb cleanly to `resolveRevision` for the head boundary.

Tests:
- TenantRepoAccessContract.spec.mjs: positive (preserved when valid) +
  negative (empty/whitespace/non-string/null)
- TenantRepoSyncService.spec.mjs: plumbing assertion — entries with
  branchRef route to envelopeBuilder.args.newHead; entries without
  default to 'HEAD'

Docs: TenantIngestionModel.md — added branchRef to the config schema
example with use-case rationale (trunk-based teams using `dev` as
integration line + `main` as release-tag-only)."
- 2026-05-26T20:46:33Z @tobiu referenced in commit `9e6ef1a` - "feat(kb): add branchRef to tenantRepos[] schema (#12040) (#12045)

Adds optional `branchRef` field to tenant-repo config entries. When
omitted, the downstream envelope builder uses its existing
'HEAD' default (= remote default branch) — backward-compatible.
When set, the value flows through TenantRepoSyncService -> envelope
builder's `newHead` parameter -> gitMirror.resolveHead({ref: branchRef}).

Empirical anchor: first cloud tenant-ingestion smoke ingested
default-branch content when the operator's active integration line
was a different branch; no substrate-side knob to redirect.

Schema validation rejects non-string and empty/whitespace-only values
with KB_TENANT_REPO_ENTRY_INVALID; valid branchRef values (any git
ref name — branch, tag, sha) pass through via `...entry` spread and
plumb cleanly to `resolveRevision` for the head boundary.

Tests:
- TenantRepoAccessContract.spec.mjs: positive (preserved when valid) +
  negative (empty/whitespace/non-string/null)
- TenantRepoSyncService.spec.mjs: plumbing assertion — entries with
  branchRef route to envelopeBuilder.args.newHead; entries without
  default to 'HEAD'

Docs: TenantIngestionModel.md — added branchRef to the config schema
example with use-case rationale (trunk-based teams using `dev` as
integration line + `main` as release-tag-only).

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"
- 2026-05-26T20:46:33Z @tobiu closed this issue
- 2026-05-26T21:06:13Z @neo-opus-ada cross-referenced by PR #12050

