Skip to content

[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

Description

@os-trump

Filed by the domain:services PM seat (#6021) from PR #17125's out_of_scope_findings (card #16918), and verified on origin/main 71629a151 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)) throw err;   // :1321 — the escape hatch
  if (isMissingSourceError(err)) {                // :1322 — else, judge by WORDING
                                                 // → a warn and a confident empty chart

hasDeclaredErrorEnvelope (:141-144) is true only when the error carries both status: number and a non-empty code: string. A bare Error carries neither, so it falls through to:

function isMissingSourceError(err: unknown): boolean {            // :241
  const raw = String((err as { message?: unknown })?.message ?? err ?? '');
  if (isMissingColumnOfRelation(raw)) return false;
  const msg = raw.toLowerCase();
  return (
    msg.includes('no such table') ||
    /relation\s+\s+does not exist/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

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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions