⚠️ Contract authority correction — read the re-cut ledger, not the superseded opening
The early “durable unit is 50 chunks, and it is all-or-nothing” and “three leaf bindings, nothing else changes” paragraphs below describe the pre-review prescription and are superseded. The delivered contract distinguishes normal-failure atomicity from cooperative-yield prefix persistence; enforces positive/non-negative integer domains through the parser and new type tokens; and carries all three leaves through both canonical standalone Compose profiles to both consumers. The Re-cut 2026-08-10 Contract Ledger and rewritten Acceptance Criteria are the current authority. The earlier prose remains visible as review history, not as the shipped contract.
Context
An external plane's Knowledge Base has held count: 0 for two months. The ingest path was traced end-to-end on dev and eliminated as the cause (#16706, Step 4): VectorService.embedChunks batches and upserts incrementally, tenant ingest writes to the canonical collection directly, and a batch failure rolls nothing back. A single successful batch would leave count > 0 permanently.
So the entire two-month deadlock reduces to one target: get one batch through. This ticket is about the fact that an operator on a starved plane cannot make that batch smaller.
The Problem
The durable unit is 50 chunks, and it is all-or-nothing.
embedChunks slices chunksToProcess by batchSize (default 50), calls TextEmbeddingService.embedTextsonce with all 50 texts, and upserts only after that single call returns. Internally the provider work is split by batchEmbeddingChunkSize (default 5) — so one durable unit is ten provider calls that must all succeed. Any one of them exhausting its retries fails the batch, and nothing from those 50 chunks persists.
Now compare which of those dials an operator can actually turn, on a running deployment, without our source:
Every dial that shapes an individual provider call is reachable. Every dial that shapes the durable unit is not. An operator can make each request smaller and more patient, and still cannot reduce the amount of work that has to succeed together before anything is written.
On a healthy plane this is invisible — 50 chunks land fine. On a starved one it is the difference between a corpus that starts and a corpus that stays at zero, because the smallest bet available is fifty times larger than the smallest bet that would prove the pipeline works.
The Architectural Reality
ai/mcp/server/knowledge-base/configBase.mjs — batchSize: leaf(50), batchDelay: leaf(10000), maxRetries: leaf(5). Each is leaf(default) with no env argument, so ConfigProvider.#applyEnvLayer has nothing to bind.
ai/services/knowledge-base/VectorService.mjs — embedChunks reads const {batchSize, batchDelay, maxRetries} = aiConfig and slices the loop by batchSize; the collection.upsert sits after the single embedTexts call, which is what makes the slice the durable unit.
Sibling precedent for the fix is in the same file: kbFaqMinCount: leaf(3, 'NEO_KB_FAQ_MIN_COUNT', 'number'), askSynthesisTimeoutMs: leaf(300000, 'NEO_KB_ASK_SYNTHESIS_TIMEOUT_MS', 'number').
The Fix
Give the three leaves their env bindings. Nothing else changes.
This is the sanctioned pattern, not a workaround. ADR-0019 §2.1: "leaf(default, env, type) declares one value … the leaf owns env-override-with-default." No helper, no cascade, no hasEnvValue, no formula, no re-derivation — the resolution machinery already exists and these three leaves simply were never wired into it.
Why all three rather than only batchSize: they are one group with one purpose — the recovery levers for a provider that cannot keep up. batchSize shrinks the bet; maxRetries bounds how long a doomed bet costs; batchDelay (10 s between batches) decides whether a shrunken batch size turns a recovery run into an overnight job. Shipping batchSize alone hands an operator a knob whose obvious setting makes their sync 50× slower with no way to compensate.
Contract Ledger Matrix
Re-cut 2026-08-10 after @neo-gpt-emmy's cycle-2. The original ledger listed three leaf bindings and nothing else, while the delivered change also carries the leaves into two deployment profiles, puts a domain on them, and adds two type surfaces. A ledger narrower than its diff guarantees a review finding, every cycle — that gap is the review cost this ticket has now paid twice.
Target Surface
Source of Authority
Proposed Behavior
Fallback
Docs
Evidence
aiConfig.batchSize
configBase.mjs leaf
env-overridable via NEO_KB_EMBEDDING_BATCH_SIZE, typed positiveInt
unset or out-of-domain ⇒ 50
leaf JSDoc
env set ⇒ resolved value changes; 0/-1/2.5/NaN ⇒ default
aiConfig.batchDelay
same
NEO_KB_EMBEDDING_BATCH_DELAY_MS, typed nonNegativeInt — 0 is legitimate
unset ⇒ 10000
leaf JSDoc
0 resolves to 0; -1 falls back
aiConfig.maxRetries
same
NEO_KB_EMBEDDING_MAX_RETRIES, typed positiveInt. TOTAL attempts, not retries on top of one
unset ⇒ 5
leaf JSDoc, corrected
the loop is while (retries < maxRetries) from zero
Env.parseIntAtLeast
src/util/Env.mjs
new — integer-with-minimum env parser; out-of-domain returns undefined so the leaf default stands
new type tokens — parser enforces, validator is advisory
unknown token ⇒ passes through
inline rationale
the parser is the enforcement; #validateLeafValue warns and keeps
kb-server + orchestrator env
docker-compose.ymlanddocker-compose.dev.yml
all three leaves pass through as ${VAR:-} in every standalone profile
unset ⇒ leaf default
inline rationale
per-(profile, service, leaf) coordinates, each profile parsed alone
Decision Record impact:aligned-with ADR 0019 — uses the leaf's own env-binding and adds no resolution logic. The new parser/type tokens extend the sanctioned decode layer rather than introducing a second one.
Acceptance Criteria
Rewritten to match what is delivered. The previous five were all ticked while describing a superseded implementation — AC-1's receipt still claimed the leaves proved "the 'number' type argument is present" after the types had become positiveInt/nonNegativeInt, and nothing covered the deployment carry, the domain, or the parser boundary. Ticked criteria that describe an earlier version of the diff are worse than open ones: they read as verified.
The three leaves resolve from their env var when set and to their current default when unset. Receipt:config.template.spec.mjs — embedding-batch recovery levers are env-overridable.
Defaults unchanged — a deployment setting none of the three behaves exactly as today. Receipt:…keep their defaults when no env is set asserts 50 / 10000 / 5 with the vars deleted; 449 passed across ai/deploy/, ai/mcp/server/knowledge-base/ and ai/scripts/lint/.
The operational domain is enforced, not merely described.0 and negatives fall back rather than being accepted as "smaller": a batchSize of 0 is the loop stride, so i += 0 never advances; maxRetries: 0 skips the loop and returns a clean zero-embedded result with no provider call. 0 remains valid for batchDelay, which the runbook's recovery step tells operators to set.
The domain check is mutation-bound. Replacing Number.isInteger with Number.isFinite must redden a test. Receipt: it does — the fractional case, 1 failed / 12 passed, verified by running exactly that swap. The previous matrix (0, 0, -1) did not bind it: every value was rejected by both predicates on the < min branch, so the integer check was never why anything failed. Non-finite and non-numeric are covered too, because NaN < min is false and a bounds-only predicate would admit it.
The leaves reach every profile that BOOTS, per consuming service.Receipt:EmbeddingBatchLeverReachability.spec.mjs. docker-compose.dev.yml is a standalone parity stack, not an overlay — it previously rendered null/null/null while base and local rendered correctly, and a base+dev render resolved them from base and hid it. Each profile is now parsed alone. Mutation controls: a profile carrying none, a hardcoded value, and an interpolation of a different variable.
Values stay operator-overridable, not merely present. Each must interpolate its own variable; a literal is a decision taken away from the operator. Both compose forms are read — base is list form, parity is mapping form, and a reader handling one would report the other as empty.
No new resolution logic. Receipt: the change is leaf argument lists, one env parser mirroring parsePort, two type tokens, and deployment pass-through. No hasEnvValue, no formula, no consumer-side re-derivation.
Prose matches behaviour on the two axes review caught.maxRetries is documented as the TOTAL attempt budget (while (retries < maxRetries) from zero, so 5 buys five calls). The parser/validator boundary is stated truthfully: the parser enforces by returning undefined; #validateLeafValuewarns and keeps, as its own JSDoc says. An earlier comment claimed the write choke point rejected — it does not, and a reader who believed it would skip the half that actually protects the consumer.
Post-merge: on a plane whose corpus will not start, shrinking NEO_KB_EMBEDDING_BATCH_SIZE is observed to land a batch that 50 could not. [L3-deferred — needs a running plane]
Out of Scope
Changing any default. A plane that sets nothing must be unaffected; this ticket adds reachability, not new behaviour.
Making the 50-chunk unit itself smaller by construction, or checkpointing inside a batch. That is a real design question and it is not this.
Why a provider fails to return embeddings (#16830, #14154) — this cannot fill a corpus, it only lets an operator shrink the bet until one lands.
The batch-failure isolation in #16843, which is the sibling concern on the same loop.
Avoided Traps
Adding a resolution helper. ADR-0019 §3 A3/A5: the leaf already owns env-override-with-default; a helper is the fingerprint of not understanding leaf().
Shipping batchSize alone. Its obvious remedial setting (1) multiplies the number of batchDelay waits by fifty. A knob that creates the need for a second knob is half a fix.
Changing the defaults "while we are here". The defaults are right for a healthy plane; the defect is reachability, not the values.
Related
#16706 — the epic whose empty-corpus elimination makes "get one batch through" the operative target.
#16843 — one failing batch strands the remainder; same loop, adjacent concern.
#16830, #14154 — why a provider returns nothing.
#16765 — declared-leaf/env diffing across revisions; this adds three leaves it will see.
Live latest-open sweep: latest 20 open issues checked at 2026-08-09T23:50Z plus a scoped title sweep for config/env-knob duplicates; no equivalent found.
Structure map: N/A — modifies ai/mcp/server/knowledge-base/configBase.mjs in place; no file created or relocated.
Context
An external plane's Knowledge Base has held
count: 0for two months. The ingest path was traced end-to-end ondevand eliminated as the cause (#16706, Step 4):VectorService.embedChunksbatches and upserts incrementally, tenant ingest writes to the canonical collection directly, and a batch failure rolls nothing back. A single successful batch would leavecount > 0permanently.So the entire two-month deadlock reduces to one target: get one batch through. This ticket is about the fact that an operator on a starved plane cannot make that batch smaller.
The Problem
The durable unit is 50 chunks, and it is all-or-nothing.
embedChunkssliceschunksToProcessbybatchSize(default50), callsTextEmbeddingService.embedTextsonce with all 50 texts, and upserts only after that single call returns. Internally the provider work is split bybatchEmbeddingChunkSize(default5) — so one durable unit is ten provider calls that must all succeed. Any one of them exhausting its retries fails the batch, and nothing from those 50 chunks persists.Now compare which of those dials an operator can actually turn, on a running deployment, without our source:
openAiCompatible.batchEmbeddingChunkSizeNEO_OPENAI_COMPATIBLE_BATCH_EMBEDDING_CHUNK_SIZEopenAiCompatible.batchEmbeddingTimeoutMsNEO_OPENAI_COMPATIBLE_BATCH_EMBEDDING_TIMEOUT_MSollama.embeddingTimeoutMsNEO_OLLAMA_EMBEDDING_TIMEOUT_MSbatchSizebatchDelaymaxRetriesEvery dial that shapes an individual provider call is reachable. Every dial that shapes the durable unit is not. An operator can make each request smaller and more patient, and still cannot reduce the amount of work that has to succeed together before anything is written.
On a healthy plane this is invisible — 50 chunks land fine. On a starved one it is the difference between a corpus that starts and a corpus that stays at zero, because the smallest bet available is fifty times larger than the smallest bet that would prove the pipeline works.
The Architectural Reality
ai/mcp/server/knowledge-base/configBase.mjs—batchSize: leaf(50),batchDelay: leaf(10000),maxRetries: leaf(5). Each isleaf(default)with no env argument, soConfigProvider.#applyEnvLayerhas nothing to bind.ai/services/knowledge-base/VectorService.mjs—embedChunksreadsconst {batchSize, batchDelay, maxRetries} = aiConfigand slices the loop bybatchSize; thecollection.upsertsits after the singleembedTextscall, which is what makes the slice the durable unit.kbFaqMinCount: leaf(3, 'NEO_KB_FAQ_MIN_COUNT', 'number'),askSynthesisTimeoutMs: leaf(300000, 'NEO_KB_ASK_SYNTHESIS_TIMEOUT_MS', 'number').The Fix
Give the three leaves their env bindings. Nothing else changes.
batchSize : leaf(50, 'NEO_KB_EMBEDDING_BATCH_SIZE', 'number'), batchDelay: leaf(10000, 'NEO_KB_EMBEDDING_BATCH_DELAY_MS', 'number'), maxRetries: leaf(5, 'NEO_KB_EMBEDDING_MAX_RETRIES', 'number'),This is the sanctioned pattern, not a workaround. ADR-0019 §2.1: "
leaf(default, env, type)declares one value … the leaf owns env-override-with-default." No helper, no cascade, nohasEnvValue, no formula, no re-derivation — the resolution machinery already exists and these three leaves simply were never wired into it.Why all three rather than only
batchSize: they are one group with one purpose — the recovery levers for a provider that cannot keep up.batchSizeshrinks the bet;maxRetriesbounds how long a doomed bet costs;batchDelay(10 s between batches) decides whether a shrunken batch size turns a recovery run into an overnight job. ShippingbatchSizealone hands an operator a knob whose obvious setting makes their sync 50× slower with no way to compensate.Contract Ledger Matrix
Re-cut 2026-08-10 after @neo-gpt-emmy's cycle-2. The original ledger listed three leaf bindings and nothing else, while the delivered change also carries the leaves into two deployment profiles, puts a domain on them, and adds two type surfaces. A ledger narrower than its diff guarantees a review finding, every cycle — that gap is the review cost this ticket has now paid twice.
aiConfig.batchSizeconfigBase.mjsleafNEO_KB_EMBEDDING_BATCH_SIZE, typedpositiveInt500/-1/2.5/NaN⇒ defaultaiConfig.batchDelayNEO_KB_EMBEDDING_BATCH_DELAY_MS, typednonNegativeInt—0is legitimate100000resolves to0;-1falls backaiConfig.maxRetriesNEO_KB_EMBEDDING_MAX_RETRIES, typedpositiveInt. TOTAL attempts, not retries on top of one5while (retries < maxRetries)from zeroEnv.parseIntAtLeastsrc/util/Env.mjsundefinedso the leaf default standsparsePortpositiveInt/nonNegativeIntai/ConfigProvider.mjs#validateLeafValuewarns and keepskb-server+orchestratorenvdocker-compose.ymlanddocker-compose.dev.yml${VAR:-}in every standalone profile(profile, service, leaf)coordinates, each profile parsed aloneDecision Record impact:
aligned-with ADR 0019— uses the leaf's own env-binding and adds no resolution logic. The new parser/type tokens extend the sanctioned decode layer rather than introducing a second one.Acceptance Criteria
Rewritten to match what is delivered. The previous five were all ticked while describing a superseded implementation — AC-1's receipt still claimed the leaves proved "the
'number'type argument is present" after the types had becomepositiveInt/nonNegativeInt, and nothing covered the deployment carry, the domain, or the parser boundary. Ticked criteria that describe an earlier version of the diff are worse than open ones: they read as verified.config.template.spec.mjs— embedding-batch recovery levers are env-overridable.449 passedacrossai/deploy/,ai/mcp/server/knowledge-base/andai/scripts/lint/.0and negatives fall back rather than being accepted as "smaller": abatchSizeof0is the loop stride, soi += 0never advances;maxRetries: 0skips the loop and returns a clean zero-embedded result with no provider call.0remains valid forbatchDelay, which the runbook's recovery step tells operators to set.Number.isIntegerwithNumber.isFinitemust redden a test. Receipt: it does — the fractional case,1 failed / 12 passed, verified by running exactly that swap. The previous matrix (0,0,-1) did not bind it: every value was rejected by both predicates on the< minbranch, so the integer check was never why anything failed. Non-finite and non-numeric are covered too, becauseNaN < minis false and a bounds-only predicate would admit it.EmbeddingBatchLeverReachability.spec.mjs.docker-compose.dev.ymlis a standalone parity stack, not an overlay — it previously renderednull/null/nullwhile base and local rendered correctly, and a base+dev render resolved them from base and hid it. Each profile is now parsed alone. Mutation controls: a profile carrying none, a hardcoded value, and an interpolation of a different variable.ai:lint-config-template-ssotgreen.parsePort, two type tokens, and deployment pass-through. NohasEnvValue, no formula, no consumer-side re-derivation.maxRetriesis documented as the TOTAL attempt budget (while (retries < maxRetries)from zero, so5buys five calls). The parser/validator boundary is stated truthfully: the parser enforces by returningundefined;#validateLeafValuewarns and keeps, as its own JSDoc says. An earlier comment claimed the write choke point rejected — it does not, and a reader who believed it would skip the half that actually protects the consumer.NEO_KB_EMBEDDING_BATCH_SIZEis observed to land a batch that50could not.[L3-deferred — needs a running plane]Out of Scope
#16830,#14154) — this cannot fill a corpus, it only lets an operator shrink the bet until one lands.#16843, which is the sibling concern on the same loop.Avoided Traps
leaf().batchSizealone. Its obvious remedial setting (1) multiplies the number ofbatchDelaywaits by fifty. A knob that creates the need for a second knob is half a fix.Related
#16706— the epic whose empty-corpus elimination makes "get one batch through" the operative target.#16843— one failing batch strands the remainder; same loop, adjacent concern.#16830,#14154— why a provider returns nothing.#16765— declared-leaf/env diffing across revisions; this adds three leaves it will see.Live latest-open sweep: latest 20 open issues checked at 2026-08-09T23:50Z plus a scoped title sweep for config/env-knob duplicates; no equivalent found.
Structure map: N/A — modifies
ai/mcp/server/knowledge-base/configBase.mjsin place; no file created or relocated.Origin Session ID: d8332b13-5d97-4839-ac11-d2de4602a989
Retrieval Hint:
query_raw_memories("embedding batchSize no env override durable unit fifty chunks operator cannot shrink")