Uh oh!
There was an error while loading. Please reload this page.
docs(service-settings): state getMany's all-or-nothing key validation on the declaration that owns it - #12169
Conversation
… on its own declaration `SettingsService.getMany` validates every requested key against the namespace manifest before reading any env override and before loading any row, so one undeclared key rejects the whole call with `UnknownKeyError` (`code: 'SETTINGS_UNKNOWN_KEY'`) and the caller gets nothing — not the subset it was entitled to. N per-key `get()` calls part ways on exactly that input. The doc comment was otherwise detailed and claimed row-for-row equivalence with per-key `get` "BY CONSTRUCTION" — true for every key that resolves, and not for the refusal. It now draws that line, states the blast radius, and says what a caller against a partial manifest should expect. Adds a sibling pin that asserts the property rather than the throw: the error envelope, that zero rows were loaded (undeclared key last in the request), and that per-key `get()` still answers each declared key. The pre-existing pin asserted only `rejects.toThrow(/nope/)`, which stays green whatever the blast radius is — measured: it survived an ablation that deferred validation until after the row load, while the new pin turned red. No behaviour change: the implementation file's diff is 34 added lines and 0 removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NDGG54XF5gbTLdQzCtnaVV
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 7 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin bbd6041a28d7b50ea544a90d795cf532b448a9df && git checkout bbd6041a28d7b50ea544a90d795cf532b448a9df
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 71f9cd1d753a595d130406d72367c1069274de37 5067b790d95b8f969806d2dc669b9b0f78e368ba && git checkout -B drift-repro 71f9cd1d753a595d130406d72367c1069274de37 && git merge --no-ff 5067b790d95b8f969806d2dc669b9b0f78e368ba
node scripts/docs-audit/affected-docs.mjs --json 71f9cd1d753a595d130406d72367c1069274de37
|
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11680
Documentation and a pin. No behaviour change — the implementation file's
diff is 34 added lines and 0 removed (
git diff --numstat), all of theminside one doc comment.
Routing deviation — read this first
Triage routed this card to
domain:specon the premise that "theSettingsService.getManydeclaration" lives inpackages/spec. Measuredagainst
origin/mainat577fabf, it does not:git grep getMany -- packages/spec→ zero hits.packages/spec/src/contracts/holds 49 non-test contract modules; none isa settings service, and the only
SettingsServicementions anywhere underthat directory are three prose references inside
crypto-provider.ts.SettingsServiceinterface declarations in the repo areSettingsServicePluginOptionsandSettingsServiceOptions, both inpackages/services/service-settings/— neither declaresgetMany.So the contract that owns
getManyis the concrete method's own doc comment inpackages/services/service-settings/src/settings-service.ts— which is exactlywhere the issue body pointed. This PR touches no
packages/spec/**path,so the spec-surface review limb does not apply to it; the card's domain label
is the PM's to correct.
The issue's own premise is intact and was verified line by line before any
edit.
The rule, as measured
getManychecks every requested key against the namespace manifest beforeit reads a single env override and before it loads a single row:
One undeclared key therefore rejects the whole call —
UnknownKeyError,code: 'SETTINGS_UNKNOWN_KEY'— and the caller receives nothing, not thesubset it was entitled to. N per-key
get()calls part ways on exactly thatinput: each declared key still answers, and only the undeclared one throws.
The doc comment was otherwise detailed — it explained the grouped row load and
the env-override ordering, and claimed row-for-row equivalence with per-key
get"BY CONSTRUCTION". That claim holds for every key that resolves and notfor the refusal, and the comment did not draw the line.
getMany's doc commentnow states the rule, its blast radius, why validating ahead of the grouped walk
makes the refusal independent of key order and scope grouping, and what a caller
against a partial manifest should expect.
setManyalready pre-flights itswhole patch the same way, and the comment says so.
The pin, and why the existing one was not enough
settings-getmany.test.tsalready assertedawait expect(svc.getMany('localization', ['timezone', 'nope'])).rejects.toThrow(/nope/)— green whatever the blast radius is. The new sibling pin asserts the property
instead:
code: 'SETTINGS_UNKNOWN_KEY'plus the message clause).This is a service-layer error class carrying
codeand nostatus—there is no HTTP boundary here, so
codeis the whole machine-readableenvelope there is to assert;
engine.findcalled 0 times) with the undeclared keyplaced last in the request — that is the "up-front" half, and it is the
half the old pin cannot see;
get()still resolves each declared key on the sameinput, asserted on the cascade layer rather than the literal (this
fixture stores JSON text in
valuewhile the service persists valuesverbatim — see the finding below).
Ablation
Deferred
getMany's validation until after the grouped row load, then restored../settings-service.jsrelatively,which vitest resolves to
src/— no packageexports→disthop isinvolved, so no rebuild is required for the mutation to take effect. Stated
because a rebuild-free ablation is the exception, not the default.
if (!reg.scopes.has(key)) throw new UnknownKeyError(namespace, key);in thefile went 3 → 2 (the
getManyoccurrence gone;getandsetManyuntouched), and the mutated
getManyhead was dumped and read back.count back to 3. The script carried
trap … EXIT INT TERM, and the trapfired.
expect(engine.find).toHaveBeenCalledTimes(0)— "expectedvi.fn()to becalled +0 times, but got 2 times" — while the pre-existing
toThrow(/nope/)pin stayed green through the same ablation (6 passed / 1 failed). That is
the measurement the new pin exists for.
"expect 4" where 3 injection sites can only produce 3. The stated expectation
was wrong, not the observation; the load-bearing on-disk proof is the 3 → 2
throw count and the head dump, both of which held.
Verification
Union run at
5067b79, after the final commit.pnpm --filter @objectstack/service-settings typecheck(tsc --noEmit,script name echoed — not a zero-match pass) and
… test→ 29 test files,515 tests passed.
pnpm --filter @objectstack/service-settings exec vitest run src/settings-getmany.test.ts→ 7 passed (7).
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(no hand-built path list), all exit 0:
check:nul-bytes,check:error-code-casing,check:changeset-gate-self-tests,check:objectui-changeset,check:published-files,check:slot-lookup,check:test-source-alias,check:type-source-resolution,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check-plugin-teardown-shape,release-rehearsal-clone --self-test,docs-audit/check-affected-docs,and the convention-triggered set for a test edit —
check:query-options-erasure,check:engine-double-contract,check:where-matcher,check:cross-package-test-inputs,check:type-check-coverage.npx eslint . --no-inline-config --format json— the repo-wide run, not anarrowing: 5220 files resolved by eslint's own config, 0 errors, 0
warnings. (Type-aware linting is off — the config states "no
parserOptions.project, no typed@typescript-eslintrules".)check:type-check-debt --re-measure, whichrefuses on an unbuilt worktree and needs the whole workspace closure built —
a repo-scale run CI owns.
docs-audit/check-drift-commentneeds PR context.Out-of-scope finding, not filed
settings-getmany.test.ts'sROWSfixture stores JSON text invalue(
'"America/New_York"'), butsys_setting.valueis aField.jsoncolumn andthe service writes
storedValue = rawValueand readsreturn row.value ?? null— verbatim both ways, with the driver owning the JSON codec. The fake engine
skips that codec and returns rows as written, so every value in this fixture
resolves one JSON encoding deep. It is invisible today because every assertion
in the file compares
getManyagainstget, and both are equally affected —the same "a fake that lies consistently" shape this file's own header warns
about for
$ormatchers. It cost one test iteration here and would mislead thenext author who asserts a concrete value.
Not filed as an issue: the dedup search this repo requires before filing goes
through the REST list endpoint, and from this session that endpoint answers
"GitHub access is not enabled for this session" (
ghis not installedeither). Reported to the PM instead rather than filed without a duplicate
check.
Generated by Claude Code