Skip to content

fix(rest)!: refuse unknown keys on the dataset query body (#29156) - #29251

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-9bs2fu
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-9bs2fu

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt

Requested by Steve · project thread
Fixes #29156

Clause-②: no (narrowing)
Metadata-protocol: none

维护者速读

  • 改了什么:数据集查询接口 POST /analytics/dataset/query 现在拒收请求体里它不认识的键(400 VALIDATION_FAILED,点名该键);把 runtimeFilter 放错在 selection 外面时,提示「did you mean selection.runtimeFilter」。同时给了 dataset 又给 datasetName 也拒收。
  • 为什么改:以前放错位置的筛选条件被静默丢掉,接口照样 200,返回的是未筛选的总数,看板数字偏大且无人察觉。
  • 风险与回滚:只影响发送了多余键的调用方;客户端 SDK、控制台看板/报表/数据集预览、verify 都只发这四个键,实测不受影响。回滚即还原 analytics-routes.ts 的一处调用。
  • 先看:packages/rest/src/analytics-dataset-body-door.ts(判定逻辑与提示);packages/rest/src/analytics-routes.ts(接入点,在 selection 判定之前);packages/rest/src/analytics-dataset-body-door.test.ts。
  • 两分钟验证:pnpm --filter @objectstack/rest exec vitest run src/analytics-dataset-body-door.test.ts,预期 19 条全过(其中 10 条在 main 上为红)。
  • 需要你做的:无,席位落地。

What changes

POST {basePath}/analytics/dataset/query judged body.selection (through datasetSelectionRefusal), body.dataset and body.datasetName, and ignored every other top-level key. A runtimeFilter written beside selection instead of inside it answered 200 with the unfiltered aggregate; "bogus": 1 beside selection answered 200 too. The sibling door POST /analytics/query refuses an unknown key, and this door's own selection refuses one with a "did you mean" hint.

  • New packages/rest/src/analytics-dataset-body-door.ts: datasetQueryBodyRefusal(body) over the closed set DATASET_QUERY_BODY_KEYS = dataset, datasetName, previewDrafts, selection, measured from the handler's read points (the handler reads exactly these four off req.body).
    • An unknown key answers 400 VALIDATION_FAILED, message Unrecognized key(s) on this dataset query: ... (the sibling door's opening), one details.fields[] entry per key with code: unknown_field. The query never runs.
    • Remedy: runtimeFilter, filter, filters, where say did you mean selection.runtimeFilter?; any other selection member says did you mean selection.KEY?. The member list is read off DatasetSelectionSchema.shape, not transcribed.
    • A truthy dataset beside a truthy datasetName (the handler's own truthiness) answers 400 VALIDATION_FAILED naming both (invalid_shape). A falsy datasetName beside dataset still runs inline, and a falsy dataset beside a name still runs the saved dataset, as before.
  • analytics-routes.ts: one call, right after the body is read and before the selection.measures check, so a misplaced member is named before "selection.measures must be a non-empty array".
  • Envelope: this door's own body-refusal frame (flat { code, message, details.fields }, the one an unknown selection key already answers here), not the nested { error: {...} } the sibling dispatcher door writes. One door, one dialect.
  • Changeset .changeset/29156-dataset-query-body-unknown-keys.md (@objectstack/rest patch, BREAKING banner, Before / Now / Unchanged bullets and the remedy, adr-0087: not-required (no-migration-prescription)).
  • content/docs/data-modeling/analytics.mdx states the body shape, so it now says which keys the body takes and what is refused.

No packages/spec or ui/ edit.

Why

A dropped filter is a wider number with nothing to tell it apart from a filter that matched everything: the REST ingress policy's silent-widening case (query-allowlist.ts), on the body instead of the query string.

Producer census (before narrowing)

Producer Keys it sends
@objectstack/client analytics.queryDataset the caller's payload, typed dataset?, datasetName?, selection, previewDrafts?
Console ui/packages/data-objectstack queryDataset (dashboards, reports, charts, filter bar, dataset preview) datasetName OR dataset (a ternary), selection, previewDrafts only when true
@objectstack/verify dashboard-window.ts datasetName, selection
docs analytics.mdx, platform-checklist dashboards.json / studio-authoring.json datasetName, selection
dogfood suites (10 files) dataset or datasetName, selection
MCP / AI tools (packages/mcp, service-ai) none: no caller of this door

No producer sends a key outside the four, and none sends both dataset and datasetName, so the pair is refused in this PR (delta acceptance 3).

Tests

  • packages/rest/src/analytics-dataset-body-door.test.ts (new, 19 cases, through the real route): §1 refusals (top-level runtimeFilter on a saved and an inline dataset, filter / filters / where, a misplaced dimensions, bogus, several keys sorted, the accepted keys listed), each asserting status, code, the named keys, the remedy, and that queryDataset was never called; §2 the dataset + datasetName refusal (no lookup, no query) and its three controls (a '' and a null datasetName beside dataset run inline; a null dataset beside a name runs the saved dataset); §3 controls asserting the arguments the service received (datasetName, inline dataset, selection.runtimeFilter, previewDrafts, the unchanged selection-level refusal, the unchanged "Provide body.dataset" message).
  • packages/qa/dogfood/test/analytics-dataset-body-door.dogfood.test.ts (new, 4 cases, showcase stack over HTTP): plain body 200 with a count above 0; selection.runtimeFilter 200 and narrows to 0; top-level runtimeFilter 400 with the remedy; bogus 400.
  • No existing test needed an edit.

Verification

Head 9b9d5b7b (a fast-forward on dbf091e2, the merge of origin/main 14208f2 into the fix), objectstack-ai/objectstack. The 9b9d5b7b round aligned the dataset + datasetName check to the handler's truthiness and added two pins (a '' and a null datasetName beside dataset run inline). At 9b9d5b7b: the rest pin file 19 of 19; ablation of the old condition reds the '' pin (1 failed, 18 passed) and restores to HEAD; rest suite 368 files, 6982 tests passed; typecheck exit 0; the same 90 derived gates all exit 0. The readings below are from dbf091e2 unless stated; the dogfood battery was not re-run, since no dogfood test sends both keys.

Red first on main, green with the fix
  • Base 692e0dc8, the test committed alone (e7b0ead3): pnpm --filter @objectstack/rest exec vitest run src/analytics-dataset-body-door.test.ts → Tests 10 failed | 7 passed (17); every refusal case read expected 200 to be 400 with the body {"rows":[],"fields":[]} (the query ran), every control green.
  • With the fix (1d599e3e): Tests 17 passed (17); dogfood pin Tests 4 passed (4).
Ablation of the refusal (scripts/ablation-replace.mjs, rebuilt, dist proven)
  • Mutation: if (bodyRefusal) { → if (bodyRefusal && false) { in analytics-routes.ts (anchor 1 → 0, blob 372cafb8f9a0 → 3142e06b6c1b); @objectstack/rest rebuilt with --force; ablation-dist-preflight marker present in dist/index.cjs and dist/index.js.
  • Rest pin: Tests 10 failed | 7 passed (17), the same 10 refusals red.
  • Dogfood pin (showcase over HTTP): Tests 2 failed | 2 passed (4). The top-level runtimeFilter case read {"rows":[{"project_count":5}], …, "sql":"SELECT COUNT(*) AS \"project_count\" FROM \"showcase_project\""}: expected 200 to be 400: the unfiltered count, while the selection.runtimeFilter control on the same stack answers 0.
  • Restore: blob == HEAD 372cafb8f9a0, git diff HEAD empty; rebuilt, --absent marker absent from all 6 built files, tree clean; rest pin 17/17, dogfood pin 4/4.
Package suites and lint
  • pnpm --filter @objectstack/rest test → Test Files 368 passed (368) · Tests 6980 passed | 332 skipped, exit 0 (at 6b02dae7; the merge brought no packages/rest or analytics path).
  • pnpm --filter @objectstack/rest typecheck → exit 0 (test layer 0 errors).
  • eslint packages/rest packages/qa/dogfood --no-inline-config (root config) → exit 0.
Full dogfood battery (narrowing), final head dbf091e
  • pnpm --filter @objectstack/dogfood test under os-verify-lock → VERDICT command-exit 0 · Test Files 389 passed | 3 skipped (392) · Tests 3212 passed | 28 skipped (3240). No failure.
Derived gates at dbf091e
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack → 90 commands; all run, all exit 0. check-plugin-teardown-shape.mjs --self-test first answered exit 3 (pinned commit 621a487 missing from the shallow clone); after git fetch --depth=1 origin 621a4876… it passed (48 cases).
  • --ran reconciliation: 90 derived, 90 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero).
  • check-changeset-no-major.mjs --base origin/main --event (this body) → exit 0: no (narrowing) + none at patch. check-adr-0087-registration.mjs → not-required (no-migration-prescription) notice.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (no spec edit)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes. (eslint over the touched packages, see Verification)
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: n/a, not touched.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English (the 维护者速读 section is the required Chinese summary).
  • The Before Submitting list in CONTRIBUTING.md is done.

Acceptance notes

  • The claim wrote Clause-②: no; this refuses bodies that answered 200 before, so the PR and changeset carry Clause-②: no (narrowing) (the delta's narrowing rules: ! title, BREAKING banner, adr-0087: marker, full dogfood battery, ablation).
  • Out of scope, noted: the same route's query string is not a closed set (it reads preview through refuseRepeatedQueryParams only); the ingress policy converts routes per lane.

Generated by Claude Code

A runtimeFilter beside selection answered 200 with the unfiltered
aggregate. The outer body is now a closed set measured from the
handler's read points; dataset beside datasetName is refused too.

Claude-Session: https://claude.ai/code/session_01ADHkmQ6M1P18JzkN4aDNPb
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/l label Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest, touching 13 documentable anchor(s).

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-catalog.mdx (via invalid_shape (literal, a string literal in datasetQueryBodyRefusal), unknown_field (literal, a string literal in datasetQueryBodyRefusal))
  • content/docs/data-modeling/analytics.mdx (via datasetName (literal, a string literal in DATASET_QUERY_BODY_KEYS; a string literal in datasetQueryBodyRefusal), previewDrafts (literal, a string literal in DATASET_QUERY_BODY_KEYS), runtimeFilter (literal, a string literal in remedyFor), selection.runtimeFilter (literal, a string literal in remedyFor))
  • content/docs/ui/dashboards.mdx (via runtimeFilter (literal, a string literal in remedyFor))
  • content/docs/ui/reports.mdx (via runtimeFilter (literal, a string literal in remedyFor))

⛔ 7 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v15.mdx (via runtimeFilter (literal, a string literal in remedyFor))
  • content/docs/releases/v17/17-0.mdx (via unknown_field (literal, a string literal in datasetQueryBodyRefusal))
  • content/docs/releases/v17/17-1.mdx (via runtimeFilter (literal, a string literal in remedyFor))
  • content/docs/releases/v17/17-5.mdx (via runtimeFilter (literal, a string literal in remedyFor))
  • content/docs/releases/v17/17-6.mdx (via invalid_shape (literal, a string literal in datasetQueryBodyRefusal))
  • content/docs/releases/v17/17-7.mdx (via runtimeFilter (literal, a string literal in remedyFor))
  • content/docs/releases/v9.mdx (via runtimeFilter (literal, a string literal in remedyFor))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: VALIDATION_FAILED (literal, 39 pages)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 101 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 19 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 f6a04e3af7756446bb7a2e73973f09e011090312 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from c7842c2ce05d9395a760aeaedb1095fc6ad75dfe — the merge of head 9b9d5b7b04d69e7dc908254deca27529d35a5b9d into base f6a04e3af7756446bb7a2e73973f09e011090312, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c7842c2ce05d9395a760aeaedb1095fc6ad75dfe && git checkout c7842c2ce05d9395a760aeaedb1095fc6ad75dfe
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f6a04e3af7756446bb7a2e73973f09e011090312 9b9d5b7b04d69e7dc908254deca27529d35a5b9d && git checkout -B drift-repro f6a04e3af7756446bb7a2e73973f09e011090312 && git merge --no-ff 9b9d5b7b04d69e7dc908254deca27529d35a5b9d

node scripts/docs-audit/affected-docs.mjs --json f6a04e3af7756446bb7a2e73973f09e011090312

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f6a04e3af7756446bb7a2e73973f09e011090312 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 2, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: dbf091e2099bbdbc43dd738199f50dd7fe6d9035
Local-runs: none
Scope: packages/rest/src/analytics-dataset-body-door.ts, packages/rest/src/analytics-routes.ts, packages/rest/src/analytics-dataset-body-door.test.ts, packages/qa/dogfood/test/analytics-dataset-body-door.dogfood.test.ts + packages/rest/src/analytics-selection-door.ts, packages/spec/src/api/analytics.zod.ts (DatasetSelectionSchema, its aliases and guidance), packages/spec/src/shared/strict-object.ts and lazy-schema.ts (whether .shape is reachable through the lazy proxy), packages/runtime/src/dispatcher-plugin.ts (the sibling /analytics/query envelope, errorResponseBase), packages/runtime/src/domains/analytics.ts (assertAnalyticsQueryBody), packages/spec/src/api/errors.zod.ts (the ADR-0114 field-code vocabulary), packages/client/src/index.ts (analytics.queryDataset), ui/packages/data-objectstack/src/index.ts (queryDataset, readAnalyticsErrorEnvelope), ui/packages/app-shell/src/views/metadata-admin/previews/DatasetPreview.tsx, packages/verify/src/dashboard-window.ts, the ten existing dogfood suites that post to this door, content/docs/data-modeling/analytics.mdx. The face is the narrowing face: --template --paths on the PR's file list names no hits and defers to the changeset's Clause-②: no (narrowing) line, so the floor is every diff path outside the prose faces (the four code and test paths above); the changeset and the doc page are the lane seat's ACCEPT and were read, not reviewed.

① Derived judgments

  1. Accept-set change at POST {basePath}/analytics/dataset/query (both the plain and the /environments/:environmentId mount): the body's top-level key set closes to dataset, datasetName, previewDrafts, selection. Right. Read on the head's analytics-routes.ts: the handler reads exactly body.selection, body.previewDrafts, body.dataset and body.datasetName, and the only thing passed on is svc.queryDataset(dataset, selection, context, previewDrafts ? { previewDrafts: true } : undefined): no other body member reaches the analytics service, the metadata lookup (getMetaItems({ type: 'dataset', previewDrafts })) or the DatasetSchema parse. DATASET_QUERY_BODY_KEYS equals the read set, and nothing is stripped or rewritten (the door returns a refusal or undefined; body reaches the handler as sent).
  2. A fifth key answers 400 VALIDATION_FAILED, flat { code, message, details: { fields: [{ field, code: 'unknown_field', message }] } }, and the query does not run. Right, and the frame is the right one. unknown_field and invalid_shape are both in the ADR-0114 vocabulary (packages/spec/src/api/errors.zod.ts, lines 248-249; content/docs/api/error-catalog.mdx lists them). The envelope is the frame this door already answers with everywhere: the selection refusal (analytics-selection-door.ts: { code: 'VALIDATION_FAILED', message, details: { fields } }), the inline selection.measures 400, the "Provide body.dataset" 400 and the "Invalid dataset definition." 400 are all flat. The sibling /analytics/query is a dispatcher route and answers through errorResponseBase as { success: false, error: { code, message, details } }; the only thing the dev borrowed from it is the sentence opening (Unrecognized key(s) on this dataset query: …, the strictObject surface phrasing). A nested frame here would have been the first nested refusal on a door whose five other refusals are flat, and @objectstack/client.analytics.queryDataset is documented as the one analytics method with no envelope (A sixth client-SDK erasure spelling, larger than the other five combined: 43 exported methods return res.json() directly, whose lib.dom type is Promise< any > #12104 / client.analytics.query / meta / explain and automation.trigger hand callers the RAW dispatcher envelope while every sibling unwraps it — one SDK, two calling conventions #13079). The console's readAnalyticsErrorEnvelope reads both the flat and the wrapped frame, so the refusal's code and sentence reach the dashboard banner either way. The deviation is correct.
  3. Remedy derivation. Right. runtimeFilter and the three filter near-misses (filter, filters, where) say selection.runtimeFilter; any other key in DatasetSelectionSchema.shape says selection.KEY. .shape is reachable: lazySchema is a Proxy whose get forwards every non-_zod property to the resolved real schema, and strictObject returns closedObject(z.object(shape, …).strict()), whose constructor calls ZodObject.init, so .shape is the declared member map (dimensions, measures, runtimeFilter, timeDimensions, dateGranularity, order, limit, offset, compareTo, totals, timezone). The rest pin's "misplaced dimensions" case exercises that path through the real route. The three near-miss spellings are exactly the three the spec curates as runtimeFilter aliases one level down (analytics.zod.ts lines 632-634), so the two levels agree today.
  4. dataset beside datasetName answers 400 VALIDATION_FAILED naming both (invalid_shape), before any lookup or query. Right on the ambiguity, with one edge that is wider than the changeset states (see ④, should-fix). Before: the handler's let dataset = body.dataset; if (!dataset && body.datasetName) … ran the inline definition and dropped the name silently. The census (re-run, item 6) found no producer sending both, so the delta's acceptance 3 says refuse it in this PR, and it does.
  5. No published surface moves. datasetQueryBodyRefusal and DATASET_QUERY_BODY_KEYS are new exports of a module packages/rest/src/index.ts does not re-export (same posture as analytics-selection-door.ts); no packages/spec edit, no ui/ edit, no @objectstack/client signature change. Clause-②: no (narrowing) is the right declaration; Metadata-protocol: none is right (nothing an author writes in metadata changes).
  6. Producer census, re-run independently on the head tree. The dev's table holds. @objectstack/client.analytics.queryDataset (packages/client/src/index.ts:2548) is typed { dataset?, datasetName?, selection, previewDrafts? } and posts the payload as given. The console adapter (ui/packages/data-objectstack/src/index.ts ~7164) builds the body as a ternary, { datasetName, selection, previewDrafts?: true } or { dataset, selection, previewDrafts?: true }, never both, never a fifth key; DatasetPreview.tsx goes through that adapter. @objectstack/verify dashboard-window.ts:773 sends { datasetName, selection }. The ten existing dogfood suites send dataset or datasetName, selection, with runtimeFilter only inside selection. The docs example (analytics.mdx lines 259-263) sends { datasetName, selection }; data-api.mdx, cli.mdx, services-checklist.mdx and the generated references/api/analytics.mdx only name the route. MCP (packages/mcp), service-ai, the CLI, packages/plugins and apps/** have no caller of this door. No producer sends a key outside the four, and none sends both dataset and datasetName.

② Semver level

.changeset/29156-dataset-query-body-unknown-keys.md: @objectstack/rest patch, Clause-②: no (narrowing) and Metadata-protocol: none on line-initial positions, a BREAKING banner with Before / Now / Unchanged / Who sees it bullets and the remedy, and exactly one adr-0087: not-required (no-migration-prescription) marker (a category check-adr-0087-registration.mjs lists). That is the level AGENTS.md Post-Task item 3 prescribes under ruling A (#25105): the level follows the metadata-protocol kind alone, none takes patch, and "a non-protocol change, widening or breaking, ships patch and keeps its BREAKING banner and ADR-0087 disposition." The PR title carries the !, and the PR body's two declaration lines match the changeset's. Check Changeset on this head is green (completed · success). The disposition is right: the refusal names each key and where it belongs, so a migration would have nothing to rewrite. Consistent.

③ Boundary flags

  • Claim amendment (Clause-②: no → no (narrowing)). Right: the diff refuses bodies that answered 200, which is a narrowing on a REST data door; the claim amendment 6574727839 aligns the claim with the PR and changeset. Nothing further.
  • Envelope deviation (flat frame, not the dispatcher's nested one). Answered in ① item 2: the flat frame is the only consistent choice on this door; the delta's "in the sibling door's frame" is satisfied by the sentence opening, and the nesting would have split one door into two dialects. Accepted.
  • PR attribution (assignee / reviewer via MCP not set). Not a contract matter; the standard order forbids MCP writes, so the omission is correct. No action.
  • Open question: rest-side near-miss table (A) vs exporting spec's alias map (B). A. The three spellings answer a misplaced key one level up, which is this door's own remedy, not the spec's unknown-key suggester; exporting the alias map would be a new public export of @objectstack/spec (Clause-②: yes, a spec card) in exchange for three strings that the spec's own alias block already pins by name. If the spec grows a fourth alias for runtimeFilter, the rest table will lag by one word, which is a prose-level drift, not a silent widening. Keep A; B only if a third surface starts reading the aliases.
  • Out-of-scope note (the route's query string is not a closed set; only ?preview multiplicity is judged). Correctly left out: the card is the body, and the query-string allowlist is converted per lane under the ingress policy. Noted, not escalated.
  • Author: PR user.login is objectstack-fleet[bot]; no second author to name.
  • CI at review time: Check Changeset, Governed Surface Queue Guard, PR-body guards, Lint · PM dispatch-gates, Dogfood Regression Gate, Test UI, Spec property liveness, Type Check · source gates, Check PR Size, Check Documentation Links are green; Test Core (1-8/8), Build Core, Lint · ESLint, Type Check · workspace, Live E2E UI were still in progress. This verdict is on the diff; the all-green landing gate is the seat's check before queueing.

④ Design judgment

packages/rest/src/analytics-dataset-body-door.ts and analytics-routes.ts: the narrowing holds.

  • Refusal before any query runs. The door is called right after const body = req.body ?? {}, after enforceAuth and the 501 service check (so an unauthenticated or service-less caller keeps its earlier answer, as before) and before the selection.measures check, datasetSelectionRefusal, refuseRepeatedQueryParams, the previewDrafts read, the getMetaItems lookup, the DatasetSchema parse and queryDataset. Both refusals return before getMetaItems and queryDataset; the rest pin asserts queryDataset was never called on every refusal and getMetaItems was never called on the ambiguous body. The dogfood pin proves it over HTTP on the showcase (400 with the remedy where selection.runtimeFilter on the same stack answers 0).
  • Valid bodies byte-unchanged. The door is validation-only: it returns undefined or a refusal, never a parsed object, and the handler keeps reading body.selection, body.previewDrafts, body.dataset, body.datasetName off the same object. A non-object body (array, string, null) passes the door as undefined and lands in the pre-existing selection.measures 400, as before. The §3 controls assert the service receives the caller's selection (with runtimeFilter intact) and the resolved dataset, and that the selection-level refusal and the "Provide body.dataset" sentence are unchanged.
  • previewDrafts semantics unchanged. The key is in the closed set; its read (body.previewDrafts === true || req.query?.preview === 'draft', gated by mayReadPendingDrafts(context)) is untouched and still sits after the door, so the ?preview=draft query-string path and the draft-read admission are as they were. The value is not judged (a previewDrafts: 'yes' is still ignored as not-true), which is the pre-existing behaviour and outside this card.
  • The remedy cannot point at a dead key. remedyFor reads DatasetSelectionSchema.shape, which holds only the members the selection accepts; the spec's guidance block already tells a caller who puts dataset, datasetName or previewDrafts inside selection to move them up, so the two doors now describe one body shape from both sides.
  • Message determinism. Unknown keys are sorted before the sentence and the fields[] are built, so a multi-key refusal reads the same whatever order the adapter built the object in (pinned).

Should-fix (not a FAIL):

  1. The ambiguity test is wider than the handler's old branch by one degenerate body. datasetQueryBodyRefusal treats datasetName as supplied when it is !== undefined && !== null, while the handler's branch it replaces (if (!dataset && body.datasetName)) treats a falsy name ('', 0, false) as absent. So { dataset: {...}, datasetName: '' } ran the inline definition before and answers 400 now, one body more than the changeset's "Before" bullet describes ("dataset beside datasetName ran the inline definition"). No producer sends it (the console's ternary never emits a name beside dataset; the client passes the caller's payload through), so it changes no real caller, but the refusal set and the handler's own notion of "a name was supplied" should be one test. Either read record.datasetName by the handler's truthiness (then { dataset, datasetName: '' } keeps running inline and the "Before" bullet is exact) or say in the changeset that an empty-string name beside dataset is now refused too. A follow-up commit on this PR, or a note, is enough.
  2. The near-miss table and the spec's alias block are two copies of three words. Accepted as A in ③; a one-line comment in analytics.zod.ts beside the where / filter / filters aliases naming analytics-dataset-body-door.ts as the other reader would make the next spec edit find the rest table. Not required for this PR (no packages/spec edit on this card).

Nothing here takes a declared capability away from a consumer: every body the census found a producer sending is served as before and reaches the service with the same arguments, and the only bodies refused are ones the spec's own DatasetSelectionSchema guidance already describes as misplaced.

Implemented-by: claude/project-thread-9bs2fu
Reviewed-by: session_01ADHkmQxwoGb5fSnrFkpxmt

VERDICT: PASS

…ity check

A falsy datasetName beside dataset ran inline before the door and runs
inline again; only a truthy name beside a truthy dataset is ambiguous.

Claude-Session: https://claude.ai/code/session_01ADHkmQ6M1P18JzkN4aDNPb
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 9b9d5b7b04d69e7dc908254deca27529d35a5b9d
Local-runs: none
Scope: packages/rest/src/analytics-dataset-body-door.ts, packages/rest/src/analytics-dataset-body-door.test.ts (the two paths the delta moves), packages/rest/src/analytics-routes.ts, packages/qa/dogfood/test/analytics-dataset-body-door.dogfood.test.ts (byte-identical to the reviewed head, per the delta's diff-stat) + packages/rest/src/analytics-routes.ts (the handler branch the new condition cites, let dataset = body.dataset; if (!dataset && body.datasetName)), .changeset/29156-dataset-query-body-unknown-keys.md and content/docs/data-modeling/analytics.mdx (prose faces, read for whether the Before bullet is now exact), the ten dogfood suites that post to this door (read for a body carrying both keys or a falsy name), the PR body as it stands.

This is the delta re-review of the findings-only commit on top of the full review at dbf091e2 (record comment 6574905951, PASS, should-fix 1). The new head is one commit, a fast-forward of dbf091e2 (git merge-base --is-ancestor holds), and git diff dbf091e2..9b9d5b7b touches 2 files, 18 insertions and 5 deletions: the door module's doc comment, the one ambiguity condition, and two pins. Judged on that diff, not on the dev's account of it.

① Derived judgments

  1. The ambiguity condition is now exactly the handler's truthiness on both keys. Right. The door reads if (record.dataset && record.datasetName), where record is the same body object (req.body ?? {}) the handler then reads, cast and not copied. The handler's branch is let dataset = body.dataset; if (!dataset && body.datasetName) { ...saved lookup... }: a truthy dataset wins, a falsy dataset beside a truthy datasetName runs the saved definition, and both falsy falls to the pre-existing "Provide body.dataset" 400. The door's refusal fires only in the one cell the handler never distinguished (both truthy), and passes every other cell through to the handler unchanged: { dataset: {...}, datasetName: '' | 0 | false | null | absent } runs inline, { dataset: null | undefined | false, datasetName: 'x' } runs the saved dataset. The reviewed head's !== undefined && !== null refused { dataset, datasetName: '' } (and 0, false), one cell wider than the handler; that cell is the whole behavioural delta between dbf091e2 and 9b9d5b7b, and it moves back to the pre-PR answer (200, inline). Should-fix 1 of the full record is closed by its first option.
  2. Nothing else moves. The unknown-key branch, DATASET_QUERY_BODY_KEYS, the remedy table, the sorted fields[], the flat { code, message, details: { fields } } envelope on both refusals, the invalid_shape and unknown_field codes, the two messages and the call site in analytics-routes.ts are byte-identical to dbf091e2. The changeset and the doc page are untouched by the delta; the changeset's Before bullet ("A body naming both dataset and datasetName ran the inline definition and dropped the name without a word") is now exact rather than one body narrow, and its Unchanged bullet (null dataset beside a name still runs the saved dataset) still holds. No published surface moves: no packages/spec, no ui/, no @objectstack/client edit; the module is still not re-exported from packages/rest/src/index.ts.
  3. The pins pin it. The new loop posts { dataset: inlineDataset, datasetName, selection } for datasetName in '' and null through the real route and asserts 200, queryDataset called once with the inline measures (revenue), and getMetaItems never called. Under the reviewed head's condition the '' case answers 400 ('' !== undefined && '' !== null), so that pin reds on exactly the cell the delta changes; the null case was already green under the old condition and guards the other spelling of "absent" against a future presence check ('datasetName' in record). The existing "both supplied" refusal pin (truthy name) and the null-dataset control still stand, so the three cells of the truthiness table are each pinned from the inside. File count: 16 it( lines, two loops (3 near-miss keys, 2 falsy names) → 19 cases, matching the body's "19 of 19". The dev's ablation reading (old condition back: 1 failed, 18 passed) is consistent with this reading; not re-run here.
  4. Not re-running the dogfood battery is acceptable for this delta. The only body whose answer differs between dbf091e2 and 9b9d5b7b is a truthy dataset beside a falsy-but-present datasetName. Read on the head tree: the four cases in analytics-dataset-body-door.dogfood.test.ts send datasetName alone; analytics-inline-dataset-isolation and analytics-inline-dataset-admission are the only dogfood files naming both keys, and each does so in separate bodies (saved reads with datasetName alone, the probes and refusals with dataset alone); the other seven suites send one key each. No dogfood body sends both keys and none sends datasetName: '', 0, false or null (grep over packages/qa/dogfood, packages/rest/src, packages/verify, ui/packages/data-objectstack, packages/client/src: no hit). The full-battery reading at dbf091e2 therefore covers this head for every body it exercises; Dogfood Regression Gate is green on 9b9d5b7b as well.

② Semver level

Unchanged by the delta and still right: @objectstack/rest patch, Clause-②: no (narrowing) and Metadata-protocol: none line-initial in the changeset and the PR body, BREAKING banner, one adr-0087: not-required (no-migration-prescription) marker. The delta narrows nothing further; it gives back one degenerate body the reviewed head had over-refused, so the declared narrowing is now no wider than the changeset states. Check Changeset on 9b9d5b7b: one completed run success, a second run in progress at read time.

③ Boundary flags

  • Should-fix 1 of record 6574905951 (over-refusal of { dataset, datasetName: '' }). Closed by the delta, option one (handler truthiness), with pins. Nothing further.
  • Should-fix 2 (a pointer comment beside the spec's where / filter / filters aliases). Untouched, as expected: it needs a packages/spec edit and was not required for this PR.
  • PR body (read live). The rule sentence is accurate ("A truthy dataset beside a truthy datasetName (the handler's own truthiness) answers 400 ... A falsy datasetName beside dataset still runs inline, and a falsy dataset beside a name still runs the saved dataset, as before"), the Verification section names head 9b9d5b7b as a fast-forward on dbf091e2, carries the 19-of-19 pin reading and the '' ablation, and states the dogfood battery was not re-run because no dogfood test sends both keys, which ① item 4 confirms. One stale sentence remains: the Tests section still reads "(new, 17 cases, through the real route)" and describes §2 as "the refusal ... and its null-dataset control", while the file now holds 19 cases and §2 carries three controls ('' name, null name, null dataset). Body-only, the seat's to correct; not a contract matter.
  • CI on 9b9d5b7b at read time: every required context is green (Lint & Repo Gates, TypeScript Type Check, Test Core and its 8 shards, Build Core, Governed Surface Queue Guard, Test UI, Live E2E UI success; Lint UI, Type Check UI, Build & E2E UI skipped by path filter), Dogfood Regression Gate success, Check Changeset success with a second run still in progress. This verdict is on the diff; the all-green landing gate is the seat's check before queueing.
  • Author: PR user.login is objectstack-fleet[bot]; no second author to name.

④ Design judgment

packages/rest/src/analytics-dataset-body-door.ts: the narrowing holds, and is now exactly the one declared. The door's notion of "a name was supplied" and the handler's are one test, so the refusal set equals the set of bodies in which the handler would have silently dropped a name it would otherwise have resolved: before the PR, a truthy datasetName beside a truthy dataset was looked up only if dataset were absent, and otherwise ignored; now it is refused. A falsy name was never resolvable by that branch (the lookup is gated on body.datasetName being truthy), so refusing it beside dataset would have been a new rule with no silent-drop behind it, which is what the reviewed head did and the delta undoes. Refusal still precedes getMetaItems, the DatasetSchema parse and queryDataset; valid bodies are still passed through byte-unchanged (the door returns undefined or a refusal, never a parsed object). The rewritten doc comment states the rule the code implements and names the handler's branch verbatim.

packages/rest/src/analytics-dataset-body-door.test.ts: the two pins are controls (200, inline, no lookup) named as such, generated from a ['', null] as const loop so the two spellings of "absent" share one assertion body; the '' pin is the one that fails under the reviewed head's condition. Enough to hold the line; 0 and false are the same truthiness and need no pin of their own.

Nothing in the delta takes a declared capability away from a consumer; it returns one, and the only consumer-visible difference from dbf091e2 is a body no producer sends answering as it did before the PR.

Should-fix (not a FAIL): update the PR body's Tests sentence to 19 cases and name the two falsy-name controls in §2, so the Tests and Verification sections agree.

Implemented-by: claude/project-thread-9bs2fu
Reviewed-by: session_01ADHkmQxwoGb5fSnrFkpxmt

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt

席位核对

ACCEPT · domain:server seat 2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt · 2026-11-02T11:11Z · head 9b9d5b7b · claimed by seat 2 on #29156 (claim 6573479102, amendment 6574727839)

The maintainer's words for this seat, verbatim: 「接手所有开发,包括跟进之前未完成的开发落地。」

  1. Shape: a draft against main, Fixes #29156, Clause-②: no (narrowing) and Metadata-protocol: none at line start in the body and the changeset. check-governed-merges --pr 29251: NOT governed. No packages/spec file, no ui/**. The ! title, BREAKING banner and adr-0087: not-required (no-migration-prescription) marker are present (ruling A changeset levels: minor only when the metadata protocol changes (added / narrowed / semantic), patch otherwise; release notes grouped by what the reader must do (maintainer ruling A, 2026-10-21) #25105).
  2. Contract review: PASS at dbf091e2 (6574905951). The delta re-review PASSed at 9b9d5b7b (6575642727). The delta closes should-fix 1: the dataset + datasetName refusal now uses the handler's own truthiness, so it is no wider than the changeset's Before bullet. The seat brought the PR body's rule sentence, pin count and Verification head up to date with the new head.
  3. Acceptance against the delta: (1) unknown top-level keys answer 400 VALIDATION_FAILED naming them, and a misplaced runtimeFilter / filter / filters / where gets "did you mean selection.runtimeFilter". (2) The closed set is exactly the handler's four read points. The producer census found no real producer sending a fifth key or both keys; the reviewer re-ran it independently. (3) Both keys truthy answers 400 naming both. (4) Red first on main: 10 of 17 red. Ablation reds and restores to HEAD. The full dogfood battery ran at dbf091e2 with 0 failures, and it is not owed again for the delta: no dogfood body sends both keys, as the reviewer measured.
  4. Gates on this head at ACCEPT time: 40 success, 16 skipped, 0 red, 2 still running. Every required context is green per the delta record. Auto-merge waits for the rest, and the merge queue re-runs CI against current main.
  5. Open question: answered A. The near-miss table stays in rest. A cross-reference comment in spec's analytics.zod.ts is a nicety for the next spec edit, not a card.
  6. Serial: none. No open PR touches analytics-routes.ts. Landing: pr_ready + automerge_enable through the relay.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 11:12
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 2, 2026 11:12
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit fa5e863 Nov 2, 2026
59 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-9bs2fu branch November 2, 2026 12:05
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt

Landing record · PR #29251

domain:server seat 2 · session_01ADHkmQxwoGb5fSnrFkpxmt · 2026-11-02T12:07Z · card #29156

Reading Probe Result
Merged PR #29251 state merged, merge commit fa5e863c30
On main git merge-base --is-ancestor fa5e863c30 origin/main yes
Queue refs git ls-remote origin refs/heads/gh-readonly-queue/* matching 29251 0
Content git grep -c datasetQueryBodyRefusal origin/main -- packages/rest/src/analytics-routes.ts present

Release: ships patch for rest at the next release, with the BREAKING banner its changeset carries.


Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation size/l tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

1 participant