Context
Surfaced during the same first-real-world cloud-deployment exercise that produced #12036 and #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 currently: {tenantId, repoSlug, cloneUrl, credentialRef, mirrorRoot?, cadenceMs?}. No branchRef.
GitMirror.cloneIfMissing uses git clone --mirror <url> <path> — fetches all refs (good — no branch-restriction at clone time).
GitMirror.resolveHead 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 is the chokepoint where ref flows into gitMirror.resolveHead. New branchRef from tenant config would land here.
The Fix
Schema + plumbing:
- Schema: add optional
branchRef field to tenant-repo entry. normalizeTenantRepoEntry (TenantRepoAccessContract.mjs:195-218) preserves via ...entry spread already; just declare in JSDoc + validate (must be string, non-empty if present).
- Plumbing: in
TenantRepoSyncService (line 419-434), pass branchRef: repo.branchRef || 'HEAD' through to envelopeBuilder. The envelope builder then passes it to gitMirror.resolveHead({..., ref: branchRef}).
- 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
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
Context
Surfaced during the same first-real-world cloud-deployment exercise that produced #12036 and #12032. The tenant-repo polling path clones the mirror with
--mirror(full ref set) but then resolves ingestion-target HEAD viagitMirror.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., adevintegration branch when the default ismain).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
devas integration line +mainas release-tag-only), the current tenant-ingestion path can only ingest the default branch. Empirically, in the R3 smoke, the operator's tenant repo hadmain(older legacy state) as default anddevas the active development line — ingestion picked up the stalemaincontent.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 currently:{tenantId, repoSlug, cloneUrl, credentialRef, mirrorRoot?, cadenceMs?}. NobranchRef.GitMirror.cloneIfMissingusesgit clone --mirror <url> <path>— fetches all refs (good — no branch-restriction at clone time).GitMirror.resolveHeadsignature:({mirrorRoot, tenantId, repoSlug, ref = 'HEAD'}). Therefparameter exists but the upstream call chain inTenantRepoIngestEnvelopeBuilderalways passes'HEAD'(or a sha) — no plumbing for a per-tenant branch override.TenantRepoIngestEnvelopeBuilderresolveRevisionis the chokepoint whererefflows intogitMirror.resolveHead. New branchRef from tenant config would land here.The Fix
Schema + plumbing:
branchReffield to tenant-repo entry.normalizeTenantRepoEntry(TenantRepoAccessContract.mjs:195-218) preserves via...entryspread already; just declare in JSDoc + validate (must be string, non-empty if present).TenantRepoSyncService(line 419-434), passbranchRef: repo.branchRef || 'HEAD'through toenvelopeBuilder. The envelope builder then passes it togitMirror.resolveHead({..., ref: branchRef}).'HEAD'(default branch). Backward-compatible.Contract Ledger
tenantRepos[].branchRef(new optional string field)ai/services/knowledge-base/helpers/TenantRepoAccessContract.mjsnormalizeTenantRepoEntry'dev') passed downstream togitMirror.resolveHead({ref: branchRef})'HEAD'(default branch)Decision Record impact
aligned-with ADR 0014— extends tenant-ingestion schema within existing topology.Acceptance Criteria
normalizeTenantRepoEntryaccepts optionalbranchRef(string, non-empty if present) and preserves it on the normalized entry.TenantRepoSyncServicepassesbranchRef(default'HEAD') through to the envelope builder.TenantRepoIngestEnvelopeBuilderuses the passedbranchRefwhen resolvingnewHeadrevision instead of hardcoded'HEAD'.branchRefis omitted, behavior is identical to current ('HEAD'= default branch).branchRef(non-string, empty) errors at config-load withKB_TENANT_REPO_ENTRY_INVALID.branchRef: 'dev'resolves to thedevbranch's HEAD.Out of Scope
branchRefs: string[]extension).branchRefaccepts any git ref (branch, tag, sha) since it flows throughgit rev-parse. Document with examples.branchRefis later deleted on the remote, the next fetch surfaces an error; downstream consumers handle via existingKB_GITMIRROR_REF_NOT_FOUNDpath.Avoided Traps
--single-branchclone: the currentgit clone --mirrorfetches all refs. Sticking with mirror clone + per-ingestrefselection means tenant operators can switchbranchRefwithout re-cloning.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
file:scheme)