You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[finding] a bare-Error refusal reaching queryDataset is classified by WORDING — a security refusal whose text happens to contain "not registered" becomes a 200 with an empty chart #17130
Filed by the domain:services PM seat (#6021) from PR #17125's out_of_scope_findings (card #16918), and verified on origin/main71629a151 rather than taken from the report.
The mechanism, measured
packages/services/service-analytics/src/analytics-service.ts wraps dataset execution and, on a throw, decides between re-raising and degrading to an empty result:
}catch(err){if(hasDeclaredErrorEnvelope(err))throwerr;// :1321 — the escape hatchif(isMissingSourceError(err)){// :1322 — else, judge by WORDING…// → a warn and a confident empty chart
hasDeclaredErrorEnvelope (:141-144) is true only when the error carries bothstatus: number and a non-empty code: string. A bare Error carries neither, so it falls through to:
functionisMissingSourceError(err: unknown): boolean{// :241constraw=String((erras{message?: unknown})?.message??err??'');if(isMissingColumnOfRelation(raw))returnfalse;constmsg=raw.toLowerCase();return(msg.includes('no such table')||/relation\s+…\s+doesnotexist/i.test(raw)||msg.includes("doesn't exist")||msg.includes('not registered')||// ⇐msg.includes('unknown object')||// ⇐msg.includes('is not a registered object')// ⇐);}
⇒ Any bare Error whose message happens to contain one of those substrings is reclassified as "the source table is gone" and served to the caller as an empty chart — no exception, no 4xx, no 5xx. The file's own docblock names that outcome as the #5033 symptom it exists to prevent: "the caller's own mistake reported as 'no data', with no exception … just a warn and a confident empty chart."
⭐ Why this is filed now, and what makes it security-shaped
PR #17125 (card #16918) makes the row-scope bridge refuse the query when a wired security service is broken — a throw new Error('[Analytics] row-level read scope could not be resolved for "X"; query refused (fail-closed).').
That is a bare Error. It propagates today only because its wording happens to match none of the six substrings. ⚠️ ⛔ That is a coincidence, not a construction. The three underlined limbs are exactly the phrasings a registry or security refusal naturally reaches for: "not registered", "unknown object". ⇒ the next security refusal written in this package, or the next reword of this one, can silently become a 200 with no rows — a fail-closed gate turned back into a fail-open one by string matching.
⛔ Not a defect in PR #17125. Its text was measured, and the behaviour today is correct. What is defective is that the correctness rests on wording.
The fix direction (not a ruling)
hasDeclaredErrorEnvelope is already the principled escape hatch, and the file already says why: "The producer answered the classification question … Nothing here may re-judge it by wording." ⇒ a refusal this package raises deliberately should carry a declared envelope (code + status) so it short-circuits at :1321 and never reaches the heuristic.
⚠️The cost that makes this a card rather than a one-liner: a new code means a row in packages/spec/src/api/error-code-ledger.zod.ts — a declared T4 surface, so check-widening-tells --declaration no refuses the PR and the card carries needs:contract-review and a CONTRACT_REVIEW_TIER verdict. Whoever takes it should first establish whether an existing code fits (that would keep it Clause-② no), and only then propose a new one.
⭐ A second, cheaper half worth considering independently: the wording heuristic is documented as measured over "13 strings … the three driver families' phrasings, the framework's not-registered signals, and this package's own refusals." ⇒ this package's own refusals are already in that population. A guard that asserts no refusal this package raises can match isMissingSourceError would have caught the fragility mechanically, and would keep catching it. ⛔ A guard whose corpus is hand-listed rots the same way; derive the population from the source.
⛔ Fences
⛔ Relaxing a security boundary is the maintainer's floor. Every direction here tightens or is neutral. If a proposal would make any refusal more likely to degrade, stop and report.
⛔ Do not widen isMissingSourceError's subtraction list by hand-adding this one message. That fixes one string and leaves the class.
⚠️[finding] isMissingSourceError 仍把 Postgres 的「缺列」措辞 column "c" of relation "t" does not exist 判为「缺源」——文档明说不该,今天靠读路径不产生该措辞而无害 #6035 already records the sharp lesson for this function: the missing-COLUMN wording literally contains a well-formed missing-relation wording, so "no tightening of 'does this say a relation is missing' can ever exclude it — only asking the more specific question FIRST can. That makes the ORDER the fix, not the pattern." The same logic applies here: the answer is to ask the envelope question first, not to make the wording question smarter.
Provenance
PR #17125 (card #16918), out_of_scope_findings — "queryDataset's graceful-degradation branch turns a BARE error into an empty result when isMissingSourceError matches its wording. This refusal's text matches none of those patterns, so it propagates — but a future refusal message containing 'not registered' or 'unknown object' would silently become a 200 with no rows."
Related: #5033 (the deliberate leniency this branch implements) · #6035 (missing COLUMN vs missing SOURCE, the ordering fix) · #5717 · #11718 (the door's declared-envelope arm) · #16918 / PR #17125 (the refusal that exposed it).
Filed by the
domain:servicesPM seat (#6021) from PR #17125'sout_of_scope_findings(card #16918), and verified onorigin/main71629a151rather than taken from the report.The mechanism, measured
packages/services/service-analytics/src/analytics-service.tswraps dataset execution and, on a throw, decides between re-raising and degrading to an empty result:hasDeclaredErrorEnvelope(:141-144) is true only when the error carries bothstatus: numberand a non-emptycode: string. A bareErrorcarries neither, so it falls through to:⇒ Any bare
Errorwhose message happens to contain one of those substrings is reclassified as "the source table is gone" and served to the caller as an empty chart — no exception, no 4xx, no 5xx. The file's own docblock names that outcome as the#5033symptom it exists to prevent: "the caller's own mistake reported as 'no data', with no exception … just a warn and a confident empty chart."⭐ Why this is filed now, and what makes it security-shaped
PR #17125 (card #16918) makes the row-scope bridge refuse the query when a wired security service is broken — a
throw new Error('[Analytics] row-level read scope could not be resolved for "X"; query refused (fail-closed).').That is a bare⚠️ ⛔ That is a coincidence, not a construction. The three underlined limbs are exactly the phrasings a registry or security refusal naturally reaches for: "not registered", "unknown object". ⇒ the next security refusal written in this package, or the next reword of this one, can silently become a 200 with no rows — a fail-closed gate turned back into a fail-open one by string matching.
Error. It propagates today only because its wording happens to match none of the six substrings.⛔ Not a defect in PR #17125. Its text was measured, and the behaviour today is correct. What is defective is that the correctness rests on wording.
The fix direction (not a ruling)
hasDeclaredErrorEnvelopeis already the principled escape hatch, and the file already says why: "The producer answered the classification question … Nothing here may re-judge it by wording." ⇒ a refusal this package raises deliberately should carry a declared envelope (code+status) so it short-circuits at:1321and never reaches the heuristic.codemeans a row inpackages/spec/src/api/error-code-ledger.zod.ts— a declared T4 surface, socheck-widening-tells --declaration norefuses the PR and the card carriesneeds:contract-reviewand aCONTRACT_REVIEW_TIERverdict. Whoever takes it should first establish whether an existing code fits (that would keep it Clause-②no), and only then propose a new one.⭐ A second, cheaper half worth considering independently: the wording heuristic is documented as measured over "13 strings … the three driver families' phrasings, the framework's not-registered signals, and this package's own refusals." ⇒ this package's own refusals are already in that population. A guard that asserts no refusal this package raises can match
isMissingSourceErrorwould have caught the fragility mechanically, and would keep catching it. ⛔ A guard whose corpus is hand-listed rots the same way; derive the population from the source.⛔ Fences
isMissingSourceError's subtraction list by hand-adding this one message. That fixes one string and leaves the class.isMissingSourceError仍把 Postgres 的「缺列」措辞column "c" of relation "t" does not exist判为「缺源」——文档明说不该,今天靠读路径不产生该措辞而无害 #6035 already records the sharp lesson for this function: the missing-COLUMN wording literally contains a well-formed missing-relation wording, so "no tightening of 'does this say a relation is missing' can ever exclude it — only asking the more specific question FIRST can. That makes the ORDER the fix, not the pattern." The same logic applies here: the answer is to ask the envelope question first, not to make the wording question smarter.Provenance
PR #17125 (card #16918),
out_of_scope_findings— "queryDataset's graceful-degradation branch turns a BARE error into an empty result whenisMissingSourceErrormatches its wording. This refusal's text matches none of those patterns, so it propagates — but a future refusal message containing 'not registered' or 'unknown object' would silently become a 200 with no rows."Related: #5033 (the deliberate leniency this branch implements) · #6035 (missing COLUMN vs missing SOURCE, the ordering fix) · #5717 · #11718 (the door's declared-envelope arm) · #16918 / PR #17125 (the refusal that exposed it).