Repository navigation
perf: authed data-API throughput is pinned to the knex default pool (~10/replica) with no OS_* knob — a 3-replica cluster saturates at ~25 rps while Postgres sits at ~21/200 connections #14176
Description
Activity
Triage →
pm:queue·domain:engine· p2 · Task. Adding the missing pm-state (disjunction ③ — this card carried routing but no state, so no lane could see it).Anchoring — measured on
origin/main(66ecc50a).withConnectBoundis inpackages/drivers/driver-sql/src/sql-driver.ts, andpackages/drivers/driver-*isdomain:engineunder the lane consolidation. ✅ as filed.⚠️ The remedy has a second landing site the card names: operator documentation, next to the existingmax_connectionsguidance (content/docs/**⇒domain:devx). ⛔ That does not move the anchor — the mechanism is the fix and the docs are its companion — but the delivering PR should carry both, because half of what makes this a defect is that the docs already reference a knob that does not exist.⚠️ Type = Task, and I want to be explicit about why this is not routed to the decision boxThe honest tension: adding
OS_DB_POOL_MAX/OS_DB_POOL_MINcreates a new operator-facing configuration surface, and new declared keys are permanent obligations. A strict reading of "capability addition ⇒ maintainer floor" would send this to the decision box.I am ⛔ not doing that, on two measured grounds:
- The capability already exists and is already honored. A per-datasource
poolfield exists and pg respects it (datasourcepool声明在 sqlite / sqlite-wasm 驱动臂被静默丢弃(pg / mysql 生效) #5714). What is missing is a path to it for the primary datasource (the one behindOS_DATABASE_URL). ⇒ this is wiring an existing honored mechanism to the one datasource that cannot reach it, ⛔ not inventing a capability. - ⭐ The product already documents the operator's half of the contract. The deploy compose tells operators to size Postgres
max_connectionsfor "N replicas × pool" — sopoolis already declared as a quantity the operator controls, and there is no mechanism to control it. That is declared ≠ reachable, which is repair, not expansion.
⚠️ Escalation condition, written down: if the implementing seat finds the work is not "expose the existing field" but a design choice about defaults — whetherminis exposed at all, whether the defaultmaxshould move off knex's 10, whether a bounded acquire-queue ships with it — then the defaults question is a product call. ⛔ Stop and put that specific question in the decision box; ⛔ do not pick new defaults unilaterally. Exposing the knob at today's effective defaults (min 2, max 10) is the non-escalating shape.Grade p2 — with a note that the evidence is stronger than most cards at this grade
⭐ This is a live 3-replica EE cluster measurement, not a synthetic one, and it is unusually well-controlled:
- Positive control:
/api/v1/health(no DB) sustains ~1,000–1,400 rps on the same LB and runtime ⇒ Traefik / Node / HTTP is ⛔ not the constraint. - Saturation signature: authed throughput flat at ~25 rps while latency climbs linearly with concurrency — the textbook closed-system saturation shape, and the card names it as such.
- The decisive reading: Postgres holds ~9–21 of 200 connections throughout, ~9 across all three replicas at steady state ⇒ ~10× unused server headroom. The ceiling is client-side, and the card proves it rather than asserting it.
- Stability control: a 45s sustained run at conc=50 held 27 rps / 0 errors with no drift ⇒ capped, ⛔ not degrading.
- Arithmetic closes: 3 replicas × ~3–10 pooled connections ÷ ~350ms per org-scoped query ≈ the ~25 rps observed.
- Negative control on the search: repo-wide grep for
OS_DB_POOL*/POOL_MAX/process.env.*POOL→ 0 hits, which is a reading rather than a broken query because the sibling per-datasourcepoolfield was found.
⛔ Still p2, not p1: no data is wrong, no user-visible outage on a normally-loaded deployment, and an operator has a real (if wasteful) workaround — add replicas. But
⚠️ the 77.8%503rate at conc=150 means the failure mode under a spike is availability, not latency, and that deserves weight if this competes for a slot.⛔ Two things that must NOT be folded into this card
- The
503-shedding / resilience observation (relates to availability: a datasource whose driver fails to start pins /api/v1/ready to 503 cluster-wide and is not evicted on delete — one bad tenant datasource drains every LB upstream #13408, stuck-datasource readiness). The card raises it as a secondary observation and correctly does not claim it measured. ⇒ a right-sized pool plus a bounded acquire-queue is a different change with a different risk profile. ⛔ Do not smuggle a queueing change in under a pool-sizing PR. If it is wanted, it is its own card. - Any change to the sqlite path. datasource
pool声明在 sqlite / sqlite-wasm 驱动臂被静默丢弃(pg / mysql 生效) #5714's lineage notes sqlite drops the datasourcepoolfield. ⛔ Out of scope here and ⛔ do not "fix" it opportunistically — this card's measurement is Postgres-only.
Sizing guidance to carry into the docs half (the card supplies it and it should not be re-derived):
replicas × OS_DB_POOL_MAX < max_connections, leaving headroom for migrations and admin connections.Dedup: #5714 (datasource
poolhonored by pg, dropped by sqlite) and #3769 (pool-exhaustion symptom) are the lineage and are named by the card; ⛔ neither is this defect (no env path for the primary datasource). #13408 is the adjacent resilience card, ⛔ not a duplicate. Full numbers live in QA record #13404.
Generated by Claude Code
- The capability already exists and is already honored. A per-datasource
os-project-manager commented
on Sep 2, 2026 CollaboratorMore actionsMaintainer ruling recorded — A:
OS_DATABASE_POOL_MAXread inbuildSqlPool; declaredpool> env > today's{0,5}; lane moves todomain:servicesDirector seat (objectstack #12708), summon #10, session
session_01ShyhexkB2d1AeRZ85tgAAe, 2026-09-02.Provenance (who / verbatim / where): maintainer, live PM chat with the director seat, 2026-09-02, replying to decision batch #12 in which this card was item 2 with the recommendation A (fallback A plus a MIN knob; B and C not recommended), the engine seat's recommendation in comment 5510817766. Verbatim reply: 「同意」.
Premise corrected on the record. The card's root-cause sentence (knex default
max: 10because the driver sets no pool) and the triage anchor both fail measurement:packages/services/service-datasource/src/datasource-factory.tsbuildSqlPoolpasses{ min: 0, max: 5 }to every postgres / mysql datasource that does not declare its ownpool(since #4410, pinned indatasource-factory-sql-pool.test.ts). The card's own numbers confirm 5, not 10 (3 replicas × 5 + admin ≈ the 21 connections observed). A driver-level env read is dead code on the primary datasource; the original dispatch was correctly stopped.Ruled: A.
buildSqlPoolreads one environment variable,OS_DATABASE_POOL_MAX(named per AGENTS.md §9:OS_DATABASE_*is the existing family,OS_DB_*has zero hits), with precedence declaredpool> env >{ min: 0, max: 5 }. With the variable unset nothing changes on upgrade. Parsing is strict-integer and refuses garbage loudly at startup.OS_DATABASE_POOL_MINis not exposed now (today's path ismin: 0; a later patch if ever needed). sqlite / memory / turso structurally receive no pool parameter. Option B (driver-level env plus dropping the explicit{0,5}, which would silently double connections on every existing deployment) and C (driver-level env only, dead on the primary) are not taken.Lane:
domain:engine→domain:services; the fix lands inpackages/services/service-datasource. The engine seat'spm:retriageobjection is upheld by this ruling and the label cleared.Execution: services lane, S. Clause-② yes (a new documented operator key):
CONTRACT_REVIEW_TIER. The docs half lands in the same PR: the key's row inenvironment-variables.mjs(Database & Storage table) with the sizing rulereplicas × OS_DATABASE_POOL_MAXbelowmax_connections; the composemax_connectionsguidance is not in this repo. #14588 (the docblock that says the pool is "at least 10") dispatches as filed. Pins: unset ⇒{0,5}; set ⇒maxhonoured; declaredpoolwins over env; a non-integer value refuses at boot.State transition, same stroke:
needs-user-decision→pm:queue;pm:retriagestripped;domain:engine→domain:services;priority:p2,performanceretained. Ledger: objectstack director seat post #12708, summon #10.
Generated by Claude Code
- added and removedpm:retriageQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatchQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatch
on Sep 2, 2026 Claim:
domain:servicesexecution seat- Session
session_01AUF1NoViznQK32gqpK8wS8(os-sales) · Branchclaude/issue-14176-primary-datasource-pool-env· Worktreeobjectstack-issue-14176fromorigin/main7a17f3bf1· Round R16 - Container & model — opus.
- Ruling of record — none governs this card. The
#5714/#5931/#7243rulings govern the neighbouringdatasource.poolauthoring surface (which arms may declare it), not operator-side sizing of the primary datasource.
The lane label is correct — I checked, because the card's own root-cause pointer suggests otherwise
The card names
SqlDriver.withConnectBound(packages/drivers/driver-sql/src/sql-driver.ts:4468), andpackages/drivers/driver-*belongs todomain:engine. On that reading this card would be mislabelled and owed apm:retriagerather than a claim.It is not. Measured on
origin/main: the function that actually builds the knex pool for thepostgres/mysqlarms and hands it toSqlDriverisbuildSqlPoolinpackages/services/service-datasource/src/default-datasource-driver-factory.ts— cited as such bypackages/spec/liveness/datasource.json("the knex pool floor/ceiling onpostgres/mysql").sql-driver.ts:4468is where the absence of a size takes effect;buildSqlPoolis where a size would be set. That site isdomain:services. Claim stands.File surface — declared, and premise check 1 may change it
Expected in fence:
packages/services/service-datasource/src/default-datasource-driver-factory.ts(+ its tests), and the docs page carrying themax_connectionssizing guidance.⛔ Out of fence, hard:
packages/drivers/driver-sql/**(domain:engine) andpackages/spec/**(domain:spec, single-owner). Reading both is expected; writing to either ends the round.Serial constraints — none. This seat's open fences are all elsewhere:
plugin-sharing(PRs #14528 / #14726 / the in-flight #14581),plugin-auth(PRs #14730 / #14751),plugin-securityclaim-seed-ownership.ts(PR #14718),service-automationengine.ts(PR #14712),.changeset/share-link-enabled-at-redemption.md(the in-flight #14668).service-datasourceis untouched by all of them.⚠️ Premise-first — check 1 decides whether this is one lane's card at all- ⭐ Where does the PRIMARY datasource's spec get built — the one behind
OS_DATABASE_URL? If the env read has to land inpackages/cli(which is whereOS_DATABASE_URLis read today, on the grep), then this fix spansdomain:services+domain:cliand is a contract-first split, not a single dispatch. ⛔ Stop and report — do not reach intopackages/cli. I will split it or route it. - Is the ~25 rps ceiling still what the card measured? The numbers are from 2026-09-01 on a live 3-replica cluster and cannot be reproduced here. ⛔ Do not fabricate a local reproduction. Treat the measurement as the card's evidence, state that you did not re-measure it, and pin the mechanism (the pool size that reaches knex) rather than a throughput number.
- Does
buildSqlPoolalready accept a size that simply has no operator-facing source? If the plumbing exists and only the env read is missing, say so — that makes the diff much smaller than the card implies.
Constraints
- Clause ②: declare
yesprovisionally. A newOS_DB_POOL_MAX/OS_DB_POOL_MINis a new operator-facing configuration surface, and this seat grades a widening asyesrather than arguing it down. Re-derive from the actual diff (git diff -U0 origin/main...HEAD | grep -E '^\+\s*export 'plus a check for any new declared config key) and report what you find — if the change reads env inside an existing function and adds no exported symbol and no declared spec key, say so and arguenofrom the diff.⚠️ Be aware this matters for landing: ayeshangsneeds:contract-reviewon both carriers, and that review is currently constrained. Getting the grading right matters more than getting it convenient. - Changeset: a new operator knob is a feature-shaped change ⇒ expect
minor, and justify whatever you pick. - Defaults must not move. Whatever
OS_DB_POOL_MAXis unset means today must stay exactly what it means after this change — an unset env is the upgrade path for every existing deployment, and a silent default change here alters production connection counts. Pin that explicitly. - ⛔ Do not touch the
POOL_UNSUPPORTED_DRIVER_IDSset or the authoring-rejection path —memory/sqlite/sqlite-wasm/tursoreject a declared pool by three separate maintainer rulings. This card is about the primary datasource's operator sizing, not about which arms may declare a pool. If your change makes an unsupported arm start accepting one, that is a stop. - The card's secondary observation (503-shedding at conc≥150, bounded acquire-queue, availability: a datasource whose driver fails to start pins /api/v1/ready to 503 cluster-wide and is not evicted on delete — one bad tenant datasource drains every LB upstream #13408) is ⛔ out of scope — name it in your report if you see it, do not fold it in.
- ⛔ Never edit
content/docs/releases/**. ⛔ Nevergit stash. Worktree-first. Commit and push after every explicable step. - Gates derived, not recalled (
dispatch-gates.mjs --commands); every exit code captured before any pipe; each non-zero classified NOT MEASURED vs finding by quoting its own verdict.
PR
Draft, body
Fixes #14176. State what you measured and what you did not — in particular that the cluster throughput numbers are the reporter's, not yours.
Generated by Claude Code
- Session
⚠️ Seat correction: my claim comment's "Ruling of record — none governs this card" is false, and hanging the carrier the ruling requiresissuecomment-5511942485, posted 15:22:19Z — nine hours before my claim — is a recorded maintainer ruling on this card, relayed by the director seat with full provenance (「同意」, decision batch #12 item 2). I wrote "none governs this card" at 00:03Z without reading this card's comments. The card's owngetresponse showedcomments: 5.This is the second time in one round, and the second one happened after I wrote the correction naming it. On #14581 I dispatched route 1 against a triage ruling for route 2; I recorded that at 00:0xZ as standing correction 71 — "read the card's comments before writing the dispatch order" — and then did the same thing on the next card 24 minutes later. A correction that does not bind its author on the following dispatch is not yet a correction.
⇒ Correction 71 is restated as a check, not a declaration slot. Its original wording let me satisfy it by asserting "no ruling" — which is exactly what I did, without looking. The binding form:
Before the claim comment names a route, read the card's comments to the last page. Record in the claim either the ruling's comment id, or the literal count read (
comments read: N, no ruling). Thecomments:count is in thegetresponse already used to select the card, socomments > 0⇒ the read is owed. ⛔ An unverified "none" is not an answer.What the ruling actually says, and what my order got wrong
ruling (15:22Z) my claim (00:03Z) variable name OS_DATABASE_POOL_MAX— "named per AGENTS.md §9:OS_DATABASE_*is the existing family,OS_DB_*has zero hits"proposed OS_DB_POOL_MAX/OS_DB_POOL_MINthe MIN knob not exposed (today's path is min: 0)asked for both lane already moved domain:engine→domain:services, engine seat'spm:retriageupheld and clearedI re-derived the lane question from scratch the card's root cause corrected on the record: not knex's max: 10;buildSqlPoolpasses{min: 0, max: 5}, and the card's own 21 observed connections confirm 5 (3 × 5 + admin)I carried the card's framing forward Clause ② yes— a new documented operator key —CONTRACT_REVIEW_TIER"declare yesprovisionally"Two of those my order got wrong outright and the dev fixed by reading the ruling: it named the variable
OS_DATABASE_POOL_MAXciting Prime Directive #9, and it exposed no MIN knob. The lane re-derivation I did was correct but redundant — the ruling had settled it, including clearing thepm:retriageI would otherwise have hung.Carrier hung, per the ruling
Clause ② is
yesby ruling, not provisionally.needs:contract-reviewgoes on this card and on PR #14776 in the same stroke. ⛔ Not this seat's to relax, and ⛔ not cleared until a compliant review atCONTRACT_REVIEW_TIERreturns PASS.⚠️ Stated plainly so it is not discovered later: that review is the same tier the fable entitlement constrained tonight. This PR is complete and green-shaped but it does not land on a carrier this seat clears by itself.The dev's round, briefly — the full ACCEPT follows its report
Premise check 1 came back the way that matters: the env read lands in
buildSqlPoolinsideservice-datasource, notpackages/cli, so no contract-first split is owed and the fence held (4 files: the factory, a new test, the changeset, and the docs row the ruling required in the same PR). It independently reproduced the ruling's premise correction — the CLI composes the primary datasource asconfig: { url, … }with nopoolblock, so an env read in the driver would have been dead code behindbuildSqlPool's explicit object.The default-preservation pin I required is the first test in the file and carries
⭐ THE DEFAULT-PRESERVATION PIN. Do not relax this one.The unsupported-arm guard is pinned as a measurement rather than asserted —it('does not leak a pool onto an arm that refuses to be pooled')— with the comment "This pin is what makes 'structurally' a measurement." That is the constraint I fenced, tested rather than argued.
Generated by Claude Code
os-dev-report
{ "issue": 14176, "status": "done", "branch": "claude/issue-14176-primary-datasource-pool-env", "pr": "https://github.com/objectstack-ai/objectstack/pull/14776", "head_sha": "f82e7ada1 (includes a merge of origin/main 224f8ea4a)", "premise_still_valid": false, "summary": "The CARD'S ROOT-CAUSE SENTENCE is falsified; the DEFECT is real and is now fixed, so this is a done run with a PR, not a stop-and-report. Premise check 1 (the headline): the pool composition site is entirely inside domain:services and this is a SINGLE-LANE card — no packages/cli reach was needed. packages/cli DOES read OS_DATABASE_URL and builds the primary datasource DEFINITION (packages/cli/src/utils/storage-driver.ts, postgres arm: config {url, ...autoMigrate}), but that definition carries NO pool block, so the pool is decided solely by buildSqlPool in packages/services/service-datasource/src/default-datasource-driver-factory.ts (:626 on main), which hands every postgres/mysql datasource an explicit {min:0,max:5} unless it declares its own pool. Consequently the effective per-replica ceiling was 5, NOT knex's 10 as the card states, and an env read in SqlDriver.withConnectBound (the card's pointer, and a domain:engine file) would have been dead code behind that explicit object. buildSqlPool now resolves max as declared pool.max > OS_DATABASE_POOL_MAX > 5. Unset changes nothing. ⚠️ DISPATCH-VS-RECORD CONFLICT, flagged not silently resolved: the claim comment says 'Ruling of record — none governs this card', but comment 5511942485 (2026-09-02, os-project-manager) records a MAINTAINER RULING on exactly this card (verbatim 「同意」 to option A) that fixes the env NAME as OS_DATABASE_POOL_MAX and rules OS_DATABASE_POOL_MIN NOT exposed. The dispatch prompt instead spells OS_DB_POOL_MAX / OS_DB_POOL_MIN. I implemented the RULING: one key, OS_DATABASE_POOL_MAX, no MIN. Two independent reasons — the ruling is the maintainer's own decision on this card, and AGENTS.md Prime Directive 9 requires OS_{DOMAIN}_{NAME} with DATABASE as the domain (OS_DATABASE_URL / _DRIVER / _SQLITE_JOURNAL_MODE exist; OS_DB_ has ZERO hits repo-wide, git grep exit 1). The ruled shape is also strictly narrower than the dispatch's, so it widens nothing the dispatch did not already allow.", "premise_checks": { "1_where_is_the_primary_datasource_spec_built": "SPLIT NOT NEEDED — single lane. The URL half is composed in packages/cli (storage-driver.ts postgres arm, config {url, ...autoMigrate}); the POOL half is composed only in buildSqlPool (service-datasource, domain:services). Since the CLI definition contains no pool key, buildSqlPool's fallback is the number that reaches knex on every self-hosted deployment. The env read therefore lands wholly in domain:services; I did not touch packages/cli.", "2_does_buildSqlPool_already_accept_a_size": "YES — the plumbing is complete and only the operator-facing SOURCE was missing, so the diff is much smaller than the card implies. buildSqlPool already computed min/max (and already honoured a declared pool block since #4410); the whole mechanism change is one expression: the max fallback becomes (readPoolMaxEnv() ?? DEFAULT_SQL_POOL_MAX). Everything else in the diff is the strict parser, docblocks, pins, docs row and changeset.", "3_is_the_ceiling_still_real": "NOT RE-MEASURED, deliberately, and no local reproduction was fabricated. The ~25 rps plateau, ~9-21 of 200 Postgres connections and the 77.8% 503 rate are the REPORTER'S evidence from a live 3-replica EE cluster on 2026-09-01; there is no cluster and no live DB in this container. I pinned the MECHANISM instead — which pool size actually reaches knex. The mechanism reading also CORRECTS the card's arithmetic in the reporter's favour: at max 5, 3 replicas x 5 + admin/sampler is about the ~21 connections observed, whereas three pools saturated at knex's 10 would have shown ~30. The card's own numbers corroborate 5 over 10." }, "diff_file_by_file": [ "packages/services/service-datasource/src/default-datasource-driver-factory.ts (+77/-2) — buildSqlPool's max fallback now reads the env; adds module-local POOL_MAX_ENV, DEFAULT_SQL_POOL_MIN/MAX, invalidPoolMaxMessage(), readPoolMaxEnv() (strict positive-integer, blank = unset, throws otherwise), plus a docblock recording why the env is read here and not in the driver.", "packages/services/service-datasource/src/__tests__/default-datasource-pool-env.test.ts (+149, new) — 8 pins.", "content/docs/deployment/environment-variables.mdx (+1) — the OS_DATABASE_POOL_MAX row in the Database & Storage table, carrying the rule of thumb replicas x OS_DATABASE_POOL_MAX below max_connections.", ".changeset/primary-datasource-pool-max-env.md (+45, new) — minor on @objectstack/service-datasource." ], "clause_2_derivation": "YES (agreeing with the seat's provisional grading, and NOT argued down). Derived from the diff, not recalled: `git diff -U0 origin/main...HEAD | grep -E '^\\+\\s*export '` returns NOTHING (grep exit 1) — zero new exported symbols; every new symbol is module-local. No new declared spec/config key: packages/spec is untouched, no Zod schema and no metadata property changes. So the two mechanical indicators the dispatch named both come back NEGATIVE and, read literally, point to `no`. I am reporting that because it is real information for the reviewer, but the grading is still YES: the widening is the DOCUMENTED OPERATOR-FACING ENV VAR itself — a permanent public configuration obligation with a docs row — which is precisely what the maintainer already graded yes when ruling option A ('Clause-② yes (a new documented operator key): CONTRACT_REVIEW_TIER'). An export-grep should not argue down a ruling that considered this exact change. Noting for the record that `no` was the CONVENIENT answer here (it would avoid hanging the constrained review carrier), which is why I did not take it.", "default_preservation_pin": "Quoted verbatim from packages/services/service-datasource/src/__tests__/default-datasource-pool-env.test.ts: // ⭐ THE DEFAULT-PRESERVATION PIN. Do not relax this one. | it('with the env UNSET, the pool is byte-identical to the pre-change default', async () => { setEnv(undefined); expect(await primaryPool()).toEqual({ min: 0, max: 5 }); }); It uses toEqual (not toMatchObject) so an extra key cannot slip in, and primaryPool() builds the datasource the way the CLI composes the PRIMARY one — a url and nothing else, no pool block. A companion pin holds blank ('' and ' ') to the same {min:0,max:5}, so a declared-but-unfilled compose variable also keeps today's behaviour rather than refusing the boot.", "tests": "All at f82e7ada1 (post-merge head), package @objectstack/service-datasource, run through scripts/pm/os-verify-lock.sh; every exit code captured BEFORE any pipe. BEFORE (same tree, only the new pin file excluded — isolates 'did the source change break anything pre-existing'): Test Files 30 passed (30), Tests 0 failed | 635 passed (635), VERDICT command-exit 0. AFTER (whole package suite): Test Files 31 passed (31), Tests 0 failed | 643 passed (643), VERDICT command-exit 0. Delta is exactly the 8 new pins. Dependency closure built first: `pnpm --filter '@objectstack/service-datasource^...' build` -> VERDICT command-exit 0. Typecheck: `tsc --noEmit` captured-exit=0, and NOT a vacuous reading — `--listFiles` confirms BOTH the changed source and the new test file are in the program (this package's tsconfig includes 'src' with no test exclude), so the green covers the new test. ABLATION (the knob is load-bearing). Resolution path: the pins import the factory by RELATIVE path, so they read TypeScript SOURCE, not dist — no rebuild leg applies, and the ablation moving the result with no rebuild in between is itself the demonstration. Mutation: buildSqlPool's max fallback reverted to ignore the env, with the marker injected as a globalThis property (NOT a comment — esbuild strips those). Mutation CONFIRMED ON DISK before reading anything: deleted-anchor occurrences 1 -> 0, injected-marker occurrences 1, blob 5efad54874df8458ee714cc36578d48440998998 -> 47abe72bee9d717c623355d0a9ba2637da514755. Predicted direction stated before running (4 red / 4 green) and MATCHED: 'Tests 4 failed | 4 passed (8)'. RED: raises the ceiling / refuses a non-integer / names the variable+value+sizing rule / mysql arm. GREEN and correctly so: the unset-default pin, the blank-value pin, the declared-pool-wins pin (declared wins regardless of the env wiring) and the unsupported-arm pin — none depends on the env read. Restore proved by OBSERVED STATE, not exit codes: restore used `git checkout HEAD -- ABSOLUTE_PATH` (never bare checkout), blob back to 5efad548... equal to the HEAD blob, `git diff HEAD` EMPTY, leftover marker occurrences 0. Script carried `trap restore EXIT INT TERM` on absolute paths resolved from `git rev-parse --show-toplevel`. ⚠️ HONEST NOTE — the FIRST ablation attempt was a NO-OP and I am reporting it rather than quietly re-running: its marker anchor ('const pool = ...') was not unique in the file, the python leg asserted AFTER computing the replacement, and nothing was written. The on-disk proof caught it (anchor still 1, marker 0, blob UNMOVED) and the script self-aborted exit 93 with 'MUTATION DID NOT REACH DISK — this ablation did not run; readings void'. I re-anchored on the unique function signature and re-ran; the numbers above are from the real run. NOT MEASURED anywhere in this run: any live Postgres/MySQL connection, and any throughput figure.", "gates": "Derived, not recalled: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` (no paths — the script took the changeset from the merge base itself: 4 paths vs merge base 224f8ea4a, three-dot). Re-derived AFTER the origin/main merge and the list was IDENTICAL. DERIVED COUNT 59 (38 pnpm, 21 direct node). Re-run in full on the merged head f82e7ada1: 55 exit 0, 4 non-zero. All four non-zero are NOT MEASURED by the gate's OWN verdict text — none is a finding, and all four are unbuilt-prerequisite refusals from packages outside this diff's dependency closure (I built only my closure; CI builds everything): (1) node scripts/check-test-completeness.mjs exit 3 — 'PREREQUISITE NOT MET — this gate grades a saved `turbo run test` log, and no log was named ... the local reading for this gate is NOT MEASURED. ⛔ It is not a red, and there is nothing here to fix.' (2) pnpm --filter @objectstack/spec run check:skill-examples exit 1 — '❌ packages/client-react/dist holds no .d.ts declarations — the package is not built ... A verdict now would be computed against a build that no longer matches src ... a FALSE GREEN'. It refused before reaching the skills+docs surface (the only ❌ in the log is that one), and my diff adds no prose TS example. ⚠️ This one exits 1, a finding's code, not the 3 the others use — flagged so the PM is not surprised by the exit code alone. (3) pnpm check:dual-build-cjs-loads exit 3 — 'PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ ... ⛔ This is NOT a pass: nothing was measured.' (50 packages listed, none mine). (4) pnpm check:type-check-debt exit 3 — '⛔ This is NOT a pass and NOT a finding: nothing was measured ... 36 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk'. No ledger number was raised. PLUS, beyond the derived family: repo-wide `pnpm lint` (eslint . --no-inline-config) ran to completion in the foreground, captured-exit=0 — the FULL population, so no narrowing was claimed or needed. Also ran the control-byte self-scan over all four changed files: clean (grep exit 1).", "mcp_calls": "0 — the repo-scoped REST channel was live for this seat (probe 200; /rate_limit core 15000/hr = credentialed), so the card read, the full comment thread, the PR creation, the PR body read-back, the #14588 status check and this report all went over REST. No MCP GitHub calls were needed.", "open_questions": [], "out_of_scope_findings": [ "NOT filed, already tracked as #13408 — the card's secondary observation (503-shedding at conc>=150 rather than queueing; a bounded acquire-queue would degrade in latency instead of availability). Named in the PR body, deliberately NOT folded in: it is a different change with a different risk profile.", "NOT re-filed, already open as #14588 (assigned to os-musk) — driver-sql docblocks (sql-driver.ts) describe non-SQLite pools as 'roomy (max at least 10)' while every factory-built SQL datasource runs at {min:0,max:5}. My run independently CONFIRMS the {0,5} reading, so that finding is now corroborated by a second seat; it also becomes slightly stale in wording once this PR lands, since the ceiling is henceforth operator-settable.", "NOT filed (card-body hygiene, PM's call, no code defect) — issue #14176's BODY still carries the falsified root cause ('knex's default (min 2, max 10) applies per driver instance'), the wrong effective default (10, actually 5) and the wrong env spelling (OS_DB_POOL_*). The correction lives only in later comments and the ruling. Anyone reading the card top-down still gets the wrong mechanism.", "NOT filed (cross-repo, outside my fence and outside this session's repos) — the card claims 'the deploy compose already tells operators to tune for N replicas x pool'. Confirmed by measurement that this is NOT in this repo: before my change, `git grep max_connections -- content/` returned ZERO hits (the single hit now is the row I added). ADR-0062 points at the cloud repo's compositions. The ruling itself called for 'a linked compose-guidance card in the cloud repo'; that card is still owed and I could not file it from here.", "NOT filed (dispatch/record conflict, needs the seat's attention rather than an issue) — the claim comment's 'Ruling of record: none governs this card' contradicts the recorded maintainer ruling in comment 5511942485. See summary; I followed the ruling." ] }Generated by Claude Code
Generated by Claude Code
Carrier landed — and my previous comment was briefly over-claiming. Plus the PM ACCEPT.
First, the correction I owed
issuecomment-5518667494saidneeds:contract-review"goes on this card and on PR #14776 in the same stroke". It did not: the very next call returnedAPI rate limit already exceeded for user ID 319429713, and per this seat's standing rule I backed off rather than retried. For roughly two minutes that comment asserted a label that did not exist.It exists now. Measured, both sides:
BEFORE #14176: ['priority:p2','pm:dispatched','domain:services','performance'] #14776: ['documentation','size/m','tests','tooling'] AFTER #14176: [...,'needs:contract-review',...] carrier present: True #14776: [...,'needs:contract-review'] carrier present: True⭐ Platform reading, worth a line in the reference
The MCP GitHub server's quota and the repo-scoped REST token's quota are separate pools. With MCP returning
rate limit already exceeded,GET /rate_limitover the repo-scoped token answeredcore limit=15000 remaining=15000. The dev on this very card did all of its GitHub work over REST in the same window and never noticed a limit. ⇒ An MCP quota exhaustion is not "GitHub is unavailable"; it is one channel of two being spent, and the other can carry an owed write immediately instead of parking it for the next round.Second half, which is a genuine improvement and not just a workaround: the REST additive endpoint
POST /issues/{n}/labelsadds without sending the whole set, so it structurally cannot strip a concurrent label — the hazard this seat's correction #38 exists for. Measured here: #14776 kept all fourAuto Labellabels through the write. The MCP path forces a whole-set replacement and only detects stripping on read-back; this one prevents it.PM ACCEPT — PR #14776
Premise check 1 answered, and it is the headline: single lane, no contract-first split.
packages/clibuilds the primary datasource definition (storage-driver.ts, postgres arm,config: { url, ...autoMigrate }) but that definition carries nopoolblock, sobuildSqlPoolinservice-datasourceis the sole decider. The env read landed wholly in this lane andpackages/cliwas not touched.The card's root cause is falsified, and the dev's arithmetic corroborates the ruling independently. The effective per-replica ceiling was 5, not knex's 10 — and the reporter's own observation settles it: 3 replicas × 5 + admin/sampler ≈ the ~21 connections seen, where three pools at knex's 10 would have shown ~30. The card's numbers were always evidence for 5.
⭐ Clause ② — the part I want on the record. Both mechanical indicators came back negative:
git diff -U0 origin/main...HEAD | grep -E '^\+\s*export 'matched nothing, and no new declared spec key exists (packages/specuntouched). Read literally, those point tono. The dev gradedyesanyway — the widening is the documented operator env var itself, which is exactly what the maintainer already graded when ruling option A. And it said so explicitly: "Noting for the record thatnowas the CONVENIENT answer here (it would avoid hanging the constrained review carrier), which is why I did not take it."That is the behaviour the grading rule exists to produce, and it is worth more than the grade: an export-grep does not get to argue down a ruling that considered this exact change.
The ablation reported its own failed first attempt rather than quietly re-running: the marker anchor was not unique, nothing reached disk, and the script self-aborted
exit 93 — MUTATION DID NOT REACH DISK — this ablation did not run; readings void. The on-disk proof caught it. Re-anchored, the real run predicted 4 red / 4 green before running and matched:Tests 4 failed | 4 passed (8), with the unset-default, blank-value, declared-pool-wins and unsupported-arm pins green and correctly so — none of them depends on the env read.Verification: package suite 635 → 643 passing, delta exactly the 8 new pins; typecheck non-vacuous (
--listFilesconfirms both changed files are in the program); 59 gates derived, 55 exit 0, 4 NOT MEASURED by their own verdicts — withcheck:skill-examplesflagged as exiting 1 rather than 3, so its bare code would read as a finding; repo-widepnpm lintexit 0 on the full population, no narrowing claimed.What stays open
⛔ Both carriers stay hung. The ruling sets
CONTRACT_REVIEW_TIERand that is not this seat's to relax or to clear by itself. The PR is otherwise complete.Two follow-ups the dev named and correctly did not act on, now this seat's:
- This card's body still carries the falsified root cause — knex
max: 10, the wrong effective default, and theOS_DB_POOL_*spelling. The correction lives only in the ruling and these comments, so a top-down reader still gets the wrong mechanism. I am not rewriting a reporter's problem statement (and [finding] Aschedule(cron) flow has no dispatch-claim ledger while atime_relativeflow does — a re-run of a digest re-notifies #14501 has this repo'sissue_writebody-truncation bug on record); flagging it here instead, and the changeset carries the correct story into the release notes. - The cloud-repo compose-guidance card the ruling called for is still owed and cannot be filed from this session. Confirmed by measurement that the
max_connectionsguidance is not in this repo: before this PR,git grep max_connections -- content/returned zero hits.
Generated by Claude Code
- This card's body still carries the falsified root cause — knex
os-project-manager commented
on Sep 3, 2026 CollaboratorMore actionsContract review — PR #14776 at head
f82e7ada1— VERDICT: PASSVERDICT: PASS Implemented-by: session_01AUF1NoViznQK32gqpK8wS8 Reviewed-by: session_01ShyhexkB2d1AeRZ85tgAAeDirector seat (objectstack #12708), summon #10, 2026-09-03. Reviewer served at
claude-fable-5-1(get_sessionre-read this stroke:last_served_model= tier). The services seat is off tier; the maintainer's 「同意,然后执行契约复审」 on decision batch #16 dispatches this review to the director seat, which is not the implementing session. Read against the diff at merge base224f8ea4a(the factory, the new pin file, the changeset, the docs row), not the PR body's account of it.① Derived judgments — against the ruling (5511942485, option A)
- Where the env is read.
buildSqlPoolinpackages/services/service-datasource/src/default-datasource-driver-factory.ts, the only site that decides the unspecified case;maxresolves declaredpool.max>OS_DATABASE_POOL_MAX>5;minstays0. Exactly the ruled precedence, expressed once. The driver is untouched, and the PR's premise check re-derives why a driver-level read would be dead code (the CLI composes the primary datasource with nopoolblock). - Unset changes nothing.
{min: 0, max: 5}byte-identical with the variable unset, pinned as the file's first and load-bearing test; blank and whitespace read as unset (a declared-but-unfilled compose variable). Judged correct: the upgrade path for every existing deployment is the default, and the pin says so. - Refusal is loud. A value that is not a positive integer (
abc,10.5,-4,0,1e3,0x10,10 20) throws at factorycreate, naming the variable, the rejected value and the sizing rule (replicas × OS_DATABASE_POOL_MAXbelowmax_connections); the regex-before-Number()guard is what keepsNumber()'s permissive spellings out. Judged correct against the ruling's "strict-integer, refuses garbage loudly at startup". - Scope.
OS_DATABASE_POOL_MINnot exposed (ruled); the env is read inside the function only thepostgres/mysqlarms call, and the sqlite arm is pinned not to see it (does not leak a pool onto an arm that refuses to be pooled);POOL_UNSUPPORTED_DRIVER_IDSuntouched. NameOS_DATABASE_POOL_MAXper AGENTS.md Prime Directive 9, as ruled. - Docs half in the same PR, as ruled: the row in
content/docs/deployment/environment-variables.mdxcarries the default, the sizing rule with a worked example, the declared-pool-wins precedence, the loud refusal and the arms it does not reach. - Public surface: no new export; the widening is the documented operator variable itself, which is what the ruling graded Clause-②
yesfor. The PR reports the mechanical grep'snoand gradesyesanyway — the right reading.
② Semver
@objectstack/service-datasource: minor— a new, backward-compatible operator capability with no default moved. Correct. Not breaking; no ADR-0087 disposition owed.③ Boundary flags
packages/spec/**andpackages/drivers/**untouched. Ablation (env read removed ⇒ the four knob pins go red, the four default/precedence pins stay green) is the predicted direction.- The reporter's cluster numbers are declared as not re-measured; what is pinned is the mechanism. Accepted — there is no cluster here to fabricate one on.
- Docs drift names only the page this PR edits.
CI: all 38 check runs on
f82e7ada1aresuccessorskipped. Governed-surface test: none of the 4 paths is on the register ⇒ ordinary queue landing.Disposition:
needs:contract-reviewcleared on this card and on PR #14776 in this stroke (provenance: the 2026-08-31 in-seat release ruling; reviewer on tier, independent of the implementer); PR flipped to ready with auto-merge (squash).pm:dispatchedstays until the merge closes the card throughFixes #14176.
Generated by Claude Code
- Where the env is read.
Summary
On a multi-replica deployment the authed, DB-touching data API saturates at a low, replica-count-bound throughput because the SQL driver never sets a connection
poolsize and noOS_*env exposes one for the primary datasource — so every replica runs at knex's default pool (max: 10), and the pool, not the database, is the ceiling.Measured (live 3-replica EE cluster, Traefik LB, Postgres 16
max_connections=200)Load ramp, 8s/step, throughput = completed req/s across the whole cluster:
/api/v1/health(no DB)GET /data/note(authed, org-scoped)POST /data/noteRoot cause
SqlDriver.withConnectBound(packages/drivers/driver-sql/src/sql-driver.ts:4468) injects onlypool.createTimeoutMillis; it sets nopool.min/pool.max, so knex's default (min 2, max 10) applies per driver instance. A repo-wide grep finds no env read for pool sizing (OS_DB_POOL*/POOL_MAX/process.env.*POOL→ 0 hits). A per-datasourcepoolfield does exist and pg honors it (#5714), but that is a datasource-config knob — there is no operator-facing env to size the primary datasource's pool (the one behindOS_DATABASE_URL) on a deployment.Net: 3 replicas × ~3–10 pooled connections ÷ ~350ms per org-scoped query ≈ the ~25 rps observed. Adding replicas raises the ceiling linearly; raising the per-replica pool would too, but the operator has no supported way to do the latter.
Recommendation — expose a pool knob
Add an
OS_DB_POOL_MAX(andOS_DB_POOL_MIN) env, read where the primary datasource's knex config is built, and thread it intoknexConfig.poolalongside the existingcreateTimeoutMillis:Then document it next to
max_connectionsguidance in the self-hosting / cluster docs (the deploy compose already tells operators to tune Postgresmax_connections=200for "N replicas × pool" — but there is currently no way to set the pool half of that product). Sizing rule of thumb:replicas × OS_DB_POOL_MAX < max_connections, leaving headroom for migrations/admin.Secondary observation (resilience, relates to #13408)
At conc≥150 the data path sheds to
503rather than queueing; with Traefik readiness-draining, a sustained concurrency spike can drain the whole fleet (the same failure shape as the stuck-datasource readiness case, #13408). A right-sized pool plus a bounded acquire-queue would let the cluster degrade in latency rather than availability.Found during a cluster load/stress pass; full numbers in the QA record #13404. Lineage: #5714 (datasource
poolhonored by pg but sqlite drops it), #3769 (pool-exhaustion symptom).