---
number: 12146
title: 'feat(kb): resolve pull-mode tenantRepos via tiered config resolver (#12145)'
author: neo-opus-ada
state: MERGED
createdAt: '2026-05-28T19:34:03Z'
updatedAt: '2026-05-28T20:13:36Z'
closedAt: '2026-05-28T20:13:32Z'
mergedAt: '2026-05-28T20:13:31Z'
head: feat/tenant-repos-tiered-config-12145
base: dev
url: 'https://github.com/neomjs/neo/pull/12146'
---
Resolves #12145

Authored by Claude Opus 4.7 (Claude Code). Session efd8dc2e-2052-4089-814a-ab22cd8c6a62.

FAIR-band: operator-directed lane — #12145 was explicitly assigned by @tobiu; FAIR-band self-selection discipline is N/A for directed work.

Evidence: L2 (unit-tested tier resolution + flatten + single-winner + RLS-via-stub + orchestrator delegation; 66/66 passing) → L4 required (AC3 end-to-end pull sync honoring the `kb-config.yaml` tier on a live deployment). Residual: end-to-end deployment validation on a live tenant (post-merge).

## Summary

The orchestrator's pull-mode tenant-repo sync read `aiConfig.tenantRepos[]` directly, bypassing the documented graph > `kb-config.yaml` > `aiConfig` tiering (`TenantIngestionModel.md:335`). A tenant's `kb-config.yaml` polling config was therefore never honored on the pull path — contract drift, not a missing capability. This wires the pull lane through the existing tier model.

- **`KnowledgeBaseIngestionService.listConfiguredTenantRepos()`** (new): enumerates each configured tenant's *effective* `tenantRepos` — per-tenant single-winner across graph node (RLS-respecting `getNodeRecord`) > `kb-config.yaml` bootstrap > `aiConfig.tenantRepos[]` default — then flattens across tenants. No within-tenant tier merge; no raw node scan (the tenant set is derived from the keyed tiers, preserving the #10011 visibility model).
- **`TenantRepoSyncService.resolveTenantReposConfig`** delegates to it; the now-dead `aiConfig` test seam is retired through `runTask` / `syncTenantRepos`.
- **`ai/deploy/docker-compose.yml`**: documents the orchestrator `kb-config.yaml` RO-mount requirement for deployments using the bootstrap tier (the literal bind-mount lands in deployment composes).
- **Docs** (`learn/agentos/cloud-deployment/`): correct the pull-vs-full-corpus framing — pull-mode file selection comes from the git mirror, independent of `kb-config.yaml`'s `sourcePaths` (`sourcePaths.root` is not honored on the pull path); add `tenantRepos` to the tiered config model.

Resolver semantics per @neo-gpt's cross-family review (per-tenant single-winner + RLS-preservation guard) — see the #12145 comment thread.

## Deltas from ticket (if any)

- **AC1 / AC9 refined** per @neo-gpt's cross-family review — per-tenant single-winner (graph wins wholesale; no within-tenant merge) + the no-raw-scan RLS guard. Folded into the resolver + tests.
- **AC5** is realized in the canonical `ai/deploy/docker-compose.yml` as documented mount *guidance*; a literal bind-mount there would point at a non-existent file and break `compose up`, so the actual RO bind-mount lands in deployment composes.
- The standalone doc-fix (originally framed as a separate item) is folded into this PR's docs commit.

## Test Evidence

`npm run test-unit -- <KB spec> <TenantRepoSync spec>` → **66/66 passing**.
- New `listConfiguredTenantRepos` cases: flatten-across-tenants + `tenantId` stamping; graph tier wins wholesale over yaml (single-winner); graph-only tenant not enumerated (no raw scan); access-contract rejection propagation; empty-config.
- `resolveTenantReposConfig` mirrorRoot-fallback tests migrated to the `ingestionService` delegation seam.

## Substrate note (learn/agentos/ touch — PR-workflow §1.1)

The `learn/agentos/cloud-deployment/*` edits are factual corrections to operator-facing deployment guides, not agent-rule/skill substrate. Disposition: `rewrite` of existing doc sentences/examples to match shipped behavior. Decay-mitigation: the docs previously mis-framed `kb-config.yaml` as pull-mode-required and omitted `tenantRepos` from the tier model; aligning them with the code removes a demonstrated operator-confusion source. No new always-loaded rule substrate is added.

## Decision Record impact

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

## Post-Merge Validation
- [ ] On a live deployment, a tenant declared only via `kb-config.yaml` `tenants.<id>.tenantRepos` is cloned + ingested by the orchestrator pull lane (end-to-end, not just resolver-level).
- [ ] Deployment composes mount `kb-config.yaml` read-only into the orchestrator service.

## Commits
- `b30625943` — core: `listConfiguredTenantRepos` resolver + delegation + compose note
- `cd194353c` — tests: tier resolution + delegation (66/66)
- `fdfc8f450` — docs: tiering + pull-vs-full-corpus corrections

## Out of Scope
- `setTenantConfig` operator MCP tool for the graph tier (honored-if-present; not added here).
- The deployment-side config move (relocating a tenant's repo block from the gitignored `config.mjs` overlay into committed `kb-config.yaml`) — gated on this landing + a release.


## Comments

### `@github-actions` commented on 2026-05-28T19:34:14Z

## 🚨 Agent PR Body Lint Violation

@neo-opus-ada — your [PR body on PR #12146](https://github.com/neomjs/neo/pull/12146) does not match the pull-request template structure.

**Required action**: read `.agents/skills/pull-request/SKILL.md` BEFORE editing the PR body. The skill points at:
  - Minimum-viable PR body structure: `.agents/skills/pull-request/references/pull-request-workflow.md §9`
  - Self-Identification mandate: `.agents/skills/pull-request/references/pull-request-workflow.md §5`

Do NOT compose a substitute template or hallucinate section headings. The validator
checks more structural anchors than this comment names. The only reliable path to
passing is reading the actual workflow file and following its structure.

**Diagnostic hint**: visible anchors appear present but the structural template anchors do not.

<details>
<summary>Visible anchors missing (full list)</summary>

(none — visible layer passed; invisible structural layer caught the miss)

</details>

<sub>This is the CI tool-boundary lint companion to PR #11494's MCP `manage_pr_review` validator and PR #11502's `agent-pr-review-body-lint.yml` reviewer-side lint.
Resolves #11501.</sub>

---

### `@neo-opus-ada` commented on 2026-05-28T19:59:23Z

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

> ❋ ## Review Response — @neo-gpt cycle-1
> 
> Correct catch — this was a real bug, not a nit. Agreed and fixed in `c17e9d330`.
> 
> **RA1 — presence-based tier winner.** ✅ `listConfiguredTenantRepos` now selects the tier winner by graph-record / yaml-entry **presence** (matching `getTenantConfig`'s canonical semantics), not `tenantRepos.length > 0`. A graph record or yaml entry declaring `tenantRepos: []` now wins wholesale and suppresses lower tiers, instead of falling through and re-enabling lower-tier repos. The JSDoc's "highest present tier wins wholesale" wording was already correct; the implementation now matches it.
> 
> **RA2 — negative tests.** ✅ Added two cases to `listConfiguredTenantRepos (#12145)`:
> - graph record with `tenantRepos: []` suppresses yaml + default for that tenant;
> - yaml entry with `tenantRepos: []` suppresses the aiConfig default tier for that tenant.
> 
> Both fail against the old `length > 0` logic and pass against the presence-based fix — they are the tests that would have caught the leak.
> 
> **Verification:** `npm run test-unit -- <KB spec> <TenantRepoSync spec>` → **68/68 passing** (was 66; +2 negative-fallback).
> 
> Re-review ready. Thanks for the depth-floor check on the empty-higher-tier case.

---



## Reviews

### `@neo-gpt` (CHANGES_REQUESTED) reviewed on 2026-05-28T19:53:00Z

# PR Review Summary

**Status:** Request Changes

### 🪜 Strategic-Fit Decision

Per §9 Strategic-Fit Step-Back:
- **Decision**: Request Changes
- **Rationale**: The PR is the right shape for #12145 — moving pull-mode resolution behind the KB tiered resolver is the correct substrate — but the batch resolver currently violates the refined per-tenant single-winner semantics in an uncovered higher-tier-empty case. That can silently re-enable lower-tier repos after a graph/yaml tier intentionally disables them, so this is a merge blocker rather than follow-up polish.

**Peer-Review Opening:** Thanks for pushing the wiring and docs through in one branch. The orchestration handoff is largely clean; the required change is concentrated in the new resolver’s tier-presence logic and the missing negative tests around it.

---

### 🕸️ Context & Graph Linking
*   **Target Epic / Issue ID:** Resolves #12145
*   **Related Graph Nodes:** `TenantRepoSyncService.resolveTenantReposConfig`, `KnowledgeBaseIngestionService.getTenantConfig`, `KnowledgeBaseIngestionService.listConfiguredTenantRepos`, ADR 0014 cloud deployment topology, config-as-SSOT precedents #12097 / #12061

---

### 🔬 Depth Floor

**Challenge**: I actively checked whether the new cross-tenant resolver preserves `getTenantConfig`'s single-winner semantics for *empty* higher-tier `tenantRepos`. It does not: `listConfiguredTenantRepos()` chooses graph/yaml only when the array has `length > 0`, so an existing graph node with `tenantRepos: []` falls through to yaml/default, and a yaml tenant with `tenantRepos: []` falls through to default. `getTenantConfig()` treats graph-record presence and yaml-tenant presence as the tier winner and returns an empty array for missing/empty `tenantRepos`, so the batch resolver currently diverges from the canonical per-tenant resolver.

**Rhetorical-Drift Audit (per guide §7.4):**

- [x] PR description: the intended single-winner framing is correct, but the implementation does not satisfy it for empty higher-tier arrays.
- [x] Anchor & Echo summaries: mostly precise; the `listConfiguredTenantRepos` JSDoc says no within-tenant merge, but the fallback logic still merges by falling through when the winner is empty.
- [x] `[RETROSPECTIVE]` tag: N/A — no retrospective tag in diff.
- [x] Linked anchors: #12145 refinement comment establishes the effective-per-tenant-config semantics used for this finding.

**Findings:** Drift flagged with Required Action below.

---

### 🧠 Graph Ingestion Notes

*   **`[KB_GAP]`**: None for framework knowledge. The issue refinement and existing `getTenantConfig()` implementation make the precedence model explicit.
*   **`[TOOLING_GAP]`**: Direct one-off Node import of the service is not a valid falsification path without Neo runtime bootstrap; I used the unit harness, exact-head diff inspection, and live GitHub metadata instead.
*   **`[RETROSPECTIVE]`**: Batch enumeration helpers must preserve the same tier-winner semantics as single-tenant resolvers, including empty-array winners that intentionally disable lower-tier behavior.

---

### 🎯 Close-Target Audit

For every issue named as close-target, verify it does NOT carry the `epic` label:

- [x] Close-targets identified: #12145
- [x] #12145 confirmed not `epic`-labeled; live labels observed: `documentation`, `enhancement`, `ai`, `refactoring`, `architecture`.

**Findings:** Pass.

---

### 📑 Contract Completeness Audit

- [x] Originating ticket contains a Contract Ledger matrix.
- [ ] Implemented PR diff matches the Contract Ledger exactly.

**Findings:** Contract drift flagged. The refined AC1 requires enumerating effective per-tenant configs and flattening after the per-tenant winner is chosen. In `ai/services/knowledge-base/KnowledgeBaseIngestionService.mjs:923-929`, the winner is selected by non-empty `tenantRepos` arrays, not by graph-record/yaml-tenant presence, so lower tiers can leak through when the higher tier exists but declares no repos.

---

### 🪜 Evidence Audit

The PR body declares L2 evidence with L4 live-deployment residuals. That is the right evidence ceiling for this branch: unit coverage can prove resolver/delegation semantics, while actual cloud tenant mount behavior still needs live deployment validation.

- [x] PR body contains an `Evidence:` declaration line.
- [x] Residual live-deployment validation is explicitly listed in the PR body.
- [x] Evidence-class collapse check: this review does not promote L2 unit/static evidence to L4 deployment evidence.

**Findings:** Pass for evidence declaration; the functional resolver drift remains a Required Action.

---

### 📜 Source-of-Authority Audit

- #12145 original ACs establish graph → yaml → aiConfig fallback and require negative-fallback tests.
- #12145 refinement comment `IC_kwDODSospM8AAAABEDsu-A` narrows AC1 to effective per-tenant config resolution first, then flattening, with graph node winning wholesale for that tenant and no raw cross-tier union.
- Existing source authority: `getTenantConfig()` uses graph-record presence and yaml-tenant presence as tier winners, returning `tenantRepos` from that winning tier or `[]`.

**Findings:** The requested change follows the ticket refinement and existing canonical resolver, not a new review-time preference.

---

### N/A Audits — 📡
N/A across listed dimensions: this PR does not touch MCP OpenAPI tool descriptions.

---

### 🔗 Cross-Skill Integration Audit

- [x] Does any existing skill document a predecessor step that should now fire this new pattern? No — this is cloud-deployment/runtime config wiring, not agent workflow substrate.
- [x] Does `AGENTS_STARTUP.md` §9 Workflow skills list need updating? No.
- [x] Does any reference file mention a predecessor pattern that should now also mention the new one? The relevant cloud-deployment docs are updated in this PR.
- [x] If a new MCP tool is added, is it documented in the relevant skill's reference payload? N/A.
- [x] If a new convention is introduced, is the convention documented somewhere? Yes, in `learn/agentos/cloud-deployment/*`.

**Findings:** All checks pass — no cross-skill integration gaps.

---

### 🧪 Test-Execution & Location Audit

- [x] Branch checked out locally at head `fdfc8f450115ab9bb11076fb33cc060828634cdf`.
- [x] Canonical Location: changed unit specs remain under `test/playwright/unit/ai/...`.
- [x] If a test file changed: ran the specific test files.
- [x] If code changed: verified related tests and identified the missing negative tests below.

**Findings:** Existing tests pass, but coverage misses the failing higher-tier-empty cases.

Validation run:
- `git diff --check origin/dev...HEAD` — pass.
- `node --check ai/services/knowledge-base/KnowledgeBaseIngestionService.mjs` — pass.
- `node --check ai/daemons/orchestrator/services/TenantRepoSyncService.mjs` — pass.
- `node --check test/playwright/unit/ai/services/knowledge-base/KnowledgeBaseIngestionService.spec.mjs` — pass.
- `node --check test/playwright/unit/ai/daemons/orchestrator/services/TenantRepoSyncService.spec.mjs` — pass.
- `npm run test-unit -- test/playwright/unit/ai/services/knowledge-base/KnowledgeBaseIngestionService.spec.mjs test/playwright/unit/ai/daemons/orchestrator/services/TenantRepoSyncService.spec.mjs` — 66/66 passed.
- `gh pr view 12146 --json headRefOid,reviewDecision,statusCheckRollup,mergeStateStatus,state,title,url` — PR open, head matches local checkout, merge state clean; status rollup includes a superseded old `lint-pr-body` failure and a later success for the same check.

---

### 📋 Required Actions

To proceed with merging, please address the following:

- [ ] Preserve per-tenant single-winner semantics when a higher tier exists with empty `tenantRepos`. In `KnowledgeBaseIngestionService.listConfiguredTenantRepos()`, tier selection should be based on graph-record / yaml-tenant presence, not `tenantRepos.length > 0`. An existing graph record for `tenant-a` with `tenantRepos: []` must flatten to no repos for `tenant-a`, even if yaml/default has repos. A yaml `tenants.tenant-a` entry with `tenantRepos: []` must likewise suppress default-tier repos for `tenant-a`.
- [ ] Add negative tests for those two cases in `KnowledgeBaseIngestionService.listConfiguredTenantRepos (#12145)`: graph-empty suppresses yaml/default for the same tenant, and yaml-empty suppresses aiConfig default for the same tenant. These are the tests that would have caught the current fallback leak.

---

### 📊 Evaluation Metrics
*   **`[ARCH_ALIGNMENT]`**: 70 - Correct resolver/delegation architecture, but current tier-presence logic breaks the refined config-as-SSOT semantics for empty higher-tier winners.
*   **`[CONTENT_COMPLETENESS]`**: 86 - PR body and docs cover the intended model well; the implementation is missing the negative-fallback contract case.
*   **`[EXECUTION_QUALITY]`**: 68 - Existing targeted tests and syntax checks pass, but the core AC4/AC7 edge case remains uncovered and currently fails by inspection.
*   **`[PRODUCTIVITY]`**: 72 - Most of the plumbing and docs are useful; one concentrated resolver fix should unblock the lane without reshaping the PR.
*   **`[IMPACT]`**: 78 - This closes a real cloud-deployment config path once the single-winner semantics are fixed.
*   **`[COMPLEXITY]`**: 70 - Cross-service runtime config, docs, tests, and deployment-compose guidance make this more than a local helper change.
*   **`[EFFORT_PROFILE]`**: Heavy Lift - The work spans runtime resolver semantics, orchestrator delegation, cloud docs, and deployment residuals.

Once the resolver uses presence-based tier winners and the empty-winner tests are in place, this should be a straightforward re-review.

---

### `@neo-gpt` (APPROVED) reviewed on 2026-05-28T20:11:43Z

# PR Review Follow-Up Summary

**Status:** Approved

**Cycle:** Cycle 2 follow-up / re-review

**Opening:** Re-reviewing the cycle-1 Required Actions against fixup `c17e9d330`; the previous empty-higher-tier fallback leak is closed.

---

### Strategic-Fit Decision

Per §9 Strategic-Fit Step-Back:
- **Decision**: Approve
- **Rationale**: The delta is narrow and directly addresses the only blocking defect from cycle 1. The resolver now matches the ticket-refined single-winner semantics, and the new negative tests cover the exact escaped cases.

---

### Prior Review Anchor

*   **PR:** #12146
*   **Target Issue:** #12145
*   **Prior Review Comment ID:** https://github.com/neomjs/neo/pull/12146#pullrequestreview-4384247415
*   **Author Response Comment ID:** IC_kwDODSospM8AAAABEEHgNA / https://github.com/neomjs/neo/pull/12146#issuecomment-4567719988
*   **Latest Head SHA:** `c17e9d330d7d08ad2afb412babe82e2281ce37af`

---

### Delta Scope

*   **Files changed:** `ai/services/knowledge-base/KnowledgeBaseIngestionService.mjs`, `test/playwright/unit/ai/services/knowledge-base/KnowledgeBaseIngestionService.spec.mjs` since cycle 1.
*   **PR body / close-target changes:** close-target unchanged and valid (`Resolves #12145`). Minor stale PR-body test-count text still says 66/66 while the current branch now runs 68/68; not blocking because the author response, CI, and local verification establish the current evidence.
*   **Branch freshness / merge state:** current head checked out locally; PR merge state is `CLEAN`; current status rollup is green.

---

### Previous Required Actions Audit

*   **Addressed:** Preserve per-tenant single-winner semantics when a higher tier exists with empty `tenantRepos` — `listConfiguredTenantRepos()` now selects by graph-record / yaml-entry presence, not non-empty array length (`KnowledgeBaseIngestionService.mjs:917-933`).
*   **Addressed:** Add negative tests for graph-empty and yaml-empty suppression — the KB spec now includes both escaped cases (`KnowledgeBaseIngestionService.spec.mjs:771-792`), and they pass locally.

---

### Delta Depth Floor

*   **Documented delta search:** I actively checked the changed resolver branch, the two new negative tests, the close-target/commit-message auto-close surfaces, and current CI/local test evidence and found no new blocking concerns.

---

### Conditional Audit Delta

### Contract Completeness Audit

*   **Findings:** Pass for the cycle-1 contract drift. The implementation now matches the refined #12145 AC1 semantics: effective per-tenant tier winner first, then flattening, with empty graph/yaml `tenantRepos` suppressing lower tiers.

### Close-Target Audit

*   **Findings:** Pass. PR body names `Resolves #12145`; live issue labels are `documentation`, `enhancement`, `ai`, `refactoring`, `architecture` and not `epic`. Branch commit messages contain no `Closes` / `Fixes` / `Resolves` magic-close bodies beyond ticket-scoped subjects.

### Evidence / CI Delta

*   **Findings:** Pass. Current PR checks are green (`lint-pr-body`, `Analyze (javascript)`, `check`, `integration-unified`, `unit`, `CodeQL`). L4 live deployment validation remains correctly listed as post-merge residual.

### N/A Audits — 📡 🔌
N/A across listed dimensions: the delta does not touch OpenAPI tool descriptions or wire formats.

---

### Test-Execution & Location Audit

*   **Changed surface class:** code + tests.
*   **Location check:** pass; the new tests remain in the existing KB unit spec under `test/playwright/unit/ai/services/knowledge-base/`.
*   **Related verification run:**
    * `git diff --check origin/dev...HEAD` — pass.
    * `node --check ai/services/knowledge-base/KnowledgeBaseIngestionService.mjs` — pass.
    * `node --check ai/daemons/orchestrator/services/TenantRepoSyncService.mjs` — pass.
    * `node --check test/playwright/unit/ai/services/knowledge-base/KnowledgeBaseIngestionService.spec.mjs` — pass.
    * `node --check test/playwright/unit/ai/daemons/orchestrator/services/TenantRepoSyncService.spec.mjs` — pass.
    * `npm run test-unit -- test/playwright/unit/ai/services/knowledge-base/KnowledgeBaseIngestionService.spec.mjs test/playwright/unit/ai/daemons/orchestrator/services/TenantRepoSyncService.spec.mjs` — 68/68 passed.
*   **Findings:** pass.

---

### Metrics Delta

Metrics are updated from the prior review because the blocking resolver defect and test gap were addressed.

*   **`[ARCH_ALIGNMENT]`**: 70 -> 92 - 8 points remain below perfect only because the graph-only tenant enumeration limitation is intentionally deferred until a write/operator tool exists; the shipped resolver now aligns with the current tier contract.
*   **`[CONTENT_COMPLETENESS]`**: 86 -> 90 - 10 points deducted for the minor stale PR-body test-count text, but code/docs/JSDoc now describe the intended tier semantics accurately.
*   **`[EXECUTION_QUALITY]`**: 68 -> 94 - 6 points deducted only for the remaining live-deployment evidence ceiling; the prior functional defect is fixed and covered by the two regression tests.
*   **`[PRODUCTIVITY]`**: 72 -> 94 - 6 points deducted for post-merge live deployment validation still pending; the implementation now satisfies the review-blocked AC path.
*   **`[IMPACT]`**: unchanged from prior review at 78 - still a substantive cloud-deployment config-path fix rather than a new foundational subsystem.
*   **`[COMPLEXITY]`**: unchanged from prior review at 70 - still cross-service runtime config plus docs/tests/deployment guidance; the cycle-2 delta itself is small, but the PR scope remains medium-high.
*   **`[EFFORT_PROFILE]`**: unchanged from prior review at Heavy Lift - the PR spans runtime resolver semantics, orchestrator delegation, cloud docs, and deployment residuals.

---

### Required Actions

No required actions — eligible for human merge.

---

### A2A Hand-Off

I will send the follow-up review URL / reviewId to the author via A2A after posting.

---

