Skip to content

fix(rest): /analytics/dataset/query carries a producer-marked userMessage on its three hand-built terminals (#12710) - #12731

Merged
os-litant merged 1 commit into
mainfrom
claude/issue-12710-analytics-fault-user-message
Aug 27, 2026
Merged

fix(rest): /analytics/dataset/query carries a producer-marked userMessage on its three hand-built terminals (#12710)#12731
os-litant merged 1 commit into
mainfrom
claude/issue-12710-analytics-fault-user-message

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes#12710

POST /api/v1/analytics/dataset/query (and its environment-scoped twin) builds its
error envelopes by hand and shares no exit with the /data door, so #9934's
producer-marked userMessage — which that door applies once at its exit,
branch-agnostically, through withDeclaredUserMessage — was applied at none of them.
A producer's caller-facing sentence reached the client on POST /data/:object and
vanished here for the identical throw.

Verified at 09123bf25.

⚠️ The card's arm count was measured here, and it disagrees with the card

The card (and the claim's ruling 2) reports this door's classified arm as already
correct and scopes the fix to the two 5xx terminals. Measured on 4af6c4419, that
holds for one of its two classified arms and not for the other.
Four terminals live
in that catch; three dropped the mark:

armref (packages/rest/src/rest-server.ts @ 4af6c4419)envelopebefore
① declared 4xx ADR-0112 passthrough:9169-9170hand-built { code, message }⛔ no mark
①b classifiedRefusalAnswer re-dress:9213-9219{ ...refusalFields, message }✅ carried it
③a declared 5xx relay:9346-9348declaredServerFaultAnswer's body, sent verbatim⛔ no mark
③b generic 500 ANALYTICS_QUERY_FAILED:9359hand-built { code, error }⛔ no mark

①b is the arm the card measured — it does carry the mark, because its body comes
from resolveErrorResponse, whose arms ride it already. ① is a different arm: it is
read first, it serves every producer that declares code + a 4xx status (the
INVALID_FILTER / CUBE_NOT_FOUND / INVALID_FIELD / DATASET_INVALID families this
door's own comments enumerate), and it re-shapes the envelope by hand without the mark.

Driven through the real route, against the flat /data door for the identical throw:

throw { code: 'INVALID_FILTER', status: 400, userMessage: 'Check the filter…' }
① analytics : 400 {"code":"INVALID_FILTER","message":"Unsupported filter operator …"}
/data door : 400 {"error":"Unsupported filter operator …","code":"INVALID_FILTER",
"userMessage":"Check the filter…"}

① is repaired in this PR, named here because it is outside the card's stated scope.
It is the same defect class on the same door, the same mechanical repair with its
correct form already pinned by the /data door and by ①b, in a file no other open PR
holds, under the same gate families — so it adds no verification surface. Leaving it
would have shipped a PR whose own prose asserted a falsehood, and queued a fourth card
in this chain. ⭐ If the reviewer prefers the card's stated scope, the ① hunk and its
§1/§4 pins are the separable half
— say so and they come out into their own card.

The census that chose the layer (ruling 1)

declaredServerFaultAnswer has exactly 2 consumers in packages/rest/src:

#consumercarries the mark?
1classifyDataError (error-response.ts:1009), whose only caller is the exported mapDataError = withDeclaredUserMessage(error, classifyDataError(…))yes — applied one layer OUT, branch-agnostically over every arm
2this route's ③a (rest-server.ts:9346)⛔ no

So this is the "some already have it" case, and the reason is visible rather than
unknown: the mark is a door-exit rule, not an arm rule.#9934 deliberately applies
it once per door, outside classification; the shared body-builder carries none of its
own because its /data consumer's wrapper already does. Repairing the shared function
would therefore be a no-op duplicate for consumer 1, would still miss ① and ③b (neither
calls it), and would split a one-per-door rule across two layers. The repair is at the
call site
, resolved once for the whole catch — which is also what lets one expression
serve all three arms.

The repair

boundedDeclaredUserMessage (exported by #12693) — declaredUserMessage's presence
answer with #5423's bound applied — asked of the raw thrown error, since none of the
three arms holds a classification to ride on. ⛔ ①b is deliberately untouched: a second
application there would be one rule applied twice. Confirmed for these arms rather than
assumed (ruling 4): all three hold the raw error, not a refusal.body, so the value
source is the record-share family's, not #12669's.

No export is added. Re-measured (ruling 4): boundedDeclaredUserMessage occurs 0
times in packages/rest/src/index.ts (positive control: 17export lines) — same
numbers the claim reports.

Clause ② criterion — falsified, and it holds

Criterion: no existing key moves or changes value. Exactly one optional key is
added, only when the producer marked one. Pinned per arm as an explicit key-order
assertion for an unmarked producer (§5): ① stays ['code','message'], ③a stays
['error','code'], ③b stays ['code','error'], each with its exact body. A blank,
empty or non-string mark is not a declaration and invents no key. Because the layer
chosen is the call site, the criterion has only one door to hold on — /data is not
touched by this diff at all.

Anti-vacuity

  • Reproduced before repair at 4af6c4419 (branch base): the pin ran 10 failed /
    3 passed
    , §4's /data half green as the positive control while the analytics half
    reported the analytics door dropped the producer's userMessage: {"error":"Internal server error","code":"READ_SCOPE_COMPILE_FAILED"}.
  • Ablation (the three ...markExtra spreads deleted): 12 failed / 5 passed,
    ABLATION_VITEST_EXIT=1. The 5 survivors are §5 — the absence assertions, which an
    unrepaired door also satisfies, exactly as predicted. What reds is the positive
    control: every assertion that a marked producer's sentence reaches the body.
  • Mutation confirmed on disk two ways before any verdict was read: anchored count
    ...markExtra 3 → 0, and blob d23f5c009…47fdb04b5….
  • Restore proved by state, not by an exit code: git checkout HEAD -- naming the
    file's ABSOLUTE path (never a bare double-dash form, which restores from the polluted
    index), trap … EXIT INT TERM, restored blob d23f5c009… equal to the
    HEAD blob, git diff HEAD empty. The fix was committed first, so the restore point
    was real.

Verification (all at 09123bf25, each through the shared verify lock)

  • pnpm --filter @objectstack/rest test153 files / 2539 tests passed, VERDICT command-exit 0.
  • pnpm --filter @objectstack/rest typecheck — green; its test half prints
    check:test-typecheck: OK — @objectstack/rest's test layer compiles under packages/rest/tsconfig.test.json; 9 file(s) / 20 error(s) held in test-typecheck-debt.json (shrink-only) — unchanged. The new pin is in that
    program (--listFiles: 1 hit; positive control, an existing sibling test: 1 hit) and
    contributes 0 of the 20 held errors.
  • pnpm lint (repo-wide eslint . --no-inline-config) — VERDICT command-exit 0.
  • Gate families from node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
    (re-derived from the committed change set, not from a hand-written list): 24
    path-derived + 6 convention-triggered, all exit 0, including
    check:route-envelope, check:dispatcher-error-vocabulary, check:nul-bytes,
    check:cross-package-test-inputs, check:engine-double-contract,
    check:where-matcher, check:type-check-coverage, check:published-files.
  • NOT MEASURED, declared:scripts/pm/check-half-states.mjs exits 3 — its own
    text says "it is no reading at all" (the trigger-file index needs GitHub API access
    this seat does not have). And check:type-check-debtrefuses on an unbuilt
    closure — "measuring now would not fail, it would silently measure a DIFFERENT
    WORLD" — which is a refusal, not a red. Its ratchet for this package is the
    check:test-typecheck reading above, measured green with the new file inside the
    program. CI runs both properly.

Reachability — stated plainly, ⛔ not inherited from #12693

Producer census for this door, run here with positive controls: zero in-repo
producers can reach these terminals with a mark. No file under packages/services/**
contains userMessage in any form (positive controls in service-analytics/src: 102
code: hits, 278 status hits), and that service dispatches no sandbox hook (positive
control: 1013 await hits), so the QuickJS side-channel — the other in-repo carrier —
does not reach it either. Repo-wide, userMessage lives only in the channel packages
themselves (spec, types, rest, runtime, client) plus one qa/dogfood file.

So this wires up a declared channel that was not connected at these three arms, not
a report that anyone is being harmed today. The published contract already promises the
field on this door's envelope (content/docs/references/api/analytics.mdx,
ApiError.userMessage); the intended producer is an out-of-repo one — an app author's
analytics datasource or strategy.

#12693's plugin-sharing producer count of 0 is the share family's number and was
not reused; the 0 above is this door's own, measured with its own controls.


Generated by Claude Code

…e on its three hand-built terminals
The route builds its error envelopes by hand and shares no exit with the /data
door, so #9934's producer-marked `userMessage` — applied there once at the exit
through `withDeclaredUserMessage`, branch-agnostically — was applied at none of
them. Scope is by ARM: ① (declared 4xx passthrough), ③a (declared 5xx relay) and
③b (generic 500) all dropped it; ①b already carried it, because its body comes
from `resolveErrorResponse`, and it is deliberately untouched.
The value is `boundedDeclaredUserMessage` (#12693), resolved once for the whole
catch. Exactly one optional key is added, only when the producer marked one; no
existing key moves or changes value at any arm.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: /data/:object (route, 64 pages)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 13 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d29e42f8b1b44b92ea58c1d05e619f27c027c3a9packageMentionDocs.

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-litant@claude