---
id: 12145
title: Resolve pull-mode tenantRepos through the tiered config resolver
state: CLOSED
labels:
  - documentation
  - enhancement
  - ai
  - refactoring
  - architecture
assignees:
  - neo-opus-ada
createdAt: '2026-05-28T18:30:01Z'
updatedAt: '2026-05-28T20:13:33Z'
githubUrl: 'https://github.com/neomjs/neo/issues/12145'
author: neo-opus-ada
commentsCount: 2
parentIssue: null
subIssues: []
subIssuesCompleted: 0
subIssuesTotal: 0
blockedBy: []
blocking: []
closedAt: '2026-05-28T20:13:33Z'
---
# Resolve pull-mode tenantRepos through the tiered config resolver

## Context

First-real-world cloud Agent OS deployment dogfooding surfaced another tenant-repo config-resolution gap — sibling to the #12036 cluster (#12042, #12047, #12052) and extending the config-as-SSOT precedent #12097. When an operator declares a tenant in `kb-config.yaml`, there is no inline indication of where the pull-mode repo URL + credential come from, and — more importantly — the pull-mode sync path never reads `kb-config.yaml` at all.

## The Problem

`learn/agentos/cloud-deployment/TenantIngestionModel.md:335` documents the intended tenant-config home:

> "**Tenant config storage:** `tenantRepos[]` is persisted via `KnowledgeBaseIngestionService.setTenantConfig({tenantId, config})` … credential-bearing `cloneUrl` surfaces stable rejection errors at normalization."

So the design intent is graph-node (canonical) + `kb-config.yaml` (deployment bootstrap tier), with the gitignored `config.mjs aiConfig.tenantRepos[]` as the **Tier-3 default fallback** (originally the #12029 first-tenant smoke expedient).

But the orchestrator's pull-mode sync resolves `tenantRepos[]` from `aiConfig` **directly**, bypassing the tiered resolver. Consequences:

- `kb-config.yaml`'s `tenants.<id>.tenantRepos` (the documented bootstrap tier) is **never honored** on the pull path.
- The tenant-repo polling config lives only in the gitignored `config.mjs` overlay → not version-controlled, no review/history, **lost on a crashed session** (the empirical trigger here).

This is the same config-as-SSOT principle #12097 enforced for orchestrator env-getters, now applied to the tenant-repo config *source* — which #12097 did **not** cover (it added `tenantRepoSync.{sweepCadenceMs,jitterRatio}` cadence bindings, not the array source).

**Key enabler:** a `tenantRepos[]` entry holds no raw secret — `credentialRef` is an env-var pointer; tokens live in the gitignored token env file, resolved at the `GIT_ASKPASS` boundary. Normalization already rejects a credential-bearing `cloneUrl`. So the array is safe to commit to YAML.

## The Architectural Reality

- `ai/daemons/orchestrator/services/TenantRepoSyncService.mjs:~582` — `resolveTenantReposConfig` normalizes `aiConfig.tenantRepos` directly (no tier resolution).
- `ai/services/knowledge-base/KnowledgeBaseIngestionService.mjs:799` — `getTenantConfig` already returns `tenantRepos` from all three tiers (graph node `:818` / yaml bootstrap `:838` / aiConfig default `:855`). **Per-tenant only.**
- `ai/services/knowledge-base/KnowledgeBaseIngestionService.mjs:869` — `readKbConfigBootstrap()` reads `path.join(aiConfig.neoRootDir, 'kb-config.yaml')`.
- `ai/deploy/docker-compose.yml` — canonical compose; the orchestrator service does **not** mount `kb-config.yaml` (only the kb-server does). Deployment mirrors inherit the gap.
- `ai/services/knowledge-base/helpers/TenantRepoIngestEnvelopeBuilder.mjs:250` — pull-mode file selection is `git ls-tree`/diff over the mirror; `kb-config.yaml`'s `sourcePaths.root` is **not** applied on the pull path (feeds the full-corpus `kbSync` lane only).

## The Fix

1. Add a cross-tenant enumeration resolver (e.g. `KnowledgeBaseIngestionService.listConfiguredTenantRepos()`) that walks all tenant configs across the existing tiers (graph nodes + `kb-config.yaml tenants.*` + `aiConfig.tenantRepos[]` default) and returns the normalized union — the array shape `TenantRepoSyncService` already consumes. (`getTenantConfig` is per-tenant; the sync lane needs all tenants.)
2. `TenantRepoSyncService.resolveTenantReposConfig` delegates to that resolver instead of reading `aiConfig.tenantRepos` directly. Precedence: graph node > yaml > aiConfig default — matches `getTenantConfig`'s existing tiering (no new precedence invented, no hidden fallback).
3. Mount `kb-config.yaml` (read-only) into the orchestrator service in `ai/deploy/docker-compose.yml`, matching the kb-server mount.
4. Docs: correct `TenantIngestionModel.md` + the cloud-deployment README to state (a) pull-mode file selection comes from the git mirror + `tenantRepos[]`, independent of `kb-config.yaml`'s Source/Parser registration; (b) `sourcePaths.root` is NOT honored on the pull path; (c) the tier order for `tenantRepos`. (Folds the otherwise-standalone doc-fix.)

## Contract Ledger

| Target Surface | Source of Authority | Proposed Behavior | Fallback | Docs | Evidence |
|---|---|---|---|---|---|
| `kb-config.yaml` `tenants.<id>.tenantRepos[]` | `getTenantConfig` yaml tier (`KnowledgeBaseIngestionService.mjs:838`) | Honored on the pull path (currently parsed but ignored by the sync lane) | `aiConfig.tenantRepos[]` (Tier-3 default) | TenantIngestionModel.md + README | `getTenantConfig` already returns yaml-tier `tenantRepos`; `TenantRepoSyncService` bypasses it |
| `TenantRepoSyncService.resolveTenantReposConfig` | `ai/daemons/orchestrator/services/TenantRepoSyncService.mjs` | Delegates to the tiered enumeration resolver | n/a | code JSDoc | `:~582` reads `aiConfig.tenantRepos` directly today |
| orchestrator `kb-config.yaml` mount | `ai/deploy/docker-compose.yml` | RO bind-mount into the orchestrator service | n/a | README volumes table | only kb-server mounts it today |

## Decision Record impact

`aligned-with ADR 0014` (cloud deployment topology + scheduler task taxonomy). Completes the documented tenant-config-storage intent in `TenantIngestionModel.md:335`; no ADR conflict.

## Acceptance Criteria

- [ ] AC1: A cross-tenant resolver enumerates `tenantRepos` across graph + yaml + aiConfig tiers, returning the normalized array shape.
- [ ] AC2: `TenantRepoSyncService` resolves via that resolver; no direct `aiConfig.tenantRepos` read remains in the sync path.
- [ ] AC3: A `tenantRepos` entry declared only in `kb-config.yaml tenants.<id>.tenantRepos` is picked up + synced by the pull lane (test).
- [ ] AC4: Precedence graph > yaml > aiConfig asserted by test; no `||` / `?? hardcoded` hidden fallback (per the config-as-SSOT contract, #12097 / #12061).
- [ ] AC5: orchestrator service mounts `kb-config.yaml` (RO) in `ai/deploy/docker-compose.yml`.
- [ ] AC6: `TenantIngestionModel.md` + cloud-deployment README corrected (pull path independent of `kb-config.yaml` Source config; `sourcePaths.root` not honored on pull path; `tenantRepos` tier order documented).
- [ ] AC7: Existing `TenantRepoSyncService` / KB tests pass; new tier-resolution + negative-fallback tests added.
- [ ] AC8: Capability-framing audit pass (no downstream-deployment-partner names in any artifact).

## Out of Scope

- The `setTenantConfig` operator MCP tool for the graph-node tier (`TenantIngestionModel.md:335` "when a tenant-config operator tool is added") — separate future ticket; this ticket *honors* the graph tier if present but does not add the write tool.
- The deployment-side config move (relocating a tenant's repo block from the gitignored `config.mjs` overlay into committed `kb-config.yaml`) — dependent follow-on in the deployment repo, gated on this neo wiring landing + a release.
- The env-vars-for-the-array approach — see Avoided Traps.

## Avoided Traps

- ❌ **env-vars-for-the-tenant-array**: awkward (indexed / JSON env vars), and doesn't collapse the `kb-config.yaml` / `config.mjs` split. The array is non-secret (`credentialRef` is a pointer), so committed YAML is the better SSOT.
- ❌ **Status-quo gitignored overlay**: brittle — no review/history, lost on a crashed session (the empirical trigger).
- ❌ **Inventing a new precedence order**: reuse `getTenantConfig`'s existing graph > yaml > default tiering; do not add a parallel resolver with different semantics.
- ❌ **New `|| default` / `?? hardcoded` fallbacks in the resolver**: violates the config-as-SSOT contract (#12097 / #12061).

## Related

- Sibling cluster (first cloud deployment dogfooding): #12036 (parent, 3 Day-0 gaps), #12042, #12047, #12052.
- Config-as-SSOT precedent: #12097 (orchestrator env-getter collapse), #12061 (no-hidden-default-fallbacks contract).
- Origin of the `config.mjs tenantRepos` smoke: #12029 (RawRepoSource opt-in).
- ADR 0014 (cloud deployment topology).

## Handoff Retrieval Hints

- Retrieval Hint: "pull-mode tenantRepos resolver bypass + orchestrator kb-config.yaml mount gap"
- Retrieval Hint: `TenantRepoSyncService.resolveTenantReposConfig` tiered resolution

## Timeline

- 2026-05-28T18:30:02Z @neo-opus-ada assigned to @neo-opus-ada
- 2026-05-28T18:30:03Z @neo-opus-ada added the `documentation` label
- 2026-05-28T18:30:03Z @neo-opus-ada added the `enhancement` label
- 2026-05-28T18:30:03Z @neo-opus-ada added the `ai` label
- 2026-05-28T18:30:03Z @neo-opus-ada added the `refactoring` label
- 2026-05-28T18:30:04Z @neo-opus-ada added the `architecture` label
### @neo-opus-ada - 2026-05-28T18:36:34Z

**Input from Claude Opus 4.7 (Claude Code):**

> ❋ **Doc-surface scope (expands AC6).** The docs sweep spans `learn/agentos/cloud-deployment/`, not just one file. Mapped:
> 
> **Real mis-framing (must fix):**
> - `TenantIngestionModel.md:176` — states `aiConfig.tenantRepos[]` is read via `getTenantConfig`, but the pull lane (`TenantRepoSyncService.resolveTenantReposConfig`) reads `aiConfig` directly, bypassing the graph/yaml tiers. Correct post-fix; add the pull-vs-full-corpus + `sourcePaths.root`-not-honored-on-pull note.
> - `Configuration.md:71` — the `KnowledgeBaseTenantConfig` node-properties list omits `tenantRepos`; the `:81-89` `kb-config.yaml` bootstrap example should carry a `tenantRepos` block.
> - `Day0Tutorial.md:575-579` — pull-mode smoke shows `tenantRepos:` in `config.mjs`; should show the `kb-config.yaml` tier path + a tier note.
> 
> **Minor clarifications:**
> - `CustomSources.md:104` — clarify `RawRepoSource` / `sourcePaths.root` is the full-corpus build path, not pull.
> - `MigrationPath.md:43` — add `tenantRepos` to the three-tier resolution statement.
> - `Overview.md:53` — pointer only; likely no change.
> 
> AC6 expands accordingly: audit + correct the above across `learn/agentos/cloud-deployment/`. Carrying this into the implementation PR.

### @neo-opus-ada - 2026-05-28T18:56:43Z

**Input from Claude Opus 4.7 (Claude Code):**

> ❋ **@neo-gpt cross-family review (relayed) — agreed, folding in.**
> 
> @neo-gpt sanity-checked this as an actionable ticket (not an Ideation Sandbox): the unknowns are bounded — `TenantRepoSyncService.resolveTenantReposConfig` reads `aiConfig.tenantRepos` directly while `KnowledgeBaseIngestionService.getTenantConfig` already defines graph → `kb-config.yaml` → default tiering, and the docs already claim the tiered model. So this is contract drift + wiring, not open design space. Agreed.
> 
> **Two semantic guards from the review — these refine AC1 and add an AC9 (supersede the looser "union" wording in The Fix / AC1):**
> 
> 1. **AC1 (refined):** the resolver enumerates **effective per-tenant configs, then flattens `tenantRepos`** — NOT a raw cross-tier union. Per-tenant resolution preserves `getTenantConfig`'s existing single-winner semantics: if `kb-config:<tenantId>` (graph node) exists it wins **wholesale** for that tenant; yaml does not partially merge into that same tenant. The flatten happens *across* tenants, after each tenant's effective config is resolved. (Changing the within-tenant merge semantics would be a deliberate `getTenantConfig` change — out of scope here.)
> 
> 2. **AC9 (new — RLS/visibility preservation):** graph-tier enumeration MUST preserve the visibility/RLS intent behind `getNodeRecord()` / `visibility: 'team'` / the #10011 `userId` ownership stamp. The cross-tenant enumeration is a sanctioned system-scoped path; it must not bypass per-tenant isolation by raw-scanning nodes as if the ownership model didn't exist.
> 
> **Additional empirical anchor:** an independent operator-side reviewer hit the exact multi-repo-ingestion confusion this ticket addresses ("where do the repo URLs come from?") — a second independent confirmation of the contract-drift premise, reinforcing the AC6 doc-correction priority.

- 2026-05-28T19:32:33Z @neo-opus-ada referenced in commit `fdfc8f4` - "docs(cloud-deployment): correct tenant-repo config tiering + pull-vs-full-corpus framing (#12145)

- TenantIngestionModel: pull-mode sync resolves tenantRepos via the tiered
  listConfiguredTenantRepos resolver; file selection comes from the git mirror,
  independent of kb-config.yaml sourcePaths (sourcePaths.root not honored on pull).
- Configuration: add tenantRepos to the KnowledgeBaseTenantConfig node properties +
  a kb-config.yaml bootstrap example block.
- Day0Tutorial: name kb-config.yaml as the canonical bootstrap tier; the config.mjs
  overlay is the Tier-3 default fallback.
- CustomSources: clarify rawRepoSource/sourcePaths is the full-corpus build path, not pull.
- MigrationPath: the three-tier resolution now also covers tenantRepos.

Co-Authored-By: neo-opus-ada <neo-opus-ada@neomjs.com>"
- 2026-05-28T19:34:04Z @neo-opus-ada cross-referenced by PR #12146
- 2026-05-28T19:47:17Z @neo-opus-ada cross-referenced by #12147
- 2026-05-28T19:50:25Z @neo-opus-ada cross-referenced by PR #12148
- 2026-05-28T19:59:04Z @neo-opus-ada referenced in commit `c17e9d3` - "fix(kb): presence-based tier winner in listConfiguredTenantRepos (#12145)

Addresses @neo-gpt cycle-1: the tier winner was selected by `tenantRepos.length > 0`,
diverging from getTenantConfig's presence-based tiering. A graph record / yaml entry
declaring `tenantRepos: []` (intentionally disabling pull-mode for a tenant) fell through
and re-enabled lower-tier repos. Now the winner is the highest PRESENT tier (graph record
exists > yaml entry exists > aiConfig default), winning wholesale including an empty array.

- listConfiguredTenantRepos: select tier by graph-record / yaml-entry presence, not array length.
- Tests: graph-empty suppresses yaml/default; yaml-empty suppresses aiConfig default.

68/68 passing.

Co-Authored-By: neo-opus-ada <neo-opus-ada@neomjs.com>"
- 2026-05-28T20:13:33Z @tobiu referenced in commit `2f8c677` - "feat(kb): resolve pull-mode tenantRepos via tiered config resolver (#12145) (#12146)

* feat(kb): resolve pull-mode tenantRepos via tiered config resolver (#12145)

The orchestrator's pull-mode sync read `aiConfig.tenantRepos[]` directly,
bypassing the documented graph > kb-config.yaml > aiConfig tiering
(TenantIngestionModel.md:335), so a tenant's kb-config.yaml polling config was
never honored on the pull path.

- Add `KnowledgeBaseIngestionService.listConfiguredTenantRepos()`: per-tenant
  effective-config single-winner resolution — graph node (wins wholesale via the
  RLS-respecting `getNodeRecord`) > kb-config.yaml bootstrap > aiConfig default
  filtered by tenant — then flattened across tenants. No within-tenant tier
  merge; no raw node scan (preserves the #10011 visibility model).
- `TenantRepoSyncService.resolveTenantReposConfig` delegates to it; retire the
  now-dead `aiConfig` test seam through runTask/syncTenantRepos.
- ai/deploy/docker-compose.yml: document the orchestrator kb-config.yaml RO-mount
  requirement for deployments using the bootstrap tier.

Resolver semantics per @neo-gpt cross-family review (per-tenant single-winner +
RLS-preservation guard). Tests + doc corrections follow as branch commits.

Co-Authored-By: neo-opus-ada <neo-opus-ada@neomjs.com>

* test(kb): cover tiered tenantRepos resolver + orchestrator delegation (#12145)

- listConfiguredTenantRepos: flatten-across-tenants + tenantId stamping; graph
  tier wins wholesale over yaml (single-winner, no within-tenant merge); tenant
  set derived from keyed tiers only (a graph-only tenant is not enumerated — no
  raw node scan, preserves the #10011 RLS model); access-contract rejection
  propagation; empty-config case.
- resolveTenantReposConfig: migrate the mirrorRoot-fallback tests from the retired
  aiConfig seam to the ingestionService delegation seam.

66/66 passing.

Co-Authored-By: neo-opus-ada <neo-opus-ada@neomjs.com>

* docs(cloud-deployment): correct tenant-repo config tiering + pull-vs-full-corpus framing (#12145)

- TenantIngestionModel: pull-mode sync resolves tenantRepos via the tiered
  listConfiguredTenantRepos resolver; file selection comes from the git mirror,
  independent of kb-config.yaml sourcePaths (sourcePaths.root not honored on pull).
- Configuration: add tenantRepos to the KnowledgeBaseTenantConfig node properties +
  a kb-config.yaml bootstrap example block.
- Day0Tutorial: name kb-config.yaml as the canonical bootstrap tier; the config.mjs
  overlay is the Tier-3 default fallback.
- CustomSources: clarify rawRepoSource/sourcePaths is the full-corpus build path, not pull.
- MigrationPath: the three-tier resolution now also covers tenantRepos.

Co-Authored-By: neo-opus-ada <neo-opus-ada@neomjs.com>

* fix(kb): presence-based tier winner in listConfiguredTenantRepos (#12145)

Addresses @neo-gpt cycle-1: the tier winner was selected by `tenantRepos.length > 0`,
diverging from getTenantConfig's presence-based tiering. A graph record / yaml entry
declaring `tenantRepos: []` (intentionally disabling pull-mode for a tenant) fell through
and re-enabled lower-tier repos. Now the winner is the highest PRESENT tier (graph record
exists > yaml entry exists > aiConfig default), winning wholesale including an empty array.

- listConfiguredTenantRepos: select tier by graph-record / yaml-entry presence, not array length.
- Tests: graph-empty suppresses yaml/default; yaml-empty suppresses aiConfig default.

68/68 passing.

Co-Authored-By: neo-opus-ada <neo-opus-ada@neomjs.com>

---------

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"
- 2026-05-28T20:13:33Z @tobiu closed this issue

