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
- 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.)
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).
- Mount
kb-config.yaml (read-only) into the orchestrator service in ai/deploy/docker-compose.yml, matching the kb-server mount.
- 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
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
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 readskb-config.yamlat all.The Problem
learn/agentos/cloud-deployment/TenantIngestionModel.md:335documents the intended tenant-config home:So the design intent is graph-node (canonical) +
kb-config.yaml(deployment bootstrap tier), with the gitignoredconfig.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[]fromaiConfigdirectly, bypassing the tiered resolver. Consequences:kb-config.yaml'stenants.<id>.tenantRepos(the documented bootstrap tier) is never honored on the pull path.config.mjsoverlay → 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 —credentialRefis an env-var pointer; tokens live in the gitignored token env file, resolved at theGIT_ASKPASSboundary. Normalization already rejects a credential-bearingcloneUrl. So the array is safe to commit to YAML.The Architectural Reality
ai/daemons/orchestrator/services/TenantRepoSyncService.mjs:~582—resolveTenantReposConfignormalizesaiConfig.tenantReposdirectly (no tier resolution).ai/services/knowledge-base/KnowledgeBaseIngestionService.mjs:799—getTenantConfigalready returnstenantReposfrom all three tiers (graph node:818/ yaml bootstrap:838/ aiConfig default:855). Per-tenant only.ai/services/knowledge-base/KnowledgeBaseIngestionService.mjs:869—readKbConfigBootstrap()readspath.join(aiConfig.neoRootDir, 'kb-config.yaml').ai/deploy/docker-compose.yml— canonical compose; the orchestrator service does not mountkb-config.yaml(only the kb-server does). Deployment mirrors inherit the gap.ai/services/knowledge-base/helpers/TenantRepoIngestEnvelopeBuilder.mjs:250— pull-mode file selection isgit ls-tree/diff over the mirror;kb-config.yaml'ssourcePaths.rootis not applied on the pull path (feeds the full-corpuskbSynclane only).The Fix
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 shapeTenantRepoSyncServicealready consumes. (getTenantConfigis per-tenant; the sync lane needs all tenants.)TenantRepoSyncService.resolveTenantReposConfigdelegates to that resolver instead of readingaiConfig.tenantReposdirectly. Precedence: graph node > yaml > aiConfig default — matchesgetTenantConfig's existing tiering (no new precedence invented, no hidden fallback).kb-config.yaml(read-only) into the orchestrator service inai/deploy/docker-compose.yml, matching the kb-server mount.TenantIngestionModel.md+ the cloud-deployment README to state (a) pull-mode file selection comes from the git mirror +tenantRepos[], independent ofkb-config.yaml's Source/Parser registration; (b)sourcePaths.rootis NOT honored on the pull path; (c) the tier order fortenantRepos. (Folds the otherwise-standalone doc-fix.)Contract Ledger
kb-config.yamltenants.<id>.tenantRepos[]getTenantConfigyaml tier (KnowledgeBaseIngestionService.mjs:838)aiConfig.tenantRepos[](Tier-3 default)getTenantConfigalready returns yaml-tiertenantRepos;TenantRepoSyncServicebypasses itTenantRepoSyncService.resolveTenantReposConfigai/daemons/orchestrator/services/TenantRepoSyncService.mjs:~582readsaiConfig.tenantReposdirectly todaykb-config.yamlmountai/deploy/docker-compose.ymlDecision Record impact
aligned-with ADR 0014(cloud deployment topology + scheduler task taxonomy). Completes the documented tenant-config-storage intent inTenantIngestionModel.md:335; no ADR conflict.Acceptance Criteria
tenantReposacross graph + yaml + aiConfig tiers, returning the normalized array shape.TenantRepoSyncServiceresolves via that resolver; no directaiConfig.tenantReposread remains in the sync path.tenantReposentry declared only inkb-config.yaml tenants.<id>.tenantReposis picked up + synced by the pull lane (test).||/?? hardcodedhidden fallback (per the config-as-SSOT contract, #12097 / #12061).kb-config.yaml(RO) inai/deploy/docker-compose.yml.TenantIngestionModel.md+ cloud-deployment README corrected (pull path independent ofkb-config.yamlSource config;sourcePaths.rootnot honored on pull path;tenantRepostier order documented).TenantRepoSyncService/ KB tests pass; new tier-resolution + negative-fallback tests added.Out of Scope
setTenantConfigoperator 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.config.mjsoverlay into committedkb-config.yaml) — dependent follow-on in the deployment repo, gated on this neo wiring landing + a release.Avoided Traps
kb-config.yaml/config.mjssplit. The array is non-secret (credentialRefis a pointer), so committed YAML is the better SSOT.getTenantConfig's existing graph > yaml > default tiering; do not add a parallel resolver with different semantics.|| default/?? hardcodedfallbacks in the resolver: violates the config-as-SSOT contract (#12097 / #12061).Related
config.mjs tenantRepossmoke: #12029 (RawRepoSource opt-in).Handoff Retrieval Hints
TenantRepoSyncService.resolveTenantReposConfigtiered resolution