fix(service-analytics): a row-scope refusal carries a declared envelope, so queryDataset stops classifying refusals by their wording - #17336
Conversation
… source `queryDataset` classifies a BARE error by its words, and three of the six limbs (`not registered`, `unknown object`, `is not a registered object`) are the phrasings a registry or security refusal reaches for. The guard walks every `throw` in this package's non-test sources with the TypeScript AST, resolves each one's wording through in-package message helpers, and asserts none of the un-enveloped ones can be read as a driver reporting an absent table. Population and verdict are both derived: the sites from the AST, the verdict from `isMissingSourceError` itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…sed refusals
`queryDataset` re-throws any error whose producer declared `code` + `status`
and classifies everything else by wording — six substrings, three of which
(`not registered`, `unknown object`, `is not a registered object`) are what a
registry or security refusal reaches for. A hit is not a wrong status code, it
is `{rows: [], fields: [], totals: []}`: a fail-closed gate served as a
confident empty chart.
The row-scope RESOLUTION stage refused bare on both of its sites, and
propagated only because its text happened to miss all six. Both now carry
`READ_SCOPE_COMPILE_FAILED` / 500 — the code the sibling LOWERING stage has
owned since #5367, so no ledger row is added — through one constructor. Every
message is byte-unchanged: the fix is the declaration, not a luckier string.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…D / 500 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…clared-envelope-on-analytics-refusals
📓 Docs Drift CheckThis PR changes 1 package(s): 1 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 — 9 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 7f8c3ea619cf8bc8a460d6d5fa100096b25ddaca && git checkout 7f8c3ea619cf8bc8a460d6d5fa100096b25ddaca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cca1dc0bfb1e380442670843606d6a0c7762f6ba d1cd397d4810f50e519fe351d616ad4be42a9aa8 && git checkout -B drift-repro cca1dc0bfb1e380442670843606d6a0c7762f6ba && git merge --no-ff d1cd397d4810f50e519fe351d616ad4be42a9aa8
node scripts/docs-audit/affected-docs.mjs --json cca1dc0bfb1e380442670843606d6a0c7762f6ba
|
Fixes #17335
Clause-②: no — noerror-code-ledgerrow is added. The existingREAD_SCOPE_COMPILE_FAILED, already owned by@objectstack/service-analytics(packages/spec/src/api/error-code-ledger.zod.ts:783) and registered for exactly this condition, is reused. Nopackages/spec/**edit, no new exported symbol on any barrel (isMissingSourceErroris exported from its module for the guard to ask, and stays absent fromindex.ts).The defect
queryDatasetdecides between re-raising and degrading to an empty chart. A bareErrorcarries neitherstatusnorcode, so it misseshasDeclaredErrorEnvelopeand falls through toisMissingSourceError, which judges by wording — and three of its six substrings (not registered,unknown object,is not a registered object) are exactly the phrasings a registry or security refusal reaches for.PR #17125's fail-closed row-scope refusal propagates today only because its wording happens to match none of them. ⛔ A coincidence, not a construction: the next reword turns a fail-closed gate into a 200 with no rows.
Two legs, and the cheaper one is the one that keeps working
1 — the guard (landed first, deliberately).
refusal-wording-collision.test.tsderives its population from source: everythrowin every non-test.tsunder the package'ssrc/, read through the TypeScript AST, with wording resolved transitively through in-package message helpers, and the verdict asked of the realisMissingSourceError. It asserts no bare refusal this package raises can match it.⇒ 22 files, 87 throws, 71 carrying wording, 0 collisions.
⭐ This is the half that protects the next reword rather than this one message. ⛔ Its population is derived, never hand-listed — a hand-listed corpus rots the same way the thing it guards did.
2 — the fix.
readScopeUnresolvedError(src/read-scope-refusal.ts) stampsREAD_SCOPE_COMPILE_FAILED/ 500 on both row-scope resolution refusals —plugin.ts's security bridge andAnalyticsService.resolveReadScopes— soqueryDatasetre-throws them athasDeclaredErrorEnvelopeand never reaches the heuristic.isMissingSourceError's subtraction list is untouched; PR #17125's refusal is not reworded; every message is byte-unchanged; no refusal is made likelier to degrade.Why an existing code rather than a new one
The ledger was enumerated with two independent legs that reconcile: a real JS parse of the
ERROR_CODE_LEDGERobject literal (lines 190..1266) and a line-anchored regex over the same byte range — both answeringowners=29 rows=324 uniqueCodes=282, identical sets.READ_SCOPE_COMPILE_FAILEDis registered for "RLS read-scope lowering failed fail-closed — a SERVER fault (500), never the caller's", which is this condition. ⇒ no ledger row,Clause-②staysno, and the PR is landable without aCONTRACT_REVIEW_TIERverdict.READ_SCOPE_UNRESOLVEDwould be purely additive over what lands here (swap the constant, keep the constructor, keep every message), so choosing reuse now forecloses nothing.PERMISSION_DENIED/ 403 was rejected on a measured ground, not taste: at 4xx the boundary echoes the message, so "read-scope resolution failed for X" would tell a tenant that this deployment's security service is broken. The declared 500 withholds it — the disclosure posture #5367's maintainer ruling settled for this family.Evidence (measured at
d1cd397d4, pre-suspension)Own package — 104 files / 2243 tests green; typecheck clean, and proven to cover the new files rather than exclude them (
tsc --noEmit --listFilesplaces both new test files in the program).Acceptance 1 (positive) — three colliding wordings (
not registered,unknown object,is not a registered object), raised through the package's own constructor, all propagate out of the realqueryDataset. ⛔ Never a 200 with an empty chart.Acceptance 2 (negative control) —
no such table, Postgres's realrelation "x" does not existand MySQL'sdoesn't existstill degrade to{rows:[],fields:[],totals:[]}with the "backing object … is unavailable" warn; an absent security service still runs unscoped; the pre-existingdataset-degradation-envelope.test.ts(11 cases) green unchanged.Acceptance 3 (guard controls) — an in-suite synthetic control (one bare colliding throw + one enveloped one; exactly the bare one reported) and a live control on the real tree: BEFORE=0 / AFTER=1 marker counts proving the mutation hit disk,
plugin.ts:1142named in the failure,Tests 1 failed | 3 passed. Restored and proved by state (blob == HEAD blob, emptygit diff HEAD, emptygit status --porcelain), thenTests 4 passed.Acceptance 4 (ablation of the fix) — prediction written first; mutate+measure in ONE shell under
trap … EXIT INT TERM; on-disk proofBEFORE_CODE=1/BEFORE_STATUS=1 → AFTER_CODE=0/AFTER_STATUS=0. ResultTests 6 failed | 13 passed— exactly the predicted 6, in two distinct shapes: three rows fail oncode/statusundefined, and three fail with "a fail-closed refusal was served as an empty chart" — i.e.queryDatasetresolved, the defect reproduced. ⭐ Predicted green and green: all four wording-guard cases and all three #5033 negative controls — stripping the envelope is invisible to a wording check, which is why the envelope needed pins of its own.Acceptance 5 (cross-package sweep) — population derived two ways and reconciled: leg A = every workspace manifest depending on
@objectstack/service-analytics(6 packages); leg B = every file outside the package naming it (81 files / 18 packages), narrowed to real imports (5 packages, all inside leg A). Positive control: leg A independently contains@objectstack/runtime(owner ofanalytics-query-read-scope-withhold.test.ts, which pins the wire answer for this very family). Results: service-analytics 104/2243 · rest 187/3118 · runtime 251/3531 · client 41/492 · verify 14/103 · cli 190/2644 · dogfood 135 files + 1064 tests.Repo-wide lint, not narrowed — full
eslint . --no-inline-config: 6477 files, 0 errors, 0 warnings, exit 0.Gates — 61 families derived, 61 run.
--ranprinted0 NOT-MEASURED; that zero is the runner's claim and is annotated by hand instead:check:dual-build-cjs-loadsandcheck:type-check-debteach exited 3 (PREREQUISITE NOT MET) on their first run and were re-run to exit 0 once their prerequisites existed — ⛔ neither counted as a pass on the strength of the first run. Four artifact-roster families whose roster sits under a directory this diff touches were run explicitly (silence is evidence in neither direction), pluscheck:error-code-provenancebecause this diff stamps an error code: all PASS.Known, recorded, not fixed here
missingSourceRelationhas a second arm a bare colliding refusal can land in:"… unknown object in the resolved scope"makes the extractor read the relation name asin, soqueryDatasetre-reports the refusal as a cross-datasource topology error — loud, but describing a JOIN that does not exist. Both arms are wrong for a security refusal; this card is about the silent one, and the envelope closes both arms for the refusals this PR touches. ⛔ Not filed separately because it is the class this card fixes.Generated by Claude Code